/** * Phase-aware prompt factory — assembles complete prompts for each phase type. * * Reads workflow .md + agent .md files from disk (D006), extracts structured * blocks (, , ), and composes system prompts with * injected context files per phase type. */ 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 ─────────────────────────────────────────────────── /** * Maps phase types to their workflow file names. */ const PHASE_WORKFLOW_MAP: Record = { [PhaseType.Execute]: 'execute-plan.md', [PhaseType.Research]: 'research-phase.md', [PhaseType.Plan]: 'plan-phase.md', [PhaseType.Verify]: 'verify-phase.md', [PhaseType.Discuss]: 'discuss-phase.md', [PhaseType.Repair]: 'execute-plan.md', }; // ─── XML block extraction ──────────────────────────────────────────────────── /** * Extract content from an XML-style block (e.g., ...). * Returns the trimmed inner content, or empty string if not found. */ export function extractBlock(content: string, tagName: string): string { const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'); const match = content.match(regex); return match ? match[1].trim() : ''; } /** * Extract all blocks from a workflow's section. * Returns an array of step contents with their name attributes. */ export function extractSteps(processContent: string): Array<{ name: string; content: string }> { const steps: Array<{ name: string; content: string }> = []; const stepRegex = /]*>([\s\S]*?)<\/step>/gi; let match; while ((match = stepRegex.exec(processContent)) !== null) { steps.push({ name: match[1], content: match[2].trim(), }); } return steps; } // ─── PromptFactory class ───────────────────────────────────────────────────── export class PromptFactory { private readonly workflowsDir: string; private readonly agentsDir: string; private readonly projectAgentsDir?: string; 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'); } /** * Build a complete prompt for the given phase type. * * For execute phase with a plan, delegates to buildExecutorPrompt(). * For other phases, assembles: role + purpose + process steps + context. */ async buildPrompt( phaseType: PhaseType, plan: ParsedPlan | null, contextFiles: ContextFiles, ): Promise { // 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)); } // Prompt assembly order is cache-optimized (#1614): // Stable prefix (deterministic per phase type) → cached by Anthropic at 0.1x cost // Variable suffix (.planning/ files) → uncached, changes per project/run const sections: string[] = []; // ── STABLE PREFIX (cacheable across runs for the same phase type) ── // ── Agent role ── const agentDef = await this.loadAgentDef(phaseType); if (agentDef) { const role = parseAgentRole(agentDef); if (role) { sections.push(`## Role\n\n${role}`); } } // ── Workflow purpose + process ── const workflow = await this.loadWorkflowFile(phaseType); if (workflow) { const purpose = extractBlock(workflow, 'purpose'); if (purpose) { sections.push(`## Purpose\n\n${purpose}`); } const process = extractBlock(workflow, 'process'); if (process) { const steps = extractSteps(process); if (steps.length > 0) { const stepBlocks = steps.map((s) => `### ${s.name}\n\n${s.content}`).join('\n\n'); sections.push(`## Process\n\n${stepBlocks}`); } } } // ── 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 ── const contextSection = this.formatContextFiles(contextFiles); if (contextSection) { sections.push(contextSection); } 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 { 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 } // Fall back to GSD-1 originals const filePath = join(this.workflowsDir, filename); try { return await readFile(filePath, 'utf-8'); } catch { return undefined; } } /** * Load the agent definition for a phase type. * Tries 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 { const agentFilename = PHASE_AGENT_MAP[phaseType]; if (!agentFilename) return undefined; // 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) { paths.push(join(this.projectAgentsDir, agentFilename)); } for (const p of paths) { try { return await readFile(p, 'utf-8'); } catch { // Not found at this path, try next } } return undefined; } /** * Format context files into a prompt section. */ private formatContextFiles(contextFiles: ContextFiles): string | null { const entries: string[] = []; const fileLabels: Record = { state: 'Project State (STATE.md)', roadmap: 'Roadmap (ROADMAP.md)', context: 'Context (CONTEXT.md)', research: 'Research (RESEARCH.md)', requirements: 'Requirements (REQUIREMENTS.md)', config: 'Config (config.json)', plan: 'Plan (PLAN.md)', summary: 'Summary (SUMMARY.md)', }; for (const [key, label] of Object.entries(fileLabels)) { const content = contextFiles[key as keyof ContextFiles]; if (content) { entries.push(`### ${label}\n\n${content}`); } } if (entries.length === 0) return null; return `## Context\n\n${entries.join('\n\n')}`; } /** * Get phase-specific instructions that aren't covered by the workflow file. */ private getPhaseInstructions(phaseType: PhaseType): string | null { switch (phaseType) { case PhaseType.Research: return 'Focus on technical investigation. Do not modify source files. Produce RESEARCH.md with findings organized by topic, confidence levels (HIGH/MEDIUM/LOW), and specific recommendations.'; case PhaseType.Plan: return 'Create executable plans with task breakdown, dependency analysis, and verification criteria. Each task must have clear acceptance criteria and a done condition.'; case PhaseType.Verify: return 'Verify goal achievement, not just task completion. Start from what the phase SHOULD deliver, then verify it actually exists and works. Produce VERIFICATION.md with pass/fail for each criterion.'; case PhaseType.Discuss: return 'Extract implementation decisions that downstream agents need. Identify gray areas, capture decisions that guide research and planning.'; case PhaseType.Execute: return null; default: return null; } } } export { PHASE_WORKFLOW_MAP };