Files
msd-core/sdk/src/phase-prompt.ts
Tom Boucher 002bcf2a8a fix(2137): skip worktree isolation when .gitmodules detected (#2144)
* 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>
2026-04-12 08:33:04 -04:00

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 };