Files
msd-core/sdk/src/phase-prompt.ts
Tom Boucher 397c34142a Deepen SDK package seam and converge runtime skills policy (#3238)
* Deepen SDK package seam and converge runtime skills policy

* fix(sdk): unified install-root resolution for workflows and agents (CR finding 1)

Use the already-resolved gsdInstallDir constant instead of calling
resolveLegacyInstallDir() again when computing agentsDir, ensuring
workflowsDir and agentsDir share the same install root.

* fix(sdk): tilde shortening requires path-boundary match (CR finding 2)

Both renderGlobalSkillsBaseDisplayPath and renderGlobalSkillDisplayPath
used startsWith(home) which could incorrectly shorten unrelated paths
sharing the same prefix. Now checks for home === base or
base.startsWith(home + sep) to ensure a real directory boundary.

* fix(sdk): validate loadConfig export before invocation (CR finding 3)

After requiring core.cjs, check typeof mod.loadConfig === 'function'
before calling it. Throws a classified GSDError with the module path
if the export is missing, rather than a generic TypeError.

* fix(test): guard root lookup before .path dereference (CR finding 4)

Added assert.ok() guards for claudeRoot and codexRoot after the .find()
calls so that a missing root produces an explicit assertion failure
rather than a TypeError on .path dereference.

* fix(ci): fail-safe on transient API errors in approval dismissal (CR finding 6)

resolveRole() returns 'unknown' for non-404 errors (rate limits, 5xx,
network blips). shouldDismissReviewer() now treats 'unknown' as
unresolvable and skips dismissal, preventing legitimate approvals from
being dismissed due to a transient API failure. Only 'none' (true 404)
is treated as a confirmed non-collaborator.

* changeset: pr=3238 SDK package seam and runtime skills convergence

* fix(sdk): harden resolveGlobalSkillDir against path traversal (CR finding 1)

Use resolve+relative to validate that skillName cannot escape the global
skills base directory. Values like "../../foo" or absolute paths now
return null instead of joining directly. All imports (resolve, relative,
isAbsolute) were already present in helpers.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): split skill-dir-resolution and skill-not-found warnings (CR finding 2)

After resolveGlobalSkillDir's hardening can return null for traversal
attempts, the old single-branch warning "Global skill not found at ..."
was misleading. Split into two distinct cases:
- skillDir === null → "Could not resolve global skill directory for ..."
- skillMd missing → "Global skill not found at ..."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: lock skill path-traversal rejection in resolveGlobalSkillDir

Regression test verifying that traversal segments (../../foo, ../escape),
empty string, and absolute paths are all rejected (return null), while
a legitimate skill name resolves correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): align display-path contract + traversal coverage for resolveGlobalSkillMarkdownPath (CR nitpicks)

- renderGlobalSkillsBaseDisplayPath now returns a non-null string for
  unsupported runtimes (e.g. cline → "(cline does not use a skills directory)")
  matching the existing renderGlobalSkillDisplayPath contract; callers
  of both helpers no longer need null-checks for unsupported runtimes.
- Remove now-redundant ! non-null assertion on renderGlobalSkillsBaseDisplayPath
  calls in skill-manifest.ts (return type is string, not string | null).
- Extend the path-traversal test block to assert resolveGlobalSkillMarkdownPath
  also propagates null for ../../foo, ../escape, empty, and /abs/path inputs,
  locking the null-propagation contract against future refactors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:06:43 -04:00

260 lines
9.0 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 type { ContextFiles, ParsedPlan } from './types.js';
import { PhaseType } from './types.js';
import { buildExecutorPrompt } from './prompt-builder.js';
import { PHASE_AGENT_MAP } from './tool-scoping.js';
import { sanitizePrompt } from './prompt-sanitizer.js';
import { resolveLegacyInstallDir } from './sdk-package-compatibility.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;
}
// ─── 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 {
private readonly workflowsDir: string;
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 ?? resolveLegacyInstallDir();
this.workflowsDir = join(gsdInstallDir, 'workflows');
this.agentsDir = options?.agentsDir ?? join(gsdInstallDir, '..', '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 ??
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,
phaseDir?: string,
): 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, phaseDir }), this.projectDir);
}
// 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) ──
// ── 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 agentContent = stripYamlFrontmatter(agentDef);
if (agentContent) {
sections.push(`## Agent Instructions\n\n${agentContent}`);
}
}
// ── 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}`);
}
}
}
// ── 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'), this.projectDir);
}
/**
* Load the workflow file for a phase type.
* 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 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
}
}
return undefined;
}
/**
* Load the agent definition for a phase type.
* 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;
// Priority: installed agents → project-level → SDK bundled (last resort)
const paths = [
join(this.agentsDir, agentFilename),
];
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');
} 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')}`;
}
}
export { PHASE_WORKFLOW_MAP };