* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1) Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests. Made-with: Cursor * fix(2137): skip worktree isolation when .gitmodules detected When a project contains git submodules, worktree isolation cannot correctly handle submodule commits — three separate gaps exist in worktree setup, executor commit protocol, and merge-back. Rather than patch each gap individually, detect .gitmodules at phase start and fall back to sequential execution, which handles submodules transparently (Option B). Affected workflows: execute-phase.md, quick.md --------- Co-authored-by: David Sienkowski <dave@sienkowski.com>
265 lines
9.4 KiB
TypeScript
265 lines
9.4 KiB
TypeScript
/**
|
|
* Phase-aware prompt factory — assembles complete prompts for each phase type.
|
|
*
|
|
* Reads workflow .md + agent .md files from disk (D006), extracts structured
|
|
* blocks (<role>, <purpose>, <process>), and composes system prompts with
|
|
* injected context files per phase type.
|
|
*/
|
|
|
|
import { readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { 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, string> = {
|
|
[PhaseType.Execute]: 'execute-plan.md',
|
|
[PhaseType.Research]: 'research-phase.md',
|
|
[PhaseType.Plan]: 'plan-phase.md',
|
|
[PhaseType.Verify]: 'verify-phase.md',
|
|
[PhaseType.Discuss]: 'discuss-phase.md',
|
|
[PhaseType.Repair]: 'execute-plan.md',
|
|
};
|
|
|
|
// ─── XML block extraction ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract content from an XML-style block (e.g., <purpose>...</purpose>).
|
|
* Returns the trimmed inner content, or empty string if not found.
|
|
*/
|
|
export function extractBlock(content: string, tagName: string): string {
|
|
const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
|
const match = content.match(regex);
|
|
return match ? match[1].trim() : '';
|
|
}
|
|
|
|
/**
|
|
* Extract all <step> blocks from a workflow's <process> section.
|
|
* Returns an array of step contents with their name attributes.
|
|
*/
|
|
export function extractSteps(processContent: string): Array<{ name: string; content: string }> {
|
|
const steps: Array<{ name: string; content: string }> = [];
|
|
const stepRegex = /<step\s+name="([^"]*)"[^>]*>([\s\S]*?)<\/step>/gi;
|
|
let match;
|
|
|
|
while ((match = stepRegex.exec(processContent)) !== null) {
|
|
steps.push({
|
|
name: match[1],
|
|
content: match[2].trim(),
|
|
});
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
// ─── PromptFactory class ─────────────────────────────────────────────────────
|
|
|
|
export class PromptFactory {
|
|
private readonly workflowsDir: string;
|
|
private readonly agentsDir: string;
|
|
private readonly projectAgentsDir?: string;
|
|
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<string> {
|
|
// 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<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
|
|
}
|
|
|
|
// 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<string | undefined> {
|
|
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<keyof ContextFiles, string> = {
|
|
state: 'Project State (STATE.md)',
|
|
roadmap: 'Roadmap (ROADMAP.md)',
|
|
context: 'Context (CONTEXT.md)',
|
|
research: 'Research (RESEARCH.md)',
|
|
requirements: 'Requirements (REQUIREMENTS.md)',
|
|
config: 'Config (config.json)',
|
|
plan: 'Plan (PLAN.md)',
|
|
summary: 'Summary (SUMMARY.md)',
|
|
};
|
|
|
|
for (const [key, label] of Object.entries(fileLabels)) {
|
|
const content = contextFiles[key as keyof ContextFiles];
|
|
if (content) {
|
|
entries.push(`### ${label}\n\n${content}`);
|
|
}
|
|
}
|
|
|
|
if (entries.length === 0) return null;
|
|
return `## Context\n\n${entries.join('\n\n')}`;
|
|
}
|
|
|
|
/**
|
|
* Get phase-specific instructions that aren't covered by the workflow file.
|
|
*/
|
|
private getPhaseInstructions(phaseType: PhaseType): string | null {
|
|
switch (phaseType) {
|
|
case PhaseType.Research:
|
|
return 'Focus on technical investigation. Do not modify source files. Produce RESEARCH.md with findings organized by topic, confidence levels (HIGH/MEDIUM/LOW), and specific recommendations.';
|
|
case PhaseType.Plan:
|
|
return 'Create executable plans with task breakdown, dependency analysis, and verification criteria. Each task must have clear acceptance criteria and a done condition.';
|
|
case PhaseType.Verify:
|
|
return 'Verify goal achievement, not just task completion. Start from what the phase SHOULD deliver, then verify it actually exists and works. Produce VERIFICATION.md with pass/fail for each criterion.';
|
|
case PhaseType.Discuss:
|
|
return 'Extract implementation decisions that downstream agents need. Identify gray areas, capture decisions that guide research and planning.';
|
|
case PhaseType.Execute:
|
|
return null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export { PHASE_WORKFLOW_MAP };
|