feat: GSD SDK — headless CLI with init + auto commands (#1407)
* test: Bootstrapped sdk/ as TypeScript ESM package with full GSD-1 PLAN.… - "sdk/package.json" - "sdk/tsconfig.json" - "sdk/vitest.config.ts" - "sdk/src/types.ts" - "sdk/src/plan-parser.ts" - "sdk/src/plan-parser.test.ts" GSD-Task: S01/T01 * test: Implemented config reader and gsd-tools bridge with 25 unit tests… - "sdk/src/config.ts" - "sdk/src/config.test.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/gsd-tools.test.ts" GSD-Task: S01/T02 * test: Built prompt-builder, session-runner, and GSD class — 85 total un… - "sdk/src/prompt-builder.ts" - "sdk/src/prompt-builder.test.ts" - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/types.ts" GSD-Task: S01/T03 * test: Created E2E integration test with fixtures proving full SDK pipel… - "sdk/src/e2e.integration.test.ts" - "sdk/test-fixtures/sample-plan.md" - "sdk/test-fixtures/.planning/config.json" - "sdk/test-fixtures/.planning/STATE.md" - "vitest.config.ts" - "tsconfig.json" GSD-Task: S01/T04 * test: Added PhaseType/GSDEventType enums, 16-variant GSDEvent union, GS… - "sdk/src/types.ts" - "sdk/src/event-stream.ts" - "sdk/src/logger.ts" - "sdk/src/event-stream.test.ts" - "sdk/src/logger.test.ts" GSD-Task: S02/T01 * test: Built ContextEngine for phase-aware context file resolution, getT… - "sdk/src/context-engine.ts" - "sdk/src/tool-scoping.ts" - "sdk/src/phase-prompt.ts" - "sdk/src/context-engine.test.ts" - "sdk/src/tool-scoping.test.ts" - "sdk/src/phase-prompt.test.ts" GSD-Task: S02/T02 * test: Wired event stream into session runner, added onEvent()/addTransp… - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/e2e.integration.test.ts" GSD-Task: S02/T03 * feat: Added PhaseStepType enum, PhaseOpInfo interface, phase lifecycle… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/phase-runner-types.test.ts" GSD-Task: S03/T01 * test: Implemented PhaseRunner state machine with 39 unit tests covering… - "sdk/src/phase-runner.ts" - "sdk/src/phase-runner.test.ts" GSD-Task: S03/T02 * test: Wired PhaseRunner into GSD.runPhase() public API with full re-exp… - "sdk/src/index.ts" - "sdk/src/phase-runner.integration.test.ts" - "sdk/src/phase-runner.ts" GSD-Task: S03/T03 * test: Expanded runVerifyStep with full gap closure cycle (plan → execut… - "sdk/src/types.ts" - "sdk/src/phase-runner.ts" - "sdk/src/phase-runner.test.ts" GSD-Task: S04/T02 * fix: Added 3 integration tests proving phasePlanIndex returns correct t… - "sdk/src/phase-runner.integration.test.ts" - "sdk/src/index.ts" GSD-Task: S04/T03 * test: Add milestone-level types, typed roadmapAnalyze(), GSD.run() orch… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/index.ts" - "sdk/src/milestone-runner.test.ts" GSD-Task: S05/T01 * test: Added CLITransport (structured stdout log lines) and WSTransport… - "sdk/src/cli-transport.ts" - "sdk/src/cli-transport.test.ts" - "sdk/src/ws-transport.ts" - "sdk/src/ws-transport.test.ts" - "sdk/src/index.ts" - "sdk/package.json" GSD-Task: S05/T02 * test: Added gsd-sdk CLI entry point with argument parsing, bin field, p… - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/package.json" GSD-Task: S05/T03 * feat: Add InitNewProjectInfo type, initNewProject()/configSet() GSDTool… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/src/gsd-tools.test.ts" GSD-Task: S01/T01 * chore: Created InitRunner orchestrator with setup → config → PROJECT.md… - "sdk/src/init-runner.ts" - "sdk/src/types.ts" - "sdk/src/index.ts" GSD-Task: S01/T02 * test: Wired InitRunner into CLI main() for full gsd-sdk init dispatch a… - "sdk/src/cli.ts" - "sdk/src/init-runner.test.ts" - "sdk/src/cli.test.ts" GSD-Task: S01/T03 * test: Add PlanCheck step, AI self-discuss, and retryOnce wrapper to Pha… - "sdk/src/types.ts" - "sdk/src/phase-runner.ts" - "sdk/src/session-runner.ts" - "sdk/src/phase-runner.test.ts" - "sdk/src/phase-runner-types.test.ts" GSD-Task: S02/T01 * feat: Rewrite CLITransport with ANSI colors, phase banners, spawn indic… - "sdk/src/cli-transport.ts" - "sdk/src/cli-transport.test.ts" GSD-Task: S02/T02 * test: Add `gsd-sdk auto` command with autoMode config override, USAGE t… - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/src/index.ts" - "sdk/src/types.ts" GSD-Task: S02/T03 * fix: CLI shebang + gsd-tools non-JSON output handling Three bugs found during first real gsd-sdk run: 1. cli.ts shebang was commented out — shell executed JS as bash, triggering ImageMagick's import command instead of Node 2. configSet() called exec() which JSON.parse()d the output, but gsd-tools config-set returns 'key=value' text, not JSON. Added execRaw() method for commands that return plain text. 3. Same JSON parse bug affected commit() (returns git SHA), stateLoad(), verifySummary(), initExecutePhase(), stateBeginPhase(), and phaseComplete(). All switched to execRaw(). Tests updated to match real gsd-tools output format (plain text instead of mocked JSON). 376/376 tests pass.
This commit is contained in:
233
sdk/src/phase-prompt.ts
Normal file
233
sdk/src/phase-prompt.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Phase-aware prompt factory — assembles complete prompts for each phase type.
|
||||
*
|
||||
* Reads workflow .md + agent .md files from disk (D006), extracts structured
|
||||
* blocks (<role>, <purpose>, <process>), and composes system prompts with
|
||||
* injected context files per phase type.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import type { ContextFiles, ParsedPlan } from './types.js';
|
||||
import { PhaseType } from './types.js';
|
||||
import { buildExecutorPrompt, parseAgentRole } from './prompt-builder.js';
|
||||
import { PHASE_AGENT_MAP } from './tool-scoping.js';
|
||||
|
||||
// ─── Workflow file mapping ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps phase types to their workflow file names.
|
||||
*/
|
||||
const PHASE_WORKFLOW_MAP: Record<PhaseType, string> = {
|
||||
[PhaseType.Execute]: 'execute-plan.md',
|
||||
[PhaseType.Research]: 'research-phase.md',
|
||||
[PhaseType.Plan]: 'plan-phase.md',
|
||||
[PhaseType.Verify]: 'verify-phase.md',
|
||||
[PhaseType.Discuss]: 'discuss-phase.md',
|
||||
};
|
||||
|
||||
// ─── XML block extraction ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract content from an XML-style block (e.g., <purpose>...</purpose>).
|
||||
* Returns the trimmed inner content, or empty string if not found.
|
||||
*/
|
||||
export function extractBlock(content: string, tagName: string): string {
|
||||
const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
||||
const match = content.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all <step> blocks from a workflow's <process> section.
|
||||
* Returns an array of step contents with their name attributes.
|
||||
*/
|
||||
export function extractSteps(processContent: string): Array<{ name: string; content: string }> {
|
||||
const steps: Array<{ name: string; content: string }> = [];
|
||||
const stepRegex = /<step\s+name="([^"]*)"[^>]*>([\s\S]*?)<\/step>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = stepRegex.exec(processContent)) !== null) {
|
||||
steps.push({
|
||||
name: match[1],
|
||||
content: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
// ─── PromptFactory class ─────────────────────────────────────────────────────
|
||||
|
||||
export class PromptFactory {
|
||||
private readonly workflowsDir: string;
|
||||
private readonly agentsDir: string;
|
||||
private readonly projectAgentsDir?: string;
|
||||
|
||||
constructor(options?: {
|
||||
gsdInstallDir?: string;
|
||||
agentsDir?: string;
|
||||
projectAgentsDir?: string;
|
||||
}) {
|
||||
const gsdInstallDir = options?.gsdInstallDir ?? join(homedir(), '.claude', 'get-shit-done');
|
||||
this.workflowsDir = join(gsdInstallDir, 'workflows');
|
||||
this.agentsDir = options?.agentsDir ?? join(homedir(), '.claude', 'agents');
|
||||
this.projectAgentsDir = options?.projectAgentsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete prompt for the given phase type.
|
||||
*
|
||||
* For execute phase with a plan, delegates to buildExecutorPrompt().
|
||||
* For other phases, assembles: role + purpose + process steps + context.
|
||||
*/
|
||||
async buildPrompt(
|
||||
phaseType: PhaseType,
|
||||
plan: ParsedPlan | null,
|
||||
contextFiles: ContextFiles,
|
||||
): Promise<string> {
|
||||
// Execute phase with a plan: delegate to existing buildExecutorPrompt
|
||||
if (phaseType === PhaseType.Execute && plan) {
|
||||
const agentDef = await this.loadAgentDef(phaseType);
|
||||
return buildExecutorPrompt(plan, agentDef);
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
// ── Agent role ──
|
||||
const agentDef = await this.loadAgentDef(phaseType);
|
||||
if (agentDef) {
|
||||
const role = parseAgentRole(agentDef);
|
||||
if (role) {
|
||||
sections.push(`## Role\n\n${role}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow purpose + process ──
|
||||
const workflow = await this.loadWorkflowFile(phaseType);
|
||||
if (workflow) {
|
||||
const purpose = extractBlock(workflow, 'purpose');
|
||||
if (purpose) {
|
||||
sections.push(`## Purpose\n\n${purpose}`);
|
||||
}
|
||||
|
||||
const process = extractBlock(workflow, 'process');
|
||||
if (process) {
|
||||
const steps = extractSteps(process);
|
||||
if (steps.length > 0) {
|
||||
const stepBlocks = steps.map((s) => `### ${s.name}\n\n${s.content}`).join('\n\n');
|
||||
sections.push(`## Process\n\n${stepBlocks}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context files ──
|
||||
const contextSection = this.formatContextFiles(contextFiles);
|
||||
if (contextSection) {
|
||||
sections.push(contextSection);
|
||||
}
|
||||
|
||||
// ── Phase-specific instructions ──
|
||||
const phaseInstructions = this.getPhaseInstructions(phaseType);
|
||||
if (phaseInstructions) {
|
||||
sections.push(`## Phase Instructions\n\n${phaseInstructions}`);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow file for a phase type.
|
||||
* Returns the raw content, or undefined if not found.
|
||||
*/
|
||||
async loadWorkflowFile(phaseType: PhaseType): Promise<string | undefined> {
|
||||
const filename = PHASE_WORKFLOW_MAP[phaseType];
|
||||
const filePath = join(this.workflowsDir, filename);
|
||||
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the agent definition for a phase type.
|
||||
* Tries user-level agents dir first, then project-level.
|
||||
* Returns undefined if no agent is mapped or file not found.
|
||||
*/
|
||||
async loadAgentDef(phaseType: PhaseType): Promise<string | undefined> {
|
||||
const agentFilename = PHASE_AGENT_MAP[phaseType];
|
||||
if (!agentFilename) return undefined;
|
||||
|
||||
// Try user-level agents dir first
|
||||
const paths = [join(this.agentsDir, agentFilename)];
|
||||
|
||||
// Then project-level if configured
|
||||
if (this.projectAgentsDir) {
|
||||
paths.push(join(this.projectAgentsDir, agentFilename));
|
||||
}
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
return await readFile(p, 'utf-8');
|
||||
} catch {
|
||||
// Not found at this path, try next
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format context files into a prompt section.
|
||||
*/
|
||||
private formatContextFiles(contextFiles: ContextFiles): string | null {
|
||||
const entries: string[] = [];
|
||||
|
||||
const fileLabels: Record<keyof ContextFiles, string> = {
|
||||
state: 'Project State (STATE.md)',
|
||||
roadmap: 'Roadmap (ROADMAP.md)',
|
||||
context: 'Context (CONTEXT.md)',
|
||||
research: 'Research (RESEARCH.md)',
|
||||
requirements: 'Requirements (REQUIREMENTS.md)',
|
||||
config: 'Config (config.json)',
|
||||
plan: 'Plan (PLAN.md)',
|
||||
summary: 'Summary (SUMMARY.md)',
|
||||
};
|
||||
|
||||
for (const [key, label] of Object.entries(fileLabels)) {
|
||||
const content = contextFiles[key as keyof ContextFiles];
|
||||
if (content) {
|
||||
entries.push(`### ${label}\n\n${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
return `## Context\n\n${entries.join('\n\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get phase-specific instructions that aren't covered by the workflow file.
|
||||
*/
|
||||
private getPhaseInstructions(phaseType: PhaseType): string | null {
|
||||
switch (phaseType) {
|
||||
case PhaseType.Research:
|
||||
return 'Focus on technical investigation. Do not modify source files. Produce RESEARCH.md with findings organized by topic, confidence levels (HIGH/MEDIUM/LOW), and specific recommendations.';
|
||||
case PhaseType.Plan:
|
||||
return 'Create executable plans with task breakdown, dependency analysis, and verification criteria. Each task must have clear acceptance criteria and a done condition.';
|
||||
case PhaseType.Verify:
|
||||
return 'Verify goal achievement, not just task completion. Start from what the phase SHOULD deliver, then verify it actually exists and works. Produce VERIFICATION.md with pass/fail for each criterion.';
|
||||
case PhaseType.Discuss:
|
||||
return 'Extract implementation decisions that downstream agents need. Identify gray areas, capture decisions that guide research and planning.';
|
||||
case PhaseType.Execute:
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { PHASE_WORKFLOW_MAP };
|
||||
Reference in New Issue
Block a user