fix(sdk): use installed agent/workflow prompts instead of stripped-down bundled copies
The SDK bundled its own agents and workflows at ~17% the size of the real ones, missing critical instructions like file naming conventions, scope reduction rules, discovery protocols, and TDD integration. This caused the planner to create a single PLAN.md instead of properly named per-plan files (01-01-PLAN.md, 01-02-PLAN.md), breaking wave-based parallel execution. - Invert load priority: installed GSD agents/workflows first, SDK bundled as last-resort fallback - Replace @-reference stripping with resolution (read + inline content) - Use full agent definitions instead of extracting only the <role> block - Delete sdk/prompts/agents/ and sdk/prompts/workflows/ (13 files) - Delete headless-prompts.test.ts (validated deleted files) - Thread projectDir through sanitizePrompt for @-reference resolution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -128,7 +128,7 @@ describe('PromptFactory assembled output', () => {
|
||||
it('includes role section for phases with agents', async () => {
|
||||
// Research, Plan, Execute, Verify all have agents; Discuss does not
|
||||
const researchOutput = await factory.buildPrompt(PhaseType.Research, null, EMPTY_CONTEXT);
|
||||
expect(researchOutput).toContain('## Role');
|
||||
expect(researchOutput).toContain('## Agent Instructions');
|
||||
});
|
||||
|
||||
it('includes purpose section from workflow files', async () => {
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,7 @@ export class GSD {
|
||||
*/
|
||||
async runPhase(phaseNumber: string, options?: PhaseRunnerOptions): Promise<PhaseRunnerResult> {
|
||||
const tools = this.createTools();
|
||||
const promptFactory = new PromptFactory();
|
||||
const promptFactory = new PromptFactory({ projectDir: this.projectDir });
|
||||
const contextEngine = new ContextEngine(this.projectDir, undefined, undefined, this.workstream);
|
||||
const config = await loadConfig(this.projectDir, this.workstream);
|
||||
|
||||
|
||||
@@ -625,24 +625,27 @@ describe('InitRunner', () => {
|
||||
return { runner, tools, eventStream, events: eventStream.events as GSDEvent[] };
|
||||
}
|
||||
|
||||
it('readGSDFile prefers sdk/prompts/ template over GSD-1 path', async () => {
|
||||
it('readGSDFile prefers installed GSD over sdk/prompts/ template', async () => {
|
||||
const { runner } = createRunnerWithSdkPrompts();
|
||||
|
||||
await runner.run('build a todo app');
|
||||
|
||||
// The first session call is buildProjectPrompt → reads templates/project.md
|
||||
// Installed GSD templates (if present) are preferred over SDK bundled copies
|
||||
const projectPrompt = mockRunSession.mock.calls[0]![0] as string;
|
||||
expect(projectPrompt).toContain('SDK_HEADLESS_MARKER_PROJECT');
|
||||
// Should contain PROJECT.md creation instruction regardless of source
|
||||
expect(projectPrompt).toContain('PROJECT.md');
|
||||
});
|
||||
|
||||
it('readAgentFile prefers sdk/prompts/agents/ over GSD-1 path', async () => {
|
||||
it('readAgentFile prefers installed agents over sdk/prompts/agents/', 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');
|
||||
// Should contain research instruction regardless of source
|
||||
expect(researchPrompt).toContain('You are researching the');
|
||||
});
|
||||
|
||||
it('readGSDFile falls back to GSD-1 when sdk/prompts/ file does not exist', async () => {
|
||||
@@ -705,79 +708,33 @@ describe('InitRunner', () => {
|
||||
});
|
||||
|
||||
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
|
||||
// sanitizePrompt should strip any /gsd: patterns from the assembled prompt
|
||||
expect(projectPrompt).not.toMatch(/\/gsd:\S+/);
|
||||
// But the marker should still be there
|
||||
expect(projectPrompt).toContain('SDK_HEADLESS_MARKER_PROJECT');
|
||||
expect(projectPrompt).toContain('PROJECT.md');
|
||||
});
|
||||
|
||||
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
|
||||
// sanitizePrompt should strip any /gsd: patterns from the assembled prompt
|
||||
expect(researchPrompt).not.toMatch(/\/gsd:\S+/);
|
||||
// Marker should still be present
|
||||
expect(researchPrompt).toContain('SDK_HEADLESS_MARKER_RESEARCHER');
|
||||
expect(researchPrompt).toContain('You are researching the');
|
||||
});
|
||||
|
||||
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
|
||||
// sanitizePrompt should strip any /gsd: patterns from the assembled prompt
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,7 +399,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'), this.projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,7 +447,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'), this.projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -492,7 +492,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'), this.projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,7 +540,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'), this.projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -591,7 +591,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'), this.projectDir);
|
||||
}
|
||||
|
||||
// ─── Session execution ─────────────────────────────────────────────────────
|
||||
@@ -625,42 +625,41 @@ export class InitRunner {
|
||||
* 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
|
||||
// Try installed GSD first (complete, up-to-date versions)
|
||||
const fullPath = join(GSD_TEMPLATES_DIR, '..', relativePath);
|
||||
try {
|
||||
return await readFile(fullPath, 'utf-8');
|
||||
} catch {
|
||||
// If the template doesn't exist, return a placeholder
|
||||
// Not installed, fall through to SDK bundled copies
|
||||
}
|
||||
|
||||
// Fall back to SDK bundled copies
|
||||
const sdkPath = join(this.sdkPromptsDir, relativePath);
|
||||
try {
|
||||
return await readFile(sdkPath, 'utf-8');
|
||||
} catch {
|
||||
return `(Template not found: ${relativePath})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an agent definition.
|
||||
* Tries sdk/prompts/agents/{filename} first (headless versions), then
|
||||
* falls back to GSD-1 originals (~/.claude/agents/).
|
||||
* Tries installed agents first (complete, up-to-date versions), then
|
||||
* falls back to SDK bundled copies.
|
||||
*/
|
||||
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
|
||||
// Try installed agents first (complete, up-to-date versions)
|
||||
const fullPath = join(GSD_AGENTS_DIR, filename);
|
||||
try {
|
||||
return await readFile(fullPath, 'utf-8');
|
||||
} catch {
|
||||
// Not installed, fall through to SDK bundled copies
|
||||
}
|
||||
|
||||
// Fall back to SDK bundled copies
|
||||
const sdkPath = join(this.sdkPromptsDir, 'agents', filename);
|
||||
try {
|
||||
return await readFile(sdkPath, 'utf-8');
|
||||
} catch {
|
||||
return `(Agent definition not found: ${filename})`;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('PromptFactory', () => {
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
|
||||
|
||||
expect(prompt).toContain('## Role');
|
||||
expect(prompt).toContain('## Agent Instructions');
|
||||
expect(prompt).toContain('You are a researcher.');
|
||||
expect(prompt).toContain('## Purpose');
|
||||
expect(prompt).toContain('Research the phase.');
|
||||
@@ -153,12 +153,11 @@ describe('PromptFactory', () => {
|
||||
expect(prompt).toContain('## Context');
|
||||
expect(prompt).toContain('# State');
|
||||
expect(prompt).toContain('# Roadmap');
|
||||
expect(prompt).toContain('## Phase Instructions');
|
||||
|
||||
// Cache-friendly ordering (#1614): stable prefix before variable context
|
||||
const phaseInstrIdx = prompt.indexOf('## Phase Instructions');
|
||||
const agentIdx = prompt.indexOf('## Agent Instructions');
|
||||
const contextIdx = prompt.indexOf('## Context');
|
||||
expect(phaseInstrIdx).toBeLessThan(contextIdx);
|
||||
expect(agentIdx).toBeLessThan(contextIdx);
|
||||
});
|
||||
|
||||
it('assembles plan prompt with all context files', async () => {
|
||||
@@ -187,7 +186,7 @@ describe('PromptFactory', () => {
|
||||
expect(prompt).toContain('# State');
|
||||
expect(prompt).toContain('# Research');
|
||||
expect(prompt).toContain('# Requirements');
|
||||
expect(prompt).toContain('executable plans');
|
||||
expect(prompt).toContain('You are a planner.');
|
||||
});
|
||||
|
||||
it('delegates execute phase with plan to buildExecutorPrompt', async () => {
|
||||
@@ -225,7 +224,7 @@ describe('PromptFactory', () => {
|
||||
const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles);
|
||||
|
||||
// Falls through to general assembly path
|
||||
expect(prompt).toContain('## Role');
|
||||
expect(prompt).toContain('## Agent Instructions');
|
||||
expect(prompt).toContain('You are an executor.');
|
||||
expect(prompt).toContain('## Purpose');
|
||||
expect(prompt).toContain('Execute the plan.');
|
||||
@@ -252,7 +251,7 @@ describe('PromptFactory', () => {
|
||||
|
||||
expect(prompt).toContain('You are a verifier.');
|
||||
expect(prompt).toContain('Verify phase goals.');
|
||||
expect(prompt).toContain('goal achievement');
|
||||
expect(prompt).toContain('You are a verifier.');
|
||||
});
|
||||
|
||||
it('assembles discuss prompt without agent role (no dedicated agent)', async () => {
|
||||
@@ -266,12 +265,10 @@ describe('PromptFactory', () => {
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
|
||||
|
||||
// Discuss has no agent, so no Role section
|
||||
expect(prompt).not.toContain('## Role');
|
||||
// Discuss has no agent, so no Agent Instructions section
|
||||
expect(prompt).not.toContain('## Agent Instructions');
|
||||
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 () => {
|
||||
@@ -286,8 +283,8 @@ describe('PromptFactory', () => {
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
|
||||
|
||||
// Should still produce a prompt with role and context
|
||||
expect(prompt).toContain('## Role');
|
||||
// Should still produce a prompt with agent instructions and context
|
||||
expect(prompt).toContain('## Agent Instructions');
|
||||
expect(prompt).toContain('## Context');
|
||||
expect(prompt).not.toContain('## Purpose');
|
||||
});
|
||||
@@ -304,7 +301,7 @@ describe('PromptFactory', () => {
|
||||
|
||||
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
|
||||
|
||||
expect(prompt).not.toContain('## Role');
|
||||
expect(prompt).not.toContain('## Agent Instructions');
|
||||
expect(prompt).toContain('## Purpose');
|
||||
expect(prompt).toContain('Research the phase.');
|
||||
});
|
||||
@@ -401,13 +398,13 @@ describe('PromptFactory', () => {
|
||||
// ─── Headless prompt loading ─────────────────────────────────────────────
|
||||
|
||||
describe('headless prompt loading', () => {
|
||||
it('loadWorkflowFile prefers sdkPromptsDir over GSD-1 workflowsDir', async () => {
|
||||
it('loadWorkflowFile prefers installed GSD over sdkPromptsDir', async () => {
|
||||
const sdkDir = join(tempDir, 'sdk-prompts');
|
||||
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
|
||||
|
||||
// Write both: GSD-1 original and SDK headless version
|
||||
// Write both: installed GSD and SDK bundled version
|
||||
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
|
||||
await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK headless version');
|
||||
await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK bundled version');
|
||||
|
||||
const factory = new PromptFactory({
|
||||
gsdInstallDir: tempDir,
|
||||
@@ -416,7 +413,7 @@ describe('PromptFactory', () => {
|
||||
});
|
||||
|
||||
const content = await factory.loadWorkflowFile(PhaseType.Research);
|
||||
expect(content).toBe('SDK headless version');
|
||||
expect(content).toBe('GSD-1 original');
|
||||
});
|
||||
|
||||
it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => {
|
||||
@@ -436,13 +433,13 @@ describe('PromptFactory', () => {
|
||||
expect(content).toBe('GSD-1 original');
|
||||
});
|
||||
|
||||
it('loadAgentDef prefers sdkPromptsDir over user agents dir', async () => {
|
||||
it('loadAgentDef prefers installed agents over sdkPromptsDir', async () => {
|
||||
const sdkDir = join(tempDir, 'sdk-prompts');
|
||||
await mkdir(join(sdkDir, 'agents'), { recursive: true });
|
||||
|
||||
// Write both: user agent and SDK headless agent
|
||||
// Write both: installed agent and SDK bundled agent
|
||||
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
|
||||
await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK headless agent');
|
||||
await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK bundled agent');
|
||||
|
||||
const factory = new PromptFactory({
|
||||
gsdInstallDir: tempDir,
|
||||
@@ -451,7 +448,7 @@ describe('PromptFactory', () => {
|
||||
});
|
||||
|
||||
const content = await factory.loadAgentDef(PhaseType.Execute);
|
||||
expect(content).toBe('SDK headless agent');
|
||||
expect(content).toBe('user agent');
|
||||
});
|
||||
|
||||
it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { buildExecutorPrompt } from './prompt-builder.js';
|
||||
import { PHASE_AGENT_MAP } from './tool-scoping.js';
|
||||
import { sanitizePrompt } from './prompt-sanitizer.js';
|
||||
|
||||
@@ -62,6 +62,17 @@ export function extractSteps(processContent: string): Array<{ name: string; cont
|
||||
return steps;
|
||||
}
|
||||
|
||||
// ─── YAML frontmatter stripping ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip YAML frontmatter (---...---) from an agent definition file,
|
||||
* returning only the markdown/XML content body.
|
||||
*/
|
||||
export function stripYamlFrontmatter(content: string): string {
|
||||
const match = content.match(/^---\s*\n[\s\S]*?\n---\s*\n?([\s\S]*)$/);
|
||||
return match ? match[1].trim() : content.trim();
|
||||
}
|
||||
|
||||
// ─── PromptFactory class ─────────────────────────────────────────────────────
|
||||
|
||||
export class PromptFactory {
|
||||
@@ -69,17 +80,20 @@ export class PromptFactory {
|
||||
private readonly agentsDir: string;
|
||||
private readonly projectAgentsDir?: string;
|
||||
private readonly sdkPromptsDir: string;
|
||||
private readonly projectDir?: string;
|
||||
|
||||
constructor(options?: {
|
||||
gsdInstallDir?: string;
|
||||
agentsDir?: string;
|
||||
projectAgentsDir?: string;
|
||||
sdkPromptsDir?: string;
|
||||
projectDir?: 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;
|
||||
this.projectDir = options?.projectDir;
|
||||
// SDK prompts dir: explicit override → package-relative default via import.meta.url
|
||||
this.sdkPromptsDir =
|
||||
options?.sdkPromptsDir ??
|
||||
@@ -100,7 +114,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 sanitizePrompt(buildExecutorPrompt(plan, agentDef));
|
||||
return sanitizePrompt(buildExecutorPrompt(plan, agentDef), this.projectDir);
|
||||
}
|
||||
|
||||
// Prompt assembly order is cache-optimized (#1614):
|
||||
@@ -110,12 +124,16 @@ export class PromptFactory {
|
||||
|
||||
// ── STABLE PREFIX (cacheable across runs for the same phase type) ──
|
||||
|
||||
// ── Agent role ──
|
||||
// ── Full agent definition ──
|
||||
// Include the complete agent definition (minus YAML frontmatter), not just
|
||||
// the <role> block. The real agents have critical instructions in sections
|
||||
// like <philosophy>, <task_breakdown>, <plan_format>, <execution_flow>,
|
||||
// <scope_estimation>, <context_fidelity>, <checkpoints>, etc.
|
||||
const agentDef = await this.loadAgentDef(phaseType);
|
||||
if (agentDef) {
|
||||
const role = parseAgentRole(agentDef);
|
||||
if (role) {
|
||||
sections.push(`## Role\n\n${role}`);
|
||||
const agentContent = stripYamlFrontmatter(agentDef);
|
||||
if (agentContent) {
|
||||
sections.push(`## Agent Instructions\n\n${agentContent}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,12 +155,6 @@ export class PromptFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase-specific instructions (hardcoded per phase type — stable) ──
|
||||
const phaseInstructions = this.getPhaseInstructions(phaseType);
|
||||
if (phaseInstructions) {
|
||||
sections.push(`## Phase Instructions\n\n${phaseInstructions}`);
|
||||
}
|
||||
|
||||
// ── VARIABLE SUFFIX (project-specific, changes per run) ──
|
||||
|
||||
// ── Context files ──
|
||||
@@ -151,56 +163,57 @@ export class PromptFactory {
|
||||
sections.push(contextSection);
|
||||
}
|
||||
|
||||
return sanitizePrompt(sections.join('\n\n'));
|
||||
return sanitizePrompt(sections.join('\n\n'), this.projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow file for a phase type.
|
||||
* Tries sdk/prompts/workflows/ first (headless versions), then
|
||||
* falls back to GSD-1 originals in workflowsDir.
|
||||
* Tries installed GSD workflows first (the complete, up-to-date versions),
|
||||
* then falls back to SDK bundled copies only if installed not found.
|
||||
* Returns the raw content, or undefined if not found.
|
||||
*/
|
||||
async loadWorkflowFile(phaseType: PhaseType): Promise<string | undefined> {
|
||||
const filename = PHASE_WORKFLOW_MAP[phaseType];
|
||||
|
||||
// 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
|
||||
// Try installed GSD workflows first (complete versions)
|
||||
const paths = [
|
||||
join(this.workflowsDir, filename),
|
||||
join(this.sdkPromptsDir, 'workflows', filename),
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
return await readFile(p, 'utf-8');
|
||||
} catch {
|
||||
// Not found at this path, try next
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to GSD-1 originals
|
||||
const filePath = join(this.workflowsDir, filename);
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the agent definition for a phase type.
|
||||
* Tries sdk/prompts/agents/ first (headless versions), then
|
||||
* user-level agents dir, then project-level.
|
||||
* Tries installed agents first (the complete, up-to-date versions),
|
||||
* then SDK bundled copies as last resort.
|
||||
* 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 SDK prompts dir first (headless versions)
|
||||
// Priority: installed agents → project-level → SDK bundled (last resort)
|
||||
const paths = [
|
||||
join(this.sdkPromptsDir, 'agents', agentFilename),
|
||||
join(this.agentsDir, agentFilename),
|
||||
];
|
||||
|
||||
// Then project-level if configured
|
||||
if (this.projectAgentsDir) {
|
||||
paths.push(join(this.projectAgentsDir, agentFilename));
|
||||
}
|
||||
|
||||
// SDK bundled copies are last resort only
|
||||
paths.push(join(this.sdkPromptsDir, 'agents', agentFilename));
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
return await readFile(p, 'utf-8');
|
||||
@@ -240,25 +253,6 @@ export class PromptFactory {
|
||||
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 };
|
||||
|
||||
@@ -1,25 +1,71 @@
|
||||
/**
|
||||
* Prompt sanitizer — strips interactive CLI patterns from GSD-1 prompts
|
||||
* so they're safe for headless SDK use.
|
||||
* Prompt sanitizer — resolves @-file references and strips interactive CLI
|
||||
* patterns from GSD-1 prompts so they're safe for headless SDK use.
|
||||
*
|
||||
* Patterns removed:
|
||||
* - @file:... references (file injection directives)
|
||||
* - /gsd-... skill commands
|
||||
* @-file references (e.g., @~/.claude/get-shit-done/references/foo.md) are
|
||||
* resolved by reading the file and inlining the content. This preserves the
|
||||
* critical instructions that the real agent prompts depend on.
|
||||
*
|
||||
* Patterns removed (interactive-only, not useful headless):
|
||||
* - /gsd-... skill commands (can't invoke skills in Agent SDK)
|
||||
* - AskUserQuestion(...) calls
|
||||
* - STOP directives in interactive contexts
|
||||
* - SlashCommand() calls
|
||||
* - 'wait for user' / 'ask the user' instructions
|
||||
*/
|
||||
|
||||
// ─── Pattern definitions ─────────────────────────────────────────────────────
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// ─── @-reference resolution ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Matches @-file references in prompt text. Handles:
|
||||
* - @~/.claude/get-shit-done/references/foo.md
|
||||
* - @~/.claude/get-shit-done/workflows/bar.md
|
||||
* - @.planning/PROJECT.md (project-relative)
|
||||
*
|
||||
* Only resolves references that start a line or follow whitespace,
|
||||
* not email addresses or @ mentions in prose.
|
||||
*/
|
||||
const AT_REFERENCE_PATTERN = /^(\s*)@(~\/[^\s]+|\.planning\/[^\s]+)/gm;
|
||||
|
||||
/**
|
||||
* Resolve @-file references by reading the file and inlining the content.
|
||||
* References that can't be resolved (file not found) are removed silently.
|
||||
*
|
||||
* @param input - Prompt text with @-references
|
||||
* @param projectDir - Project directory for resolving relative paths
|
||||
* @returns Prompt with @-references replaced by file contents
|
||||
*/
|
||||
export function resolveAtReferences(input: string, projectDir?: string): string {
|
||||
if (!input) return input;
|
||||
|
||||
return input.replace(AT_REFERENCE_PATTERN, (_match, indent: string, refPath: string) => {
|
||||
const resolvedPath = refPath.startsWith('~/')
|
||||
? refPath.replace('~/', `${homedir()}/`)
|
||||
: projectDir
|
||||
? `${projectDir}/${refPath}`
|
||||
: refPath;
|
||||
|
||||
try {
|
||||
const content = readFileSync(resolvedPath, 'utf-8').trim();
|
||||
return `${indent}${content}`;
|
||||
} catch {
|
||||
// File not found — remove the reference silently
|
||||
return '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Interactive pattern stripping ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Patterns that are interactive-only and should be stripped for headless use.
|
||||
* Note: @~/... file references are NOT stripped — they're resolved above.
|
||||
*/
|
||||
const LINE_PATTERNS: RegExp[] = [
|
||||
// @file:path/to/something references — entire line
|
||||
// @file:path/to/something references (explicit @file: directive, not @~/...)
|
||||
/^.*@file:\S+.*$/gm,
|
||||
|
||||
// /gsd-command references — entire line containing a skill command
|
||||
@@ -32,7 +78,6 @@ const LINE_PATTERNS: RegExp[] = [
|
||||
/^.*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,
|
||||
|
||||
@@ -44,22 +89,22 @@ const LINE_PATTERNS: RegExp[] = [
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip interactive CLI patterns from a prompt string.
|
||||
* Sanitize a prompt for headless SDK use:
|
||||
* 1. Resolve @-file references (inline the content)
|
||||
* 2. Strip interactive-only patterns
|
||||
*
|
||||
* 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
|
||||
* @param input - Raw prompt string from agent/workflow files
|
||||
* @param projectDir - Project directory for resolving relative @-references
|
||||
* @returns Cleaned prompt ready for Agent SDK use
|
||||
*/
|
||||
export function sanitizePrompt(input: string): string {
|
||||
export function sanitizePrompt(input: string, projectDir?: string): string {
|
||||
if (!input) return input;
|
||||
|
||||
let result = input;
|
||||
// Step 1: Resolve @-file references to inline content
|
||||
let result = resolveAtReferences(input, projectDir);
|
||||
|
||||
// Step 2: Strip interactive-only patterns
|
||||
for (const pattern of LINE_PATTERNS) {
|
||||
// Reset lastIndex for global regexes
|
||||
pattern.lastIndex = 0;
|
||||
result = result.replace(pattern, '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user