feat: auto --init flag, headless prompts, and prompt sanitizer (#1417)

* fix: Created 10 headless prompt files (5 workflows + 5 agents) in sdk/p…

- "sdk/prompts/workflows/execute-plan.md"
- "sdk/prompts/workflows/research-phase.md"
- "sdk/prompts/workflows/plan-phase.md"
- "sdk/prompts/workflows/verify-phase.md"
- "sdk/prompts/workflows/discuss-phase.md"
- "sdk/prompts/agents/gsd-executor.md"
- "sdk/prompts/agents/gsd-phase-researcher.md"
- "sdk/prompts/agents/gsd-planner.md"

GSD-Task: S01/T02

* feat: Created prompt-sanitizer.ts, wired headless prompt loading into P…

- "sdk/src/prompt-sanitizer.ts"
- "sdk/src/phase-prompt.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/gsd-tools.test.ts"
- "sdk/src/phase-runner-types.test.ts"

GSD-Task: S01/T01

* test: Added 111 unit tests covering sanitizePrompt(), headless prompt l…

- "sdk/src/prompt-sanitizer.test.ts"
- "sdk/src/headless-prompts.test.ts"
- "sdk/src/phase-prompt.test.ts"

GSD-Task: S01/T03

* feat: Wired sdkPromptsDir preference and sanitizePrompt into InitRunner…

- "sdk/src/init-runner.ts"
- "sdk/package.json"

GSD-Task: S02/T01

* feat: add --init flag to auto command for single-command PRD-to-execution

gsd-sdk auto --init @path/to/prd.md now bootstraps the project (init)
then immediately runs the autonomous phase execution loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add remaining headless prompt files and templates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: Extended init-runner.test.ts with 7 sdkPromptsDir preference and…

- "sdk/src/init-runner.test.ts"

GSD-Task: S02/T03

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES
2026-03-26 22:40:34 -06:00
committed by GitHub
parent 0fde35acf9
commit 89f95c43ba
38 changed files with 5146 additions and 49 deletions

View File

@@ -198,6 +198,51 @@ describe('parseCliArgs', () => {
expect(result.initInput).toBeUndefined();
});
// ─── Auto --init parsing ──────────────────────────────────────────────
it('parses auto --init with @file', () => {
const result = parseCliArgs(['auto', '--init', '@prd.md']);
expect(result.command).toBe('auto');
expect(result.init).toBe('@prd.md');
expect(result.initInput).toBeUndefined();
});
it('parses auto --init with raw text', () => {
const result = parseCliArgs(['auto', '--init', 'build a todo app']);
expect(result.command).toBe('auto');
expect(result.init).toBe('build a todo app');
});
it('parses auto --init with other options', () => {
const result = parseCliArgs([
'auto',
'--init', '@spec.md',
'--project-dir', '/tmp/proj',
'--model', 'claude-sonnet-4-6',
'--max-budget', '25',
]);
expect(result.command).toBe('auto');
expect(result.init).toBe('@spec.md');
expect(result.projectDir).toBe('/tmp/proj');
expect(result.model).toBe('claude-sonnet-4-6');
expect(result.maxBudget).toBe(25);
});
it('init is undefined when --init not provided', () => {
const result = parseCliArgs(['auto']);
expect(result.init).toBeUndefined();
});
it('init is undefined for non-auto commands', () => {
const result = parseCliArgs(['run', 'build auth']);
expect(result.init).toBeUndefined();
});
});
// ─── resolveInitInput tests ──────────────────────────────────────────────────
@@ -219,6 +264,7 @@ describe('resolveInitInput', () => {
command: 'init',
prompt: undefined,
initInput: undefined,
init: undefined,
projectDir: tmpDir,
wsPort: undefined,
model: undefined,
@@ -307,4 +353,9 @@ describe('USAGE', () => {
it('describes auto as autonomous lifecycle', () => {
expect(USAGE).toMatch(/auto\s+.*autonomous/i);
});
it('documents --init option', () => {
expect(USAGE).toContain('--init');
expect(USAGE).toContain('Bootstrap from a PRD');
});
});

View File

@@ -23,6 +23,8 @@ export interface ParsedCliArgs {
prompt: string | undefined;
/** For 'init' command: the raw input source (@file, text, or undefined for stdin). */
initInput: string | undefined;
/** For 'auto --init': bootstrap from a PRD before running the autonomous loop. */
init: string | undefined;
projectDir: string;
wsPort: number | undefined;
model: string | undefined;
@@ -43,6 +45,7 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
'ws-port': { type: 'string' },
model: { type: 'string' },
'max-budget': { type: 'string' },
init: { type: 'string' },
help: { type: 'boolean', short: 'h', default: false },
version: { type: 'boolean', short: 'v', default: false },
},
@@ -61,6 +64,7 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
command,
prompt,
initInput,
init: values.init as string | undefined,
projectDir: values['project-dir'] as string,
wsPort: values['ws-port'] ? Number(values['ws-port']) : undefined,
model: values.model as string | undefined,
@@ -85,6 +89,8 @@ Commands:
(empty) Read from stdin
Options:
--init <input> Bootstrap from a PRD before running (auto only)
Accepts @path/to/prd.md or "description text"
--project-dir <dir> Project directory (default: cwd)
--ws-port <port> Enable WebSocket transport on <port>
--model <model> Override LLM model
@@ -306,6 +312,47 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
}
try {
// If --init provided, bootstrap project first
if (args.init) {
const initInput = await resolveInitInput({
...args,
command: 'init',
initInput: args.init,
});
console.log(`[auto] Bootstrapping project from --init (${initInput.length} chars)`);
const tools = gsd.createTools();
const runner = new InitRunner({
projectDir: args.projectDir,
tools,
eventStream: gsd.eventStream,
config: {
maxBudgetPerSession: args.maxBudget,
orchestratorModel: args.model,
},
});
const initResult = await runner.run(initInput);
const initStatus = initResult.success ? 'SUCCESS' : 'FAILED';
const stepCount = initResult.steps.length;
const passedSteps = initResult.steps.filter(s => s.success).length;
const initCost = initResult.totalCostUsd.toFixed(2);
const initDuration = (initResult.totalDurationMs / 1000).toFixed(1);
console.log(`[init ${initStatus}] ${passedSteps}/${stepCount} steps, $${initCost}, ${initDuration}s`);
if (!initResult.success) {
for (const step of initResult.steps) {
if (!step.success && step.error) {
console.error(` ✗ ${step.step}: ${step.error}`);
}
}
process.exitCode = 1;
return;
}
}
const result = await gsd.run('');
// Final summary

View File

@@ -287,7 +287,7 @@ describe('GSDTools', () => {
'init-new-project.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'init' && args[1] === 'new-project' && args.includes('--raw')) {
if (args[0] === 'init' && args[1] === 'new-project') {
process.stdout.write(JSON.stringify(${JSON.stringify(mockResult)}));
} else {
process.stderr.write('unexpected args: ' + args.join(' '));

View File

@@ -51,11 +51,10 @@ export class GSDTools {
/**
* Execute a gsd-tools command and return parsed JSON output.
* Appends `--raw` to get machine-readable JSON output.
* Handles the `@file:` prefix pattern for large results.
*/
async exec(command: string, args: string[] = []): Promise<unknown> {
const fullArgs = [this.gsdToolsPath, command, ...args, '--raw'];
const fullArgs = [this.gsdToolsPath, command, ...args];
return new Promise<unknown>((resolve, reject) => {
const child = execFile(

View File

@@ -0,0 +1,159 @@
/**
* Contract test: all headless prompt files in sdk/prompts/ must contain
* zero instances of blocked interactive patterns.
*
* This prevents regression — any new prompt file or edit that reintroduces
* interactive mechanics will fail this test.
*/
import { describe, it, expect } from 'vitest';
import { readFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readdirSync } from 'node:fs';
// ─── Paths ───────────────────────────────────────────────────────────────────
const __dirname = dirname(fileURLToPath(import.meta.url));
const promptsDir = join(__dirname, '..', 'prompts');
const workflowsDir = join(promptsDir, 'workflows');
const agentsDir = join(promptsDir, 'agents');
// ─── Blocked patterns ────────────────────────────────────────────────────────
/**
* Patterns that MUST NOT appear in headless prompts.
* Each entry: [label for reporting, regex].
*/
const BLOCKED_PATTERNS: Array<[string, RegExp]> = [
['AskUserQuestion', /AskUserQuestion\s*\(/],
['SlashCommand', /SlashCommand\s*\(/],
['/gsd: command', /\/gsd:\S+/],
['@file: reference', /@file:\S+/],
['STOP + wait directive', /\bSTOP\b\s+(?:and\s+)?(?:wait|ask)/i],
['bare STOP directive', /^\s*STOP\s*[.!]?\s*$/m],
['wait for user', /\bwait\s+for\s+(?:the\s+)?user\b/i],
['ask the user', /\bask\s+the\s+user\b/i],
];
// ─── Expected files ──────────────────────────────────────────────────────────
const EXPECTED_WORKFLOWS = [
'execute-plan.md',
'research-phase.md',
'plan-phase.md',
'verify-phase.md',
'discuss-phase.md',
];
const EXPECTED_AGENTS = [
'gsd-executor.md',
'gsd-phase-researcher.md',
'gsd-planner.md',
'gsd-verifier.md',
'gsd-plan-checker.md',
'gsd-project-researcher.md',
'gsd-research-synthesizer.md',
'gsd-roadmapper.md',
];
const templatesDir = join(promptsDir, 'templates');
const researchTemplatesDir = join(templatesDir, 'research-project');
const EXPECTED_TEMPLATES = [
'project.md',
'requirements.md',
'roadmap.md',
'state.md',
];
const EXPECTED_RESEARCH_TEMPLATES = [
'ARCHITECTURE.md',
'FEATURES.md',
'PITFALLS.md',
'STACK.md',
'SUMMARY.md',
];
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('headless prompt contract', () => {
describe('file inventory', () => {
it('has all expected workflow files', () => {
const actual = readdirSync(workflowsDir).sort();
expect(actual).toEqual(EXPECTED_WORKFLOWS.sort());
});
it('has all expected agent files', () => {
const actual = readdirSync(agentsDir).sort();
expect(actual).toEqual(EXPECTED_AGENTS.sort());
});
});
describe('zero interactive patterns in workflow prompts', () => {
for (const filename of EXPECTED_WORKFLOWS) {
describe(filename, () => {
for (const [label, pattern] of BLOCKED_PATTERNS) {
it(`contains no ${label}`, async () => {
const content = await readFile(join(workflowsDir, filename), 'utf-8');
const matches = content.match(new RegExp(pattern.source, pattern.flags + 'g'));
expect(matches, `Found ${label} in ${filename}: ${matches?.join(', ')}`).toBeNull();
});
}
});
}
});
describe('zero interactive patterns in agent prompts', () => {
for (const filename of EXPECTED_AGENTS) {
describe(filename, () => {
for (const [label, pattern] of BLOCKED_PATTERNS) {
it(`contains no ${label}`, async () => {
const content = await readFile(join(agentsDir, filename), 'utf-8');
const matches = content.match(new RegExp(pattern.source, pattern.flags + 'g'));
expect(matches, `Found ${label} in ${filename}: ${matches?.join(', ')}`).toBeNull();
});
}
});
}
});
describe('template file inventory', () => {
it('has all expected top-level template files', () => {
const actual = readdirSync(templatesDir).filter(f => f.endsWith('.md')).sort();
expect(actual).toEqual(EXPECTED_TEMPLATES.sort());
});
it('has all expected research-project template files', () => {
const actual = readdirSync(researchTemplatesDir).sort();
expect(actual).toEqual(EXPECTED_RESEARCH_TEMPLATES.sort());
});
});
describe('zero interactive patterns in template prompts', () => {
for (const filename of EXPECTED_TEMPLATES) {
describe(filename, () => {
for (const [label, pattern] of BLOCKED_PATTERNS) {
it(`contains no ${label}`, async () => {
const content = await readFile(join(templatesDir, filename), 'utf-8');
const matches = content.match(new RegExp(pattern.source, pattern.flags + 'g'));
expect(matches, `Found ${label} in ${filename}: ${matches?.join(', ')}`).toBeNull();
});
}
});
}
});
describe('zero interactive patterns in research-project templates', () => {
for (const filename of EXPECTED_RESEARCH_TEMPLATES) {
describe(filename, () => {
for (const [label, pattern] of BLOCKED_PATTERNS) {
it(`contains no ${label}`, async () => {
const content = await readFile(join(researchTemplatesDir, filename), 'utf-8');
const matches = content.match(new RegExp(pattern.source, pattern.flags + 'g'));
expect(matches, `Found ${label} in ${filename}: ${matches?.join(', ')}`).toBeNull();
});
}
});
}
});
});

View File

@@ -6,7 +6,7 @@
*
* @example
* ```typescript
* import { GSD } from '@gsd/sdk';
* import { GSD } from '@gsd-build/sdk';
*
* const gsd = new GSD({ projectDir: '/path/to/project' });
* const result = await gsd.executePlan('.planning/phases/01-auth/01-auth-01-PLAN.md');

View File

@@ -560,4 +560,224 @@ describe('InitRunner', () => {
// 1 PROJECT.md + 4 research + 1 synthesis + 1 requirements + 1 roadmap = 8
expect(mockRunSession).toHaveBeenCalledTimes(8);
});
// ─── Headless prompt loading (sdkPromptsDir preference) ──────────────────
describe('sdkPromptsDir preference and sanitizer integration', () => {
let sdkPromptsDir: string;
beforeEach(async () => {
// Create a temp SDK prompts directory with test fixtures
sdkPromptsDir = join(tmpDir, 'sdk-prompts');
await mkdir(join(sdkPromptsDir, 'templates', 'research-project'), { recursive: true });
await mkdir(join(sdkPromptsDir, 'agents'), { recursive: true });
// Write headless templates (with known marker text for assertion)
await writeFile(
join(sdkPromptsDir, 'templates', 'project.md'),
'# PROJECT Template\nSDK_HEADLESS_MARKER_PROJECT\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'requirements.md'),
'# REQUIREMENTS Template\nSDK_HEADLESS_MARKER_REQUIREMENTS\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'roadmap.md'),
'# ROADMAP Template\nSDK_HEADLESS_MARKER_ROADMAP\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'state.md'),
'# STATE Template\nSDK_HEADLESS_MARKER_STATE\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'research-project', 'STACK.md'),
'# STACK Template\nSDK_HEADLESS_MARKER_STACK\n',
);
// Write headless agents (with known marker text)
await writeFile(
join(sdkPromptsDir, 'agents', 'gsd-project-researcher.md'),
'# Project Researcher Agent\nSDK_HEADLESS_MARKER_RESEARCHER\n',
);
await writeFile(
join(sdkPromptsDir, 'agents', 'gsd-research-synthesizer.md'),
'# Research Synthesizer Agent\nSDK_HEADLESS_MARKER_SYNTHESIZER\n',
);
await writeFile(
join(sdkPromptsDir, 'agents', 'gsd-roadmapper.md'),
'# Roadmapper Agent\nSDK_HEADLESS_MARKER_ROADMAPPER\n',
);
});
function createRunnerWithSdkPrompts(
toolsOverrides: Record<string, unknown> = {},
configOverrides?: Partial<InitRunnerDeps['config']>,
) {
const tools = makeTools(toolsOverrides);
const eventStream = makeEventStream();
const runner = new InitRunner({
projectDir: tmpDir,
tools,
eventStream,
config: configOverrides as any,
sdkPromptsDir,
});
return { runner, tools, eventStream, events: eventStream.events as GSDEvent[] };
}
it('readGSDFile prefers sdk/prompts/ template over GSD-1 path', async () => {
const { runner } = createRunnerWithSdkPrompts();
await runner.run('build a todo app');
// The first session call is buildProjectPrompt → reads templates/project.md
const projectPrompt = mockRunSession.mock.calls[0]![0] as string;
expect(projectPrompt).toContain('SDK_HEADLESS_MARKER_PROJECT');
});
it('readAgentFile prefers sdk/prompts/agents/ over GSD-1 path', async () => {
const { runner } = createRunnerWithSdkPrompts();
await runner.run('build a todo app');
// Research calls (indices 1-4) use gsd-project-researcher.md agent def
const researchPrompt = mockRunSession.mock.calls[1]![0] as string;
expect(researchPrompt).toContain('SDK_HEADLESS_MARKER_RESEARCHER');
});
it('readGSDFile falls back to GSD-1 when sdk/prompts/ file does not exist', async () => {
// Create an empty sdkPromptsDir — no templates at all
const emptySdkDir = join(tmpDir, 'empty-sdk-prompts');
await mkdir(join(emptySdkDir, 'templates'), { recursive: true });
await mkdir(join(emptySdkDir, 'agents'), { recursive: true });
const tools = makeTools();
const eventStream = makeEventStream();
const runner = new InitRunner({
projectDir: tmpDir,
tools,
eventStream,
sdkPromptsDir: emptySdkDir,
});
await runner.run('build a todo app');
// buildProjectPrompt reads templates/project.md — not found in empty dir,
// falls through to GSD-1 path. If GSD-1 also missing, gets placeholder.
const projectPrompt = mockRunSession.mock.calls[0]![0] as string;
// Should NOT contain our marker (since empty dir was used)
expect(projectPrompt).not.toContain('SDK_HEADLESS_MARKER_PROJECT');
// Should still contain the PROJECT.md synthesis instruction (from the prompt builder)
expect(projectPrompt).toContain('PROJECT.md');
});
it('readAgentFile falls back to GSD-1 when sdk/prompts/agents/ file does not exist', async () => {
// Empty sdkPromptsDir — no agent files
const emptySdkDir = join(tmpDir, 'empty-sdk-agents');
await mkdir(join(emptySdkDir, 'templates', 'research-project'), { recursive: true });
await mkdir(join(emptySdkDir, 'agents'), { recursive: true });
// Write templates so we get past buildProjectPrompt
await writeFile(join(emptySdkDir, 'templates', 'project.md'), '# project\n');
await writeFile(join(emptySdkDir, 'templates', 'research-project', 'STACK.md'), '# stack\n');
await writeFile(join(emptySdkDir, 'templates', 'research-project', 'FEATURES.md'), '# features\n');
await writeFile(join(emptySdkDir, 'templates', 'research-project', 'ARCHITECTURE.md'), '# arch\n');
await writeFile(join(emptySdkDir, 'templates', 'research-project', 'PITFALLS.md'), '# pitfalls\n');
const tools = makeTools();
const eventStream = makeEventStream();
const runner = new InitRunner({
projectDir: tmpDir,
tools,
eventStream,
sdkPromptsDir: emptySdkDir,
});
await runner.run('build a todo app');
// Research prompt uses agent def — not in empty agents dir, falls to GSD-1
const researchPrompt = mockRunSession.mock.calls[1]![0] as string;
// Should NOT contain our marker
expect(researchPrompt).not.toContain('SDK_HEADLESS_MARKER_RESEARCHER');
// Should still have the "researching the" instruction
expect(researchPrompt).toContain('You are researching the');
});
it('buildProjectPrompt output passes through sanitizePrompt (no /gsd: patterns)', async () => {
// Write a template that contains an interactive pattern
await writeFile(
join(sdkPromptsDir, 'templates', 'project.md'),
'# PROJECT Template\nRun /gsd:map-codebase to analyze.\nSDK_HEADLESS_MARKER_PROJECT\n',
);
const { runner } = createRunnerWithSdkPrompts();
await runner.run('build a todo app');
const projectPrompt = mockRunSession.mock.calls[0]![0] as string;
// sanitizePrompt should have stripped the /gsd: line
expect(projectPrompt).not.toMatch(/\/gsd:\S+/);
// But the marker should still be there
expect(projectPrompt).toContain('SDK_HEADLESS_MARKER_PROJECT');
});
it('buildResearchPrompt output passes through sanitizePrompt (no /gsd: patterns)', async () => {
// Write an agent def that contains interactive patterns
await writeFile(
join(sdkPromptsDir, 'agents', 'gsd-project-researcher.md'),
'# Researcher Agent\nSpawn /gsd:something for analysis.\nSDK_HEADLESS_MARKER_RESEARCHER\n',
);
const { runner } = createRunnerWithSdkPrompts();
await runner.run('build a todo app');
const researchPrompt = mockRunSession.mock.calls[1]![0] as string;
// sanitizePrompt should have stripped the /gsd: line
expect(researchPrompt).not.toMatch(/\/gsd:\S+/);
// Marker should still be present
expect(researchPrompt).toContain('SDK_HEADLESS_MARKER_RESEARCHER');
});
it('buildRoadmapPrompt output passes through sanitizePrompt (no /gsd: patterns)', async () => {
// Write agent and templates with interactive patterns
await writeFile(
join(sdkPromptsDir, 'agents', 'gsd-roadmapper.md'),
'# Roadmapper Agent\nUse /gsd:execute to run.\nSDK_HEADLESS_MARKER_ROADMAPPER\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'roadmap.md'),
'# ROADMAP Template\nRun /gsd:check-progress.\nSDK_HEADLESS_MARKER_ROADMAP\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'state.md'),
'# STATE Template\nUse /gsd:add-todo for tracking.\nSDK_HEADLESS_MARKER_STATE\n',
);
// Also need research templates and synth agent for earlier steps
await writeFile(
join(sdkPromptsDir, 'templates', 'research-project', 'FEATURES.md'), '# features\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'research-project', 'ARCHITECTURE.md'), '# arch\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'research-project', 'PITFALLS.md'), '# pitfalls\n',
);
await writeFile(
join(sdkPromptsDir, 'templates', 'research-project', 'SUMMARY.md'), '# summary\n',
);
const { runner } = createRunnerWithSdkPrompts();
await runner.run('build a todo app');
// Roadmap prompt is the last session call (index 7)
const roadmapPrompt = mockRunSession.mock.calls[7]![0] as string;
// sanitizePrompt should have stripped all /gsd: patterns
expect(roadmapPrompt).not.toMatch(/\/gsd:\S+/);
// Markers from templates should still be present
expect(roadmapPrompt).toContain('SDK_HEADLESS_MARKER_ROADMAPPER');
expect(roadmapPrompt).toContain('SDK_HEADLESS_MARKER_ROADMAP');
expect(roadmapPrompt).toContain('SDK_HEADLESS_MARKER_STATE');
});
});
});

View File

@@ -10,6 +10,7 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { execFile } from 'node:child_process';
@@ -31,6 +32,7 @@ import type { GSDTools } from './gsd-tools.js';
import type { GSDEventStream } from './event-stream.js';
import { loadConfig } from './config.js';
import { runPhaseStepSession } from './session-runner.js';
import { sanitizePrompt } from './prompt-sanitizer.js';
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -68,6 +70,8 @@ export interface InitRunnerDeps {
tools: GSDTools;
eventStream: GSDEventStream;
config?: Partial<InitConfig>;
/** Override for SDK prompts directory. Defaults to package-relative sdk/prompts/. */
sdkPromptsDir?: string;
}
export class InitRunner {
@@ -76,6 +80,7 @@ export class InitRunner {
private readonly eventStream: GSDEventStream;
private readonly config: InitConfig;
private readonly sessionId: string;
private readonly sdkPromptsDir: string;
constructor(deps: InitRunnerDeps) {
this.projectDir = deps.projectDir;
@@ -88,6 +93,10 @@ export class InitRunner {
orchestratorModel: deps.config?.orchestratorModel,
};
this.sessionId = `init-${Date.now()}`;
// SDK prompts dir: explicit override → package-relative default via import.meta.url
this.sdkPromptsDir =
deps.sdkPromptsDir ??
join(fileURLToPath(new URL('.', import.meta.url)), '..', 'prompts');
}
/**
@@ -375,7 +384,7 @@ export class InitRunner {
private async buildProjectPrompt(input: string): Promise<string> {
const template = await this.readGSDFile('templates/project.md');
return [
return sanitizePrompt([
'You are creating the PROJECT.md for a new software project.',
'Write .planning/PROJECT.md based on the template structure below and the user\'s project description.',
'',
@@ -389,7 +398,7 @@ export class InitRunner {
'',
'Write the file to .planning/PROJECT.md. Follow the template structure but fill in with real content derived from the user input.',
'Be specific and opinionated — make decisions, don\'t list options.',
].join('\n');
].join('\n'));
}
/**
@@ -415,7 +424,7 @@ export class InitRunner {
projectContent = input;
}
return [
return sanitizePrompt([
'<agent_definition>',
agentDef,
'</agent_definition>',
@@ -437,7 +446,7 @@ export class InitRunner {
'',
`Write .planning/research/${researchType}.md following the template structure.`,
'Be comprehensive but opinionated. "Use X because Y" not "Options are X, Y, Z."',
].join('\n');
].join('\n'));
}
/**
@@ -460,7 +469,7 @@ export class InitRunner {
}
}
return [
return sanitizePrompt([
'<agent_definition>',
agentDef,
'</agent_definition>',
@@ -482,7 +491,7 @@ export class InitRunner {
'',
'Write .planning/research/SUMMARY.md synthesizing all research findings.',
'Also commit all research files: git add .planning/research/ && git commit.',
].join('\n');
].join('\n'));
}
/**
@@ -511,7 +520,7 @@ export class InitRunner {
// Research may have partially failed
}
return [
return sanitizePrompt([
'You are generating REQUIREMENTS.md for this project.',
'Derive requirements from the PROJECT.md and research outputs.',
'Auto-include all table-stakes requirements (auth, error handling, logging, etc.).',
@@ -530,7 +539,7 @@ export class InitRunner {
'',
'Write .planning/REQUIREMENTS.md following the template structure.',
'Every requirement must be testable and specific. No vague aspirations.',
].join('\n');
].join('\n'));
}
/**
@@ -559,7 +568,7 @@ export class InitRunner {
}
}
return [
return sanitizePrompt([
'<agent_definition>',
agentDef,
'</agent_definition>',
@@ -581,7 +590,7 @@ export class InitRunner {
'Create .planning/ROADMAP.md and .planning/STATE.md.',
'ROADMAP.md: Transform requirements into phases. Every v1 requirement maps to exactly one phase.',
'STATE.md: Initialize project state tracking.',
].join('\n');
].join('\n'));
}
// ─── Session execution ─────────────────────────────────────────────────────
@@ -610,9 +619,20 @@ export class InitRunner {
// ─── File reading helpers ──────────────────────────────────────────────────
/**
* Read a file from the GSD templates directory (~/.claude/get-shit-done/).
* Read a file from the GSD templates directory.
* Tries sdk/prompts/{relativePath} first (headless versions), then
* falls back to GSD-1 originals (~/.claude/get-shit-done/).
*/
private async readGSDFile(relativePath: string): Promise<string> {
// Try SDK prompts dir first (headless versions)
const sdkPath = join(this.sdkPromptsDir, relativePath);
try {
return await readFile(sdkPath, 'utf-8');
} catch {
// Not in sdk/prompts/, fall through to GSD-1 originals
}
// Fall back to GSD-1 originals
const fullPath = join(GSD_TEMPLATES_DIR, '..', relativePath);
try {
return await readFile(fullPath, 'utf-8');
@@ -623,9 +643,20 @@ export class InitRunner {
}
/**
* Read an agent definition from ~/.claude/agents/.
* Read an agent definition.
* Tries sdk/prompts/agents/{filename} first (headless versions), then
* falls back to GSD-1 originals (~/.claude/agents/).
*/
private async readAgentFile(filename: string): Promise<string> {
// Try SDK prompts dir first (headless versions)
const sdkPath = join(this.sdkPromptsDir, 'agents', filename);
try {
return await readFile(sdkPath, 'utf-8');
} catch {
// Not in sdk/prompts/, fall through to GSD-1 originals
}
// Fall back to GSD-1 originals
const fullPath = join(GSD_AGENTS_DIR, filename);
try {
return await readFile(fullPath, 'utf-8');

View File

@@ -116,9 +116,12 @@ describe('PromptFactory', () => {
});
function makeFactory(): PromptFactory {
// sdkPromptsDir points to a non-existent temp subdir so real sdk/prompts/ files
// don't interfere — tests control exactly which files exist on disk.
return new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
}
@@ -365,6 +368,7 @@ describe('PromptFactory', () => {
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
@@ -381,12 +385,138 @@ describe('PromptFactory', () => {
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
});
// ─── Headless prompt loading ─────────────────────────────────────────────
describe('headless prompt loading', () => {
it('loadWorkflowFile prefers sdkPromptsDir over GSD-1 workflowsDir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
// Write both: GSD-1 original and SDK headless version
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK headless version');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('SDK headless version');
});
it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
// Only GSD-1 original exists, no SDK version
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('GSD-1 original');
});
it('loadAgentDef prefers sdkPromptsDir over user agents dir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
// Write both: user agent and SDK headless agent
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK headless agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('SDK headless agent');
});
it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
// Only user agent exists, no SDK version
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
it('buildPrompt sanitizes interactive patterns from output', async () => {
// Use separate lines so non-interactive content survives stripping
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the codebase thoroughly.', [
'Gather data from the project.\nAskUserQuestion("what?")\nAnalyze findings.',
'Run the analysis.\n/gsd:analyze --deep\nDocument results.',
]),
);
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.\nSTOP and wait for user input.\nBe thorough.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
// Interactive patterns should be stripped by sanitizePrompt()
expect(prompt).not.toContain('AskUserQuestion');
expect(prompt).not.toContain('/gsd:');
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
// Non-interactive content on separate lines should remain
expect(prompt).toContain('You are a researcher.');
expect(prompt).toContain('Be thorough.');
expect(prompt).toContain('Gather data from the project.');
expect(prompt).toContain('Analyze findings.');
});
it('buildPrompt with execute+plan sanitizes output from buildExecutorPrompt', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.\nSTOP and wait for user.\nExecute thoroughly.'),
);
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);
// Objective should remain (no interactive pattern on that line)
expect(prompt).toContain('Build the auth system');
// The role's STOP directive should be stripped
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
// Non-interactive role content should remain
expect(prompt).toContain('You are an executor.');
});
});
});
describe('PHASE_WORKFLOW_MAP', () => {

View File

@@ -8,12 +8,14 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
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';
import { sanitizePrompt } from './prompt-sanitizer.js';
// ─── Workflow file mapping ───────────────────────────────────────────────────
@@ -65,16 +67,22 @@ export class PromptFactory {
private readonly workflowsDir: string;
private readonly agentsDir: string;
private readonly projectAgentsDir?: string;
private readonly sdkPromptsDir: string;
constructor(options?: {
gsdInstallDir?: string;
agentsDir?: string;
projectAgentsDir?: string;
sdkPromptsDir?: 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;
// SDK prompts dir: explicit override → package-relative default via import.meta.url
this.sdkPromptsDir =
options?.sdkPromptsDir ??
join(fileURLToPath(new URL('.', import.meta.url)), '..', 'prompts');
}
/**
@@ -91,7 +99,7 @@ export class PromptFactory {
// Execute phase with a plan: delegate to existing buildExecutorPrompt
if (phaseType === PhaseType.Execute && plan) {
const agentDef = await this.loadAgentDef(phaseType);
return buildExecutorPrompt(plan, agentDef);
return sanitizePrompt(buildExecutorPrompt(plan, agentDef));
}
const sections: string[] = [];
@@ -135,17 +143,28 @@ export class PromptFactory {
sections.push(`## Phase Instructions\n\n${phaseInstructions}`);
}
return sections.join('\n\n');
return sanitizePrompt(sections.join('\n\n'));
}
/**
* Load the workflow file for a phase type.
* Tries sdk/prompts/workflows/ first (headless versions), then
* falls back to GSD-1 originals in workflowsDir.
* 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 SDK prompts dir first (headless versions)
const sdkPath = join(this.sdkPromptsDir, 'workflows', filename);
try {
return await readFile(sdkPath, 'utf-8');
} catch {
// Not in sdk/prompts/, fall through to GSD-1 originals
}
// Fall back to GSD-1 originals
const filePath = join(this.workflowsDir, filename);
try {
return await readFile(filePath, 'utf-8');
} catch {
@@ -155,15 +174,19 @@ export class PromptFactory {
/**
* Load the agent definition for a phase type.
* Tries user-level agents dir first, then project-level.
* Tries sdk/prompts/agents/ first (headless versions), then
* user-level agents dir, 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)];
// Try SDK prompts dir first (headless versions)
const paths = [
join(this.sdkPromptsDir, 'agents', agentFilename),
join(this.agentsDir, agentFilename),
];
// Then project-level if configured
if (this.projectAgentsDir) {

View File

@@ -352,7 +352,8 @@ describe('GSDTools typed methods', () => {
expect(result.received_args).toContain('init');
expect(result.received_args).toContain('phase-op');
expect(result.received_args).toContain('7');
expect(result.received_args).toContain('--raw');
// exec() no longer appends --raw (only execRaw does)
expect(result.received_args).not.toContain('--raw');
});
});

View File

@@ -0,0 +1,242 @@
import { describe, it, expect } from 'vitest';
import { sanitizePrompt } from './prompt-sanitizer.js';
describe('sanitizePrompt', () => {
// ─── Edge cases ──────────────────────────────────────────────────────────
describe('edge cases', () => {
it('returns empty string unchanged', () => {
expect(sanitizePrompt('')).toBe('');
});
it('returns undefined/null-ish input unchanged', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(sanitizePrompt(undefined as any)).toBeUndefined();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(sanitizePrompt(null as any)).toBeNull();
});
it('preserves clean content with no patterns', () => {
const clean = 'This is a clean prompt.\nIt has no interactive patterns.\n\nJust normal text.';
expect(sanitizePrompt(clean)).toBe(clean.trim());
});
});
// ─── @file: references ───────────────────────────────────────────────────
describe('@file: references', () => {
it('strips lines containing @file: references', () => {
const input = 'Before\nLoad @file:path/to/context.md for context\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toContain('@file:');
expect(result).toContain('Before');
expect(result).toContain('After');
});
it('strips @file: with various path formats', () => {
const input = [
'@file:simple.md',
'@file:./relative/path.md',
'@file:/absolute/path/to/file.md',
'@file:~/.claude/get-shit-done/workflows/execute-plan.md',
].join('\n');
expect(sanitizePrompt(input)).toBe('');
});
});
// ─── /gsd: slash commands ────────────────────────────────────────────────
describe('/gsd: slash commands', () => {
it('strips lines containing /gsd: commands', () => {
const input = 'Before\nRun /gsd:execute-plan to proceed\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toContain('/gsd:');
expect(result).toContain('Before');
expect(result).toContain('After');
});
it('strips various /gsd: command formats', () => {
const input = [
'Use /gsd:research-phase',
'Then /gsd:plan-phase --auto',
'Finally /gsd:verify-phase',
].join('\n');
expect(sanitizePrompt(input)).toBe('');
});
});
// ─── AskUserQuestion() calls ─────────────────────────────────────────────
describe('AskUserQuestion() calls', () => {
it('strips AskUserQuestion lines', () => {
const input = 'Before\nAskUserQuestion("What should we do?")\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toContain('AskUserQuestion');
expect(result).toContain('Before');
expect(result).toContain('After');
});
it('strips AskUserQuestion with various argument styles', () => {
const input = [
'AskUserQuestion("simple")',
'AskUserQuestion( "with spaces" )',
' AskUserQuestion("indented")',
'Use AskUserQuestion("inline") here',
].join('\n');
expect(sanitizePrompt(input)).toBe('');
});
});
// ─── SlashCommand() calls ────────────────────────────────────────────────
describe('SlashCommand() calls', () => {
it('strips SlashCommand lines', () => {
const input = 'Before\nSlashCommand("/gsd:execute")\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toContain('SlashCommand');
expect(result).toContain('Before');
expect(result).toContain('After');
});
it('strips SlashCommand with various forms', () => {
const input = [
'SlashCommand("proceed")',
'SlashCommand( "next" )',
' SlashCommand("indented")',
].join('\n');
expect(sanitizePrompt(input)).toBe('');
});
});
// ─── STOP directives ────────────────────────────────────────────────────
describe('STOP directives', () => {
it('strips "STOP and wait" lines', () => {
const input = 'Before\nSTOP and wait for user input\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toContain('STOP');
expect(result).toContain('Before');
expect(result).toContain('After');
});
it('strips bare STOP lines', () => {
const input = 'Before\nSTOP\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toContain('STOP');
});
it('strips STOP with trailing punctuation', () => {
const input = 'Before\nSTOP.\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toContain('STOP');
});
it('strips "STOP here" and "STOP now"', () => {
const input = 'STOP here\nSTOP now\nSTOP and ask';
const result = sanitizePrompt(input);
expect(result).toBe('');
});
it('preserves STOP in normal prose (not as directive)', () => {
const input = 'Do not stop the build process.';
const result = sanitizePrompt(input);
// "stop" in lowercase in normal prose should be preserved
expect(result).toContain('stop the build');
});
});
// ─── 'wait for user' / 'ask the user' instructions ──────────────────────
describe('wait for user / ask the user', () => {
it('strips "wait for user" lines', () => {
const input = 'Before\nWait for user confirmation before proceeding\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toMatch(/wait for.*user/i);
expect(result).toContain('Before');
expect(result).toContain('After');
});
it('strips "wait for the user" lines', () => {
const input = 'Before\nWait for the user to respond\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toMatch(/wait for the user/i);
});
it('strips "ask the user" lines', () => {
const input = 'Before\nAsk the user for clarification\nAfter';
const result = sanitizePrompt(input);
expect(result).not.toMatch(/ask the user/i);
expect(result).toContain('Before');
expect(result).toContain('After');
});
it('is case-insensitive for wait/ask patterns', () => {
const input = [
'WAIT FOR USER input',
'wait for user approval',
'ASK THE USER what to do',
'ask the user for feedback',
].join('\n');
expect(sanitizePrompt(input)).toBe('');
});
});
// ─── Multiple patterns in one string ─────────────────────────────────────
describe('multiple patterns in one string', () => {
it('strips all pattern types from a mixed prompt', () => {
const input = [
'## Research Phase',
'',
'Investigate the codebase using @file:context.md for context.',
'',
'When done, run /gsd:plan-phase to proceed.',
'',
'If unclear, AskUserQuestion("What should I focus on?")',
'',
'STOP and wait for user input.',
'',
'Use SlashCommand("next") to continue.',
'',
'Wait for user confirmation before executing.',
'',
'This line is clean and should remain.',
].join('\n');
const result = sanitizePrompt(input);
expect(result).not.toContain('@file:');
expect(result).not.toContain('/gsd:');
expect(result).not.toContain('AskUserQuestion');
expect(result).not.toContain('SlashCommand');
expect(result).not.toMatch(/\bSTOP\b/);
expect(result).not.toMatch(/wait for user/i);
expect(result).toContain('## Research Phase');
expect(result).toContain('This line is clean and should remain.');
});
});
// ─── Blank line collapsing ───────────────────────────────────────────────
describe('blank line collapsing', () => {
it('collapses 3+ consecutive blank lines to 2', () => {
const input = 'Line 1\n\n\n\n\nLine 2';
const result = sanitizePrompt(input);
// After trim(), the result should have at most 2 consecutive newlines
expect(result).not.toMatch(/\n{3,}/);
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('collapses blanks left by stripped lines', () => {
const input = [
'Before',
'',
'AskUserQuestion("something")',
'',
'After',
].join('\n');
const result = sanitizePrompt(input);
expect(result).toBe('Before\n\nAfter');
});
});
});

View File

@@ -0,0 +1,71 @@
/**
* Prompt sanitizer — strips interactive CLI patterns from GSD-1 prompts
* so they're safe for headless SDK use.
*
* Patterns removed:
* - @file:... references (file injection directives)
* - /gsd:... slash commands
* - AskUserQuestion(...) calls
* - STOP directives in interactive contexts
* - SlashCommand() calls
* - 'wait for user' / 'ask the user' instructions
*/
// ─── Pattern definitions ─────────────────────────────────────────────────────
/**
* Each pattern is a regex that matches a full line (or inline span) to remove.
* We strip matching lines entirely to avoid leaving blank gaps that break
* markdown structure.
*/
const LINE_PATTERNS: RegExp[] = [
// @file:path/to/something references — entire line
/^.*@file:\S+.*$/gm,
// /gsd:command references — entire line containing a slash command
/^.*\/gsd:\S+.*$/gm,
// AskUserQuestion(...) calls — entire line
/^.*AskUserQuestion\s*\(.*$/gm,
// SlashCommand() calls — entire line
/^.*SlashCommand\s*\(.*$/gm,
// STOP directives — lines that are primarily "STOP" instructions
// Match lines where STOP is used as an imperative (not as part of normal prose)
/^.*\bSTOP\b(?:\s+(?:and\s+)?(?:wait|ask|here|now)).*$/gm,
/^\s*STOP\s*[.!]?\s*$/gm,
// 'wait for user' / 'ask the user' instruction lines
/^.*\bwait\s+for\s+(?:the\s+)?user\b.*$/gim,
/^.*\bask\s+the\s+user\b.*$/gim,
];
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Strip interactive CLI patterns from a prompt string.
*
* Removes lines matching known interactive patterns (file references,
* slash commands, user-interaction directives) while preserving all
* other content unchanged.
*
* @param input - Raw prompt string, possibly containing interactive patterns
* @returns Cleaned prompt with interactive patterns removed
*/
export function sanitizePrompt(input: string): string {
if (!input) return input;
let result = input;
for (const pattern of LINE_PATTERNS) {
// Reset lastIndex for global regexes
pattern.lastIndex = 0;
result = result.replace(pattern, '');
}
// Collapse runs of 3+ blank lines down to 2 (preserve paragraph breaks)
result = result.replace(/\n{3,}/g, '\n\n');
return result.trim();
}