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:
403
sdk/src/phase-prompt.test.ts
Normal file
403
sdk/src/phase-prompt.test.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js';
|
||||
import { PhaseType } from './types.js';
|
||||
import type { ContextFiles, ParsedPlan, PlanFrontmatter } from './types.js';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'gsd-prompt-'));
|
||||
}
|
||||
|
||||
function makeWorkflowContent(purpose: string, steps: string[]): string {
|
||||
const stepBlocks = steps
|
||||
.map((s, i) => `<step name="step_${i + 1}">\n${s}\n</step>`)
|
||||
.join('\n\n');
|
||||
return `<purpose>\n${purpose}\n</purpose>\n\n<process>\n${stepBlocks}\n</process>`;
|
||||
}
|
||||
|
||||
function makeAgentDef(name: string, tools: string, role: string): string {
|
||||
return `---\nname: ${name}\ntools: ${tools}\n---\n\n<role>\n${role}\n</role>`;
|
||||
}
|
||||
|
||||
function makeParsedPlan(overrides?: Partial<ParsedPlan>): ParsedPlan {
|
||||
return {
|
||||
frontmatter: {
|
||||
phase: 'execute',
|
||||
plan: 'test-plan',
|
||||
type: 'feature',
|
||||
wave: 1,
|
||||
depends_on: [],
|
||||
files_modified: [],
|
||||
autonomous: true,
|
||||
requirements: [],
|
||||
must_haves: { truths: [], artifacts: [], key_links: [] },
|
||||
} as PlanFrontmatter,
|
||||
objective: 'Test objective',
|
||||
execution_context: [],
|
||||
context_refs: [],
|
||||
tasks: [],
|
||||
raw: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── extractBlock tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('extractBlock', () => {
|
||||
it('extracts content from a simple block', () => {
|
||||
const content = '<purpose>\nDo the thing.\n</purpose>';
|
||||
expect(extractBlock(content, 'purpose')).toBe('Do the thing.');
|
||||
});
|
||||
|
||||
it('extracts content from block with attributes', () => {
|
||||
const content = '<step name="init" priority="first">\nLoad context.\n</step>';
|
||||
expect(extractBlock(content, 'step')).toBe('Load context.');
|
||||
});
|
||||
|
||||
it('returns empty string for missing block', () => {
|
||||
const content = '<purpose>Something</purpose>';
|
||||
expect(extractBlock(content, 'role')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts multiline content', () => {
|
||||
const content = '<role>\nLine 1\nLine 2\nLine 3\n</role>';
|
||||
expect(extractBlock(content, 'role')).toBe('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSteps', () => {
|
||||
it('extracts multiple steps from process content', () => {
|
||||
const process = `
|
||||
<step name="init">Initialize</step>
|
||||
<step name="execute">Run tasks</step>
|
||||
<step name="verify">Check results</step>`;
|
||||
|
||||
const steps = extractSteps(process);
|
||||
expect(steps).toHaveLength(3);
|
||||
expect(steps[0]).toEqual({ name: 'init', content: 'Initialize' });
|
||||
expect(steps[1]).toEqual({ name: 'execute', content: 'Run tasks' });
|
||||
expect(steps[2]).toEqual({ name: 'verify', content: 'Check results' });
|
||||
});
|
||||
|
||||
it('returns empty array for no steps', () => {
|
||||
expect(extractSteps('no steps here')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles steps with priority attributes', () => {
|
||||
const process = '<step name="init" priority="first">\nDo first.\n</step>';
|
||||
const steps = extractSteps(process);
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0].name).toBe('init');
|
||||
expect(steps[0].content).toBe('Do first.');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PromptFactory tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('PromptFactory', () => {
|
||||
let tempDir: string;
|
||||
let workflowsDir: string;
|
||||
let agentsDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir();
|
||||
workflowsDir = join(tempDir, 'workflows');
|
||||
agentsDir = join(tempDir, 'agents');
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeFactory(): PromptFactory {
|
||||
return new PromptFactory({
|
||||
gsdInstallDir: tempDir,
|
||||
agentsDir,
|
||||
});
|
||||
}
|
||||
|
||||
describe('buildPrompt', () => {
|
||||
it('assembles research prompt with role + purpose + process + context', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'research-phase.md'),
|
||||
makeWorkflowContent('Research the phase.', ['Gather info', 'Analyze findings']),
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'gsd-phase-researcher.md'),
|
||||
makeAgentDef('gsd-phase-researcher', 'Read, Grep, Bash', 'You are a researcher.'),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = {
|
||||
state: '# State\nproject: test',
|
||||
roadmap: '# Roadmap\nphases listed',
|
||||
};
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
|
||||
|
||||
expect(prompt).toContain('## Role');
|
||||
expect(prompt).toContain('You are a researcher.');
|
||||
expect(prompt).toContain('## Purpose');
|
||||
expect(prompt).toContain('Research the phase.');
|
||||
expect(prompt).toContain('## Process');
|
||||
expect(prompt).toContain('Gather info');
|
||||
expect(prompt).toContain('## Context');
|
||||
expect(prompt).toContain('# State');
|
||||
expect(prompt).toContain('# Roadmap');
|
||||
expect(prompt).toContain('## Phase Instructions');
|
||||
});
|
||||
|
||||
it('assembles plan prompt with all context files', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'plan-phase.md'),
|
||||
makeWorkflowContent('Plan the implementation.', ['Break down tasks']),
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'gsd-planner.md'),
|
||||
makeAgentDef('gsd-planner', 'Read, Write, Bash', 'You are a planner.'),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = {
|
||||
state: '# State',
|
||||
roadmap: '# Roadmap',
|
||||
context: '# Context',
|
||||
research: '# Research',
|
||||
requirements: '# Requirements',
|
||||
};
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Plan, null, contextFiles);
|
||||
|
||||
expect(prompt).toContain('You are a planner.');
|
||||
expect(prompt).toContain('Plan the implementation.');
|
||||
expect(prompt).toContain('# State');
|
||||
expect(prompt).toContain('# Research');
|
||||
expect(prompt).toContain('# Requirements');
|
||||
expect(prompt).toContain('executable plans');
|
||||
});
|
||||
|
||||
it('delegates execute phase with plan to buildExecutorPrompt', async () => {
|
||||
await writeFile(
|
||||
join(agentsDir, 'gsd-executor.md'),
|
||||
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const plan = makeParsedPlan({ objective: 'Build the auth system' });
|
||||
const contextFiles: ContextFiles = { state: '# State' };
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles);
|
||||
|
||||
// buildExecutorPrompt produces structured output with ## Objective
|
||||
expect(prompt).toContain('## Objective');
|
||||
expect(prompt).toContain('Build the auth system');
|
||||
expect(prompt).toContain('## Role');
|
||||
expect(prompt).toContain('You are an executor.');
|
||||
});
|
||||
|
||||
it('handles execute phase without plan (non-delegation path)', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'execute-plan.md'),
|
||||
makeWorkflowContent('Execute the plan.', ['Run tasks']),
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'gsd-executor.md'),
|
||||
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = { state: '# State' };
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles);
|
||||
|
||||
// Falls through to general assembly path
|
||||
expect(prompt).toContain('## Role');
|
||||
expect(prompt).toContain('You are an executor.');
|
||||
expect(prompt).toContain('## Purpose');
|
||||
expect(prompt).toContain('Execute the plan.');
|
||||
});
|
||||
|
||||
it('assembles verify prompt with phase instructions', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'verify-phase.md'),
|
||||
makeWorkflowContent('Verify phase goals.', ['Check artifacts', 'Run tests']),
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'gsd-verifier.md'),
|
||||
makeAgentDef('gsd-verifier', 'Read, Bash, Grep', 'You are a verifier.'),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = {
|
||||
state: '# State',
|
||||
roadmap: '# Roadmap',
|
||||
requirements: '# Requirements',
|
||||
};
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Verify, null, contextFiles);
|
||||
|
||||
expect(prompt).toContain('You are a verifier.');
|
||||
expect(prompt).toContain('Verify phase goals.');
|
||||
expect(prompt).toContain('goal achievement');
|
||||
});
|
||||
|
||||
it('assembles discuss prompt without agent role (no dedicated agent)', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'discuss-phase.md'),
|
||||
makeWorkflowContent('Discuss implementation decisions.', ['Identify areas']),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = { state: '# State' };
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
|
||||
|
||||
// Discuss has no agent, so no Role section
|
||||
expect(prompt).not.toContain('## Role');
|
||||
expect(prompt).toContain('## Purpose');
|
||||
expect(prompt).toContain('Discuss implementation decisions.');
|
||||
expect(prompt).toContain('## Phase Instructions');
|
||||
expect(prompt).toContain('Extract implementation decisions');
|
||||
});
|
||||
|
||||
it('handles missing workflow file gracefully', async () => {
|
||||
// No workflow files on disk
|
||||
await writeFile(
|
||||
join(agentsDir, 'gsd-phase-researcher.md'),
|
||||
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.'),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = { state: '# State' };
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
|
||||
|
||||
// Should still produce a prompt with role and context
|
||||
expect(prompt).toContain('## Role');
|
||||
expect(prompt).toContain('## Context');
|
||||
expect(prompt).not.toContain('## Purpose');
|
||||
});
|
||||
|
||||
it('handles missing agent def gracefully', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'research-phase.md'),
|
||||
makeWorkflowContent('Research the phase.', ['Gather info']),
|
||||
);
|
||||
// No agent file on disk
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = { state: '# State' };
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
|
||||
|
||||
expect(prompt).not.toContain('## Role');
|
||||
expect(prompt).toContain('## Purpose');
|
||||
expect(prompt).toContain('Research the phase.');
|
||||
});
|
||||
|
||||
it('omits empty context section when no files provided', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'discuss-phase.md'),
|
||||
makeWorkflowContent('Discuss things.', ['Talk']),
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const contextFiles: ContextFiles = {};
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
|
||||
|
||||
expect(prompt).not.toContain('## Context');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadWorkflowFile', () => {
|
||||
it('loads existing workflow file', async () => {
|
||||
await writeFile(
|
||||
join(workflowsDir, 'research-phase.md'),
|
||||
'workflow content',
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const content = await factory.loadWorkflowFile(PhaseType.Research);
|
||||
expect(content).toBe('workflow content');
|
||||
});
|
||||
|
||||
it('returns undefined for missing workflow file', async () => {
|
||||
const factory = makeFactory();
|
||||
const content = await factory.loadWorkflowFile(PhaseType.Research);
|
||||
expect(content).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAgentDef', () => {
|
||||
it('loads agent def from agents dir', async () => {
|
||||
await writeFile(
|
||||
join(agentsDir, 'gsd-executor.md'),
|
||||
'agent content',
|
||||
);
|
||||
|
||||
const factory = makeFactory();
|
||||
const content = await factory.loadAgentDef(PhaseType.Execute);
|
||||
expect(content).toBe('agent content');
|
||||
});
|
||||
|
||||
it('returns undefined for phases with no agent (discuss)', async () => {
|
||||
const factory = makeFactory();
|
||||
const content = await factory.loadAgentDef(PhaseType.Discuss);
|
||||
expect(content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to project agents dir', async () => {
|
||||
const projectAgentsDir = join(tempDir, 'project-agents');
|
||||
await mkdir(projectAgentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(projectAgentsDir, 'gsd-executor.md'),
|
||||
'project agent content',
|
||||
);
|
||||
|
||||
const factory = new PromptFactory({
|
||||
gsdInstallDir: tempDir,
|
||||
agentsDir,
|
||||
projectAgentsDir,
|
||||
});
|
||||
|
||||
const content = await factory.loadAgentDef(PhaseType.Execute);
|
||||
expect(content).toBe('project agent content');
|
||||
});
|
||||
|
||||
it('prefers user agents dir over project agents dir', async () => {
|
||||
const projectAgentsDir = join(tempDir, 'project-agents');
|
||||
await mkdir(projectAgentsDir, { recursive: true });
|
||||
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
|
||||
await writeFile(join(projectAgentsDir, 'gsd-executor.md'), 'project agent');
|
||||
|
||||
const factory = new PromptFactory({
|
||||
gsdInstallDir: tempDir,
|
||||
agentsDir,
|
||||
projectAgentsDir,
|
||||
});
|
||||
|
||||
const content = await factory.loadAgentDef(PhaseType.Execute);
|
||||
expect(content).toBe('user agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PHASE_WORKFLOW_MAP', () => {
|
||||
it('maps all phase types to workflow filenames', () => {
|
||||
for (const phase of Object.values(PhaseType)) {
|
||||
expect(PHASE_WORKFLOW_MAP[phase]).toBeDefined();
|
||||
expect(PHASE_WORKFLOW_MAP[phase]).toMatch(/\.md$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('execute phase maps to execute-plan.md (not execute-phase.md)', () => {
|
||||
expect(PHASE_WORKFLOW_MAP[PhaseType.Execute]).toBe('execute-plan.md');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user