* 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>
260 lines
9.0 KiB
TypeScript
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 };
|