refactor(auto): decompose auto.ts into focused modules (#534)

* refactor(auto): decompose auto.ts into focused sub-modules (#518)

Extract ~730 lines from auto.ts (3,819 -> 3,097 lines) into three
focused modules:

- auto-recovery.ts: artifact resolution/verification, skip artifacts,
  completed-unit persistence, merge reconciliation, self-heal, loop
  remediation steps
- auto-dashboard.ts: progress widget, elapsed time formatting, unit
  description helpers, slice progress cache, footer factory
- auto-supervisor.ts: SIGTERM handling, working-tree activity detection

auto.ts retains all state machine logic (dispatchNextUnit, handleAgentEnd,
startAuto, stopAuto, pauseAuto, recoverTimedOutUnit) and the module-level
globals. Sub-modules are pure functions receiving parameters — no circular
dependencies or AutoContext abstraction.

All existing exports preserved via re-exports. Tests updated to reflect
the source file changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(auto): extract prompt builders + fix require() in recovery

- Extract 11 prompt builder functions, 6 inline helpers, 2 adaptive
  replanning checks, and text utilities into auto-prompts.ts (785 lines)
- Replace inline merge reconciliation block with reconcileMergeState()
  call (already existed in auto-recovery.ts but was duplicated)
- Fix CommonJS require("node:fs") in auto-recovery.ts → ESM import
- auto.ts: 3,819 → 2,321 lines (39% reduction)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Merge origin/main into refactor/518-decompose-auto-ts

Resolve conflicts in auto.ts:
- Keep PR's refactored imports (extracted to sub-modules)
- Add main's new BudgetEnforcementMode type import
- Add main's new sendDesktopNotification import
- Add main's budget alert functions (getBudgetAlertLevel, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-15 16:43:48 -06:00 committed by GitHub
parent 7bef5a8f8d
commit a43836ffbb
7 changed files with 1828 additions and 1594 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gsd-pi",
"version": "2.13.1",
"version": "2.14.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gsd-pi",
"version": "2.13.1",
"version": "2.14.4",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [

View file

@ -0,0 +1,432 @@
/**
* Auto-mode Dashboard progress widget rendering, elapsed time formatting,
* unit description helpers, and slice progress caching.
*
* Pure functions that accept specific parameters no module-level globals
* or AutoContext dependency. State accessors are passed as callbacks.
*/
import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import type { GSDState } from "./types.js";
import { getCurrentBranch } from "./worktree.js";
import { getActiveHook } from "./post-unit-hooks.js";
import { getLedger, getProjectTotals, formatCost, formatTokenCount } from "./metrics.js";
import {
resolveMilestoneFile,
resolveSliceFile,
} from "./paths.js";
import { parseRoadmap, parsePlan } from "./files.js";
import { readFileSync, existsSync } from "node:fs";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
// ─── Dashboard Data ───────────────────────────────────────────────────────────
/** Dashboard data for the overlay */
export interface AutoDashboardData {
active: boolean;
paused: boolean;
stepMode: boolean;
startTime: number;
elapsed: number;
currentUnit: { type: string; id: string; startedAt: number } | null;
completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[];
basePath: string;
/** Running cost and token totals from metrics ledger */
totalCost: number;
totalTokens: number;
}
// ─── Unit Description Helpers ─────────────────────────────────────────────────
export function unitVerb(unitType: string): string {
if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`;
switch (unitType) {
case "research-milestone":
case "research-slice": return "researching";
case "plan-milestone":
case "plan-slice": return "planning";
case "execute-task": return "executing";
case "complete-slice": return "completing";
case "replan-slice": return "replanning";
case "reassess-roadmap": return "reassessing";
case "run-uat": return "running UAT";
default: return unitType;
}
}
export function unitPhaseLabel(unitType: string): string {
if (unitType.startsWith("hook/")) return "HOOK";
switch (unitType) {
case "research-milestone": return "RESEARCH";
case "research-slice": return "RESEARCH";
case "plan-milestone": return "PLAN";
case "plan-slice": return "PLAN";
case "execute-task": return "EXECUTE";
case "complete-slice": return "COMPLETE";
case "replan-slice": return "REPLAN";
case "reassess-roadmap": return "REASSESS";
case "run-uat": return "UAT";
default: return unitType.toUpperCase();
}
}
function peekNext(unitType: string, state: GSDState): string {
// Show active hook info in progress display
const activeHookState = getActiveHook();
if (activeHookState) {
return `hook: ${activeHookState.hookName} (cycle ${activeHookState.cycle})`;
}
const sid = state.activeSlice?.id ?? "";
if (unitType.startsWith("hook/")) return `continue ${sid}`;
switch (unitType) {
case "research-milestone": return "plan milestone roadmap";
case "plan-milestone": return "plan or execute first slice";
case "research-slice": return `plan ${sid}`;
case "plan-slice": return "execute first task";
case "execute-task": return `continue ${sid}`;
case "complete-slice": return "reassess roadmap";
case "replan-slice": return `re-execute ${sid}`;
case "reassess-roadmap": return "advance to next slice";
case "run-uat": return "reassess roadmap";
default: return "";
}
}
/**
* Describe what the next unit will be, based on current state.
*/
export function describeNextUnit(state: GSDState): { label: string; description: string } {
const sid = state.activeSlice?.id;
const sTitle = state.activeSlice?.title;
const tid = state.activeTask?.id;
const tTitle = state.activeTask?.title;
switch (state.phase) {
case "needs-discussion":
return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." };
case "pre-planning":
return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
case "planning":
return { label: `Plan ${sid}: ${sTitle}`, description: "Research and decompose into tasks." };
case "executing":
return { label: `Execute ${tid}: ${tTitle}`, description: "Run the next task in a fresh session." };
case "summarizing":
return { label: `Complete ${sid}: ${sTitle}`, description: "Write summary, UAT, and merge to main." };
case "replanning-slice":
return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." };
case "completing-milestone":
return { label: "Complete milestone", description: "Write milestone summary." };
default:
return { label: "Continue", description: "Execute the next step." };
}
}
// ─── Elapsed Time Formatting ──────────────────────────────────────────────────
/** Format elapsed time since auto-mode started */
export function formatAutoElapsed(autoStartTime: number): string {
if (!autoStartTime) return "";
const ms = Date.now() - autoStartTime;
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}m${rs > 0 ? ` ${rs}s` : ""}`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
/** Format token counts for compact display */
export function formatWidgetTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
return `${Math.round(count / 1000000)}M`;
}
// ─── Slice Progress Cache ─────────────────────────────────────────────────────
/** Cached slice progress for the widget — avoid async in render */
let cachedSliceProgress: {
done: number;
total: number;
milestoneId: string;
/** Real task progress for the active slice, if its plan file exists */
activeSliceTasks: { done: number; total: number } | null;
} | null = null;
export function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void {
try {
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
if (!roadmapFile) return;
const content = readFileSync(roadmapFile, "utf-8");
const roadmap = parseRoadmap(content);
let activeSliceTasks: { done: number; total: number } | null = null;
if (activeSid) {
try {
const planFile = resolveSliceFile(base, mid, activeSid, "PLAN");
if (planFile && existsSync(planFile)) {
const planContent = readFileSync(planFile, "utf-8");
const plan = parsePlan(planContent);
activeSliceTasks = {
done: plan.tasks.filter(t => t.done).length,
total: plan.tasks.length,
};
}
} catch {
// Non-fatal — just omit task count
}
}
cachedSliceProgress = {
done: roadmap.slices.filter(s => s.done).length,
total: roadmap.slices.length,
milestoneId: mid,
activeSliceTasks,
};
} catch {
// Non-fatal — widget just won't show progress bar
}
}
export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null {
return cachedSliceProgress;
}
export function clearSliceProgressCache(): void {
cachedSliceProgress = null;
}
// ─── Footer Factory ───────────────────────────────────────────────────────────
/**
* Footer factory that renders zero lines hides the built-in footer entirely.
* All footer info (pwd, branch, tokens, cost, model) is shown inside the
* progress widget instead, so there's no gap or redundancy.
*/
export const hideFooter = () => ({
render(_width: number): string[] { return []; },
invalidate() {},
dispose() {},
});
// ─── Progress Widget ──────────────────────────────────────────────────────────
/** State accessors passed to updateProgressWidget to avoid direct global access */
export interface WidgetStateAccessors {
getAutoStartTime(): number;
isStepMode(): boolean;
getCmdCtx(): ExtensionCommandContext | null;
getBasePath(): string;
isVerbose(): boolean;
}
export function updateProgressWidget(
ctx: ExtensionContext,
unitType: string,
unitId: string,
state: GSDState,
accessors: WidgetStateAccessors,
): void {
if (!ctx.hasUI) return;
const verb = unitVerb(unitType);
const phaseLabel = unitPhaseLabel(unitType);
const mid = state.activeMilestone;
const slice = state.activeSlice;
const task = state.activeTask;
const next = peekNext(unitType, state);
// Cache git branch at widget creation time (not per render)
let cachedBranch: string | null = null;
try { cachedBranch = getCurrentBranch(accessors.getBasePath()); } catch { /* not in git repo */ }
// Cache pwd with ~ substitution
let widgetPwd = process.cwd();
const widgetHome = process.env.HOME || process.env.USERPROFILE;
if (widgetHome && widgetPwd.startsWith(widgetHome)) {
widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`;
}
if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`;
ctx.ui.setWidget("gsd-progress", (tui, theme) => {
let pulseBright = true;
let cachedLines: string[] | undefined;
let cachedWidth: number | undefined;
const pulseTimer = setInterval(() => {
pulseBright = !pulseBright;
cachedLines = undefined;
tui.requestRender();
}, 800);
return {
render(width: number): string[] {
if (cachedLines && cachedWidth === width) return cachedLines;
const ui = makeUI(theme, width);
const lines: string[] = [];
const pad = INDENT.base;
// ── Line 1: Top bar ───────────────────────────────────────────────
lines.push(...ui.bar());
const dot = pulseBright
? theme.fg("accent", GLYPH.statusActive)
: theme.fg("dim", GLYPH.statusPending);
const elapsed = formatAutoElapsed(accessors.getAutoStartTime());
const modeTag = accessors.isStepMode() ? "NEXT" : "AUTO";
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
const headerRight = elapsed ? theme.fg("dim", elapsed) : "";
lines.push(rightAlign(headerLeft, headerRight, width));
lines.push("");
if (mid) {
lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width));
}
if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") {
lines.push(truncateToWidth(
`${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`,
width,
));
}
lines.push("");
const target = task ? `${task.id}: ${task.title}` : unitId;
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
const phaseBadge = theme.fg("dim", phaseLabel);
lines.push(rightAlign(actionLeft, phaseBadge, width));
lines.push("");
if (mid) {
const roadmapSlices = getRoadmapSlicesSync();
if (roadmapSlices) {
const { done, total, activeSliceTasks } = roadmapSlices;
const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3)));
const pct = total > 0 ? done / total : 0;
const filled = Math.round(pct * barWidth);
const bar = theme.fg("success", "█".repeat(filled))
+ theme.fg("dim", "░".repeat(barWidth - filled));
let meta = theme.fg("dim", `${done}/${total} slices`);
if (activeSliceTasks && activeSliceTasks.total > 0) {
meta += theme.fg("dim", ` · task ${activeSliceTasks.done + 1}/${activeSliceTasks.total}`);
}
lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width));
}
}
lines.push("");
if (next) {
lines.push(truncateToWidth(
`${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`,
width,
));
}
// ── Footer info (pwd, tokens, cost, context, model) ──────────────
lines.push("");
lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…")));
// Token stats from current unit session + cumulative cost from metrics
{
const cmdCtx = accessors.getCmdCtx();
let totalInput = 0, totalOutput = 0;
let totalCacheRead = 0, totalCacheWrite = 0;
if (cmdCtx) {
for (const entry of cmdCtx.sessionManager.getEntries()) {
if (entry.type === "message" && (entry as any).message?.role === "assistant") {
const u = (entry as any).message.usage;
if (u) {
totalInput += u.input || 0;
totalOutput += u.output || 0;
totalCacheRead += u.cacheRead || 0;
totalCacheWrite += u.cacheWrite || 0;
}
}
}
}
const mLedger = getLedger();
const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
const cumulativeCost = autoTotals?.cost ?? 0;
const cxUsage = cmdCtx?.getContextUsage?.();
const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
const cxPctVal = cxUsage?.percent ?? 0;
const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
const sp: string[] = [];
if (totalInput) sp.push(`${formatWidgetTokens(totalInput)}`);
if (totalOutput) sp.push(`${formatWidgetTokens(totalOutput)}`);
if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`);
if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`);
if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`);
const cxDisplay = cxPct === "?"
? `?/${formatWidgetTokens(cxWindow)}`
: `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
if (cxPctVal > 90) {
sp.push(theme.fg("error", cxDisplay));
} else if (cxPctVal > 70) {
sp.push(theme.fg("warning", cxDisplay));
} else {
sp.push(cxDisplay);
}
const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
.join(theme.fg("dim", " "));
const modelId = cmdCtx?.model?.id ?? "";
const modelProvider = cmdCtx?.model?.provider ?? "";
const modelPhase = phaseLabel ? theme.fg("dim", `[${phaseLabel}] `) : "";
const modelDisplay = modelProvider && modelId
? `${modelProvider}/${modelId}`
: modelId;
const sRight = modelDisplay
? `${modelPhase}${theme.fg("dim", modelDisplay)}`
: "";
lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
}
const hintParts: string[] = [];
hintParts.push("esc pause");
hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard");
lines.push(...ui.hints(hintParts));
lines.push(...ui.bar());
cachedLines = lines;
cachedWidth = width;
return lines;
},
invalidate() {
cachedLines = undefined;
cachedWidth = undefined;
},
dispose() {
clearInterval(pulseTimer);
},
};
});
}
// ─── Right-align Helper ───────────────────────────────────────────────────────
/** Right-align helper: build a line with left content and right content. */
function rightAlign(left: string, right: string, width: number): string {
const leftVis = visibleWidth(left);
const rightVis = visibleWidth(right);
const gap = Math.max(1, width - leftVis - rightVis);
return truncateToWidth(left + " ".repeat(gap) + right, width);
}

View file

@ -0,0 +1,785 @@
/**
* Auto-mode Prompt Builders construct dispatch prompts for each unit type.
*
* Pure async functions that load templates and inline file content. No module-level
* state, no globals every dependency is passed as a parameter or imported as a
* utility.
*/
import { loadFile, parseContinue, parseRoadmap, parseSummary, extractUatType } from "./files.js";
import type { UatType } from "./files.js";
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
import {
resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
resolveTasksDir, resolveTaskFiles, resolveTaskFile,
relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath,
resolveGsdRootFile, relGsdRootFile,
} from "./paths.js";
import { resolveSkillDiscoveryMode } from "./preferences.js";
import type { GSDState } from "./types.js";
import type { GSDPreferences } from "./preferences.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
// ─── Inline Helpers ───────────────────────────────────────────────────────
/**
* Load a file and format it for inlining into a prompt.
* Returns the content wrapped with a source path header, or a fallback
* message if the file doesn't exist. This eliminates tool calls the LLM
* gets the content directly instead of "Read this file:".
*/
export async function inlineFile(
absPath: string | null, relPath: string, label: string,
): Promise<string> {
const content = absPath ? await loadFile(absPath) : null;
if (!content) {
return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`;
}
return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`;
}
/**
* Load a file for inlining, returning null if it doesn't exist.
* Use when the file is optional and should be omitted entirely if absent.
*/
export async function inlineFileOptional(
absPath: string | null, relPath: string, label: string,
): Promise<string | null> {
const content = absPath ? await loadFile(absPath) : null;
if (!content) return null;
return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`;
}
/**
* Load and inline dependency slice summaries (full content, not just paths).
*/
export async function inlineDependencySummaries(
mid: string, sid: string, base: string,
): Promise<string> {
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (!roadmapContent) return "- (no dependencies)";
const roadmap = parseRoadmap(roadmapContent);
const sliceEntry = roadmap.slices.find(s => s.id === sid);
if (!sliceEntry || sliceEntry.depends.length === 0) return "- (no dependencies)";
const sections: string[] = [];
const seen = new Set<string>();
for (const dep of sliceEntry.depends) {
if (seen.has(dep)) continue;
seen.add(dep);
const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY");
const summaryContent = summaryFile ? await loadFile(summaryFile) : null;
const relPath = relSliceFile(base, mid, dep, "SUMMARY");
if (summaryContent) {
sections.push(`#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`);
} else {
sections.push(`- \`${relPath}\` _(not found)_`);
}
}
return sections.join("\n\n");
}
/**
* Load a well-known .gsd/ root file for optional inlining.
* Handles the existsSync check internally.
*/
export async function inlineGsdRootFile(
base: string, filename: string, label: string,
): Promise<string | null> {
const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS";
const absPath = resolveGsdRootFile(base, key);
if (!existsSync(absPath)) return null;
return inlineFileOptional(absPath, relGsdRootFile(key), label);
}
// ─── Skill Discovery ──────────────────────────────────────────────────────
/**
* Build the skill discovery template variables for research prompts.
* Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution.
*/
export function buildSkillDiscoveryVars(): { skillDiscoveryMode: string; skillDiscoveryInstructions: string } {
const mode = resolveSkillDiscoveryMode();
if (mode === "off") {
return {
skillDiscoveryMode: "off",
skillDiscoveryInstructions: " Skill discovery is disabled. Skip this step.",
};
}
const autoInstall = mode === "auto";
const instructions = `
Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI).
For each, check if a professional agent skill already exists:
- First check \`<available_skills>\` in your system prompt — a skill may already be installed.
- For technologies without an installed skill, run: \`npx skills find "<technology>"\`
- Only consider skills that are **directly relevant** to core technologies not tangentially related.
- Evaluate results by install count and relevance to the actual work.${autoInstall
? `
- Install relevant skills: \`npx skills add <owner/repo@skill> -g -y\`
- Record installed skills in the "Skills Discovered" section of your research output.
- Installed skills will automatically appear in subsequent units' system prompts no manual steps needed.`
: `
- Note promising skills in your research output with their install commands, but do NOT install them.
- The user will decide which to install.`
}`;
return {
skillDiscoveryMode: mode,
skillDiscoveryInstructions: instructions,
};
}
// ─── Text Helpers ──────────────────────────────────────────────────────────
export function extractMarkdownSection(content: string, heading: string): string | null {
const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content);
if (!match) return null;
const start = match.index + match[0].length;
const rest = content.slice(start);
const nextHeading = rest.match(/^##\s+/m);
const end = nextHeading?.index ?? rest.length;
return rest.slice(0, end).trim();
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
// ─── Section Builders ──────────────────────────────────────────────────────
export function buildResumeSection(
continueContent: string | null,
legacyContinueContent: string | null,
continueRelPath: string,
legacyContinueRelPath: string | null,
): string {
const resolvedContent = continueContent ?? legacyContinueContent;
const resolvedRelPath = continueContent ? continueRelPath : legacyContinueRelPath;
if (!resolvedContent || !resolvedRelPath) {
return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n");
}
const cont = parseContinue(resolvedContent);
const lines = [
"## Resume State",
`Source: \`${resolvedRelPath}\``,
`- Status: ${cont.frontmatter.status || "in_progress"}`,
];
if (cont.frontmatter.step && cont.frontmatter.totalSteps) {
lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`);
}
if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`);
if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`);
if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`);
if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`);
return lines.join("\n");
}
export async function buildCarryForwardSection(priorSummaryPaths: string[], base: string): Promise<string> {
if (priorSummaryPaths.length === 0) {
return ["## Carry-Forward Context", "- No prior task summaries in this slice."].join("\n");
}
const items = await Promise.all(priorSummaryPaths.map(async (relPath) => {
const absPath = join(base, relPath);
const content = await loadFile(absPath);
if (!content) return `- \`${relPath}\``;
const summary = parseSummary(content);
const provided = summary.frontmatter.provides.slice(0, 2).join("; ");
const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; ");
const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; ");
const diagnostics = extractMarkdownSection(content, "Diagnostics");
const parts = [summary.title || relPath];
if (summary.oneLiner) parts.push(summary.oneLiner);
if (provided) parts.push(`provides: ${provided}`);
if (decisions) parts.push(`decisions: ${decisions}`);
if (patterns) parts.push(`patterns: ${patterns}`);
if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`);
return `- \`${relPath}\`${parts.join(" | ")}`;
}));
return ["## Carry-Forward Context", ...items].join("\n");
}
export function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
if (!content) {
return [
"## Slice Plan Excerpt",
`Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`,
].join("\n");
}
const lines = content.split("\n");
const goalLine = lines.find(l => l.startsWith("**Goal:**"))?.trim();
const demoLine = lines.find(l => l.startsWith("**Demo:**"))?.trim();
const verification = extractMarkdownSection(content, "Verification");
const observability = extractMarkdownSection(content, "Observability / Diagnostics");
const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``];
if (goalLine) parts.push(goalLine);
if (demoLine) parts.push(demoLine);
if (verification) {
parts.push("", "### Slice Verification", verification.trim());
}
if (observability) {
parts.push("", "### Slice Observability / Diagnostics", observability.trim());
}
return parts.join("\n");
}
// ─── Prior Task Summaries ──────────────────────────────────────────────────
export async function getPriorTaskSummaryPaths(
mid: string, sid: string, currentTid: string, base: string,
): Promise<string[]> {
const tDir = resolveTasksDir(base, mid, sid);
if (!tDir) return [];
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
const currentNum = parseInt(currentTid.replace(/^T/, ""), 10);
const sRel = relSlicePath(base, mid, sid);
return summaryFiles
.filter(f => {
const num = parseInt(f.replace(/^T/, ""), 10);
return num < currentNum;
})
.map(f => `${sRel}/tasks/${f}`);
}
// ─── Adaptive Replanning Checks ────────────────────────────────────────────
/**
* Check if the most recently completed slice needs reassessment.
* Returns { sliceId } if reassessment is needed, null otherwise.
*
* Skips reassessment when:
* - No roadmap exists yet
* - No slices are completed
* - The last completed slice already has an assessment file
* - All slices are complete (milestone done no point reassessing)
*/
export async function checkNeedsReassessment(
base: string, mid: string, state: GSDState,
): Promise<{ sliceId: string } | null> {
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (!roadmapContent) return null;
const roadmap = parseRoadmap(roadmapContent);
const completedSlices = roadmap.slices.filter(s => s.done);
const incompleteSlices = roadmap.slices.filter(s => !s.done);
// No completed slices or all slices done — skip
if (completedSlices.length === 0 || incompleteSlices.length === 0) return null;
// Check the last completed slice
const lastCompleted = completedSlices[completedSlices.length - 1];
const assessmentFile = resolveSliceFile(base, mid, lastCompleted.id, "ASSESSMENT");
const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile));
if (hasAssessment) return null;
// Also need a summary to reassess against
const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY");
const hasSummary = !!(summaryFile && await loadFile(summaryFile));
if (!hasSummary) return null;
return { sliceId: lastCompleted.id };
}
/**
* Check if the most recently completed slice needs a UAT run.
* Returns { sliceId, uatType } if UAT should be dispatched, null otherwise.
*
* Skips when:
* - No roadmap or no completed slices
* - All slices are done (milestone complete path reassessment handles it)
* - uat_dispatch preference is not enabled
* - No UAT file exists for the slice
* - UAT result file already exists (idempotent already ran)
*/
export async function checkNeedsRunUat(
base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined,
): Promise<{ sliceId: string; uatType: UatType } | null> {
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (!roadmapContent) return null;
const roadmap = parseRoadmap(roadmapContent);
const completedSlices = roadmap.slices.filter(s => s.done);
const incompleteSlices = roadmap.slices.filter(s => !s.done);
// No completed slices — nothing to UAT yet
if (completedSlices.length === 0) return null;
// All slices done — milestone complete path, skip (reassessment handles)
if (incompleteSlices.length === 0) return null;
// uat_dispatch must be opted in
if (!prefs?.uat_dispatch) return null;
// Take the last completed slice
const lastCompleted = completedSlices[completedSlices.length - 1];
const sid = lastCompleted.id;
// UAT file must exist
const uatFile = resolveSliceFile(base, mid, sid, "UAT");
if (!uatFile) return null;
const uatContent = await loadFile(uatFile);
if (!uatContent) return null;
// If UAT result already exists, skip (idempotent)
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
if (uatResultFile) {
const hasResult = !!(await loadFile(uatResultFile));
if (hasResult) return null;
}
// Classify UAT type; unknown type → treat as human-experience (human review)
const uatType = extractUatType(uatContent) ?? "human-experience";
return { sliceId: sid, uatType };
}
// ─── Prompt Builders ──────────────────────────────────────────────────────
export async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
const inlined: string[] = [];
inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context"));
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
if (projectInline) inlined.push(projectInline);
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
if (requirementsInline) inlined.push(requirementsInline);
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
if (decisionsInline) inlined.push(decisionsInline);
inlined.push(inlineTemplate("research", "Research"));
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
return loadPrompt("research-milestone", {
milestoneId: mid, milestoneTitle: midTitle,
milestonePath: relMilestonePath(base, mid),
contextPath: contextRel,
outputPath: outputRelPath,
inlinedContext,
...buildSkillDiscoveryVars(),
});
}
export async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
const researchRel = relMilestoneFile(base, mid, "RESEARCH");
const inlined: string[] = [];
inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context"));
const researchInline = await inlineFileOptional(researchPath, researchRel, "Milestone Research");
if (researchInline) inlined.push(researchInline);
const { inlinePriorMilestoneSummary } = await import("./files.js");
const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base);
if (priorSummaryInline) inlined.push(priorSummaryInline);
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
if (projectInline) inlined.push(projectInline);
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
if (requirementsInline) inlined.push(requirementsInline);
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
if (decisionsInline) inlined.push(decisionsInline);
inlined.push(inlineTemplate("roadmap", "Roadmap"));
inlined.push(inlineTemplate("decisions", "Decisions"));
inlined.push(inlineTemplate("plan", "Slice Plan"));
inlined.push(inlineTemplate("task-plan", "Task Plan"));
inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest"));
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
return loadPrompt("plan-milestone", {
milestoneId: mid, milestoneTitle: midTitle,
milestonePath: relMilestonePath(base, mid),
contextPath: contextRel,
researchPath: researchRel,
outputPath: outputRelPath,
secretsOutputPath,
inlinedContext,
});
}
export async function buildResearchSlicePrompt(
mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
): Promise<string> {
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH");
const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH");
const inlined: string[] = [];
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
if (contextInline) inlined.push(contextInline);
const researchInline = await inlineFileOptional(milestoneResearchPath, milestoneResearchRel, "Milestone Research");
if (researchInline) inlined.push(researchInline);
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
if (decisionsInline) inlined.push(decisionsInline);
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
if (requirementsInline) inlined.push(requirementsInline);
inlined.push(inlineTemplate("research", "Research"));
const depContent = await inlineDependencySummaries(mid, sid, base);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
return loadPrompt("research-slice", {
milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
slicePath: relSlicePath(base, mid, sid),
roadmapPath: roadmapRel,
contextPath: contextRel,
milestoneResearchPath: milestoneResearchRel,
outputPath: outputRelPath,
inlinedContext,
dependencySummaries: depContent,
...buildSkillDiscoveryVars(),
});
}
export async function buildPlanSlicePrompt(
mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
): Promise<string> {
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH");
const researchRel = relSliceFile(base, mid, sid, "RESEARCH");
const inlined: string[] = [];
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research");
if (researchInline) inlined.push(researchInline);
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
if (decisionsInline) inlined.push(decisionsInline);
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
if (requirementsInline) inlined.push(requirementsInline);
inlined.push(inlineTemplate("plan", "Slice Plan"));
inlined.push(inlineTemplate("task-plan", "Task Plan"));
const depContent = await inlineDependencySummaries(mid, sid, base);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
return loadPrompt("plan-slice", {
milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
slicePath: relSlicePath(base, mid, sid),
roadmapPath: roadmapRel,
researchPath: researchRel,
outputPath: outputRelPath,
inlinedContext,
dependencySummaries: depContent,
});
}
export async function buildExecuteTaskPrompt(
mid: string, sid: string, sTitle: string,
tid: string, tTitle: string, base: string,
): Promise<string> {
const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base);
const priorLines = priorSummaries.length > 0
? priorSummaries.map(p => `- \`${p}\``).join("\n")
: "- (no prior tasks)";
const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN");
const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null;
const taskPlanRelPath = relSlicePath(base, mid, sid) + `/tasks/${tid}-PLAN.md`;
const taskPlanInline = taskPlanContent
? [
"## Inlined Task Plan (authoritative local execution contract)",
`Source: \`${taskPlanRelPath}\``,
"",
taskPlanContent.trim(),
].join("\n")
: [
"## Inlined Task Plan (authoritative local execution contract)",
`Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`,
].join("\n");
const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null;
const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, relSliceFile(base, mid, sid, "PLAN"));
// Check for continue file (new naming or legacy)
const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE");
const legacyContinueDir = resolveSlicePath(base, mid, sid);
const legacyContinuePath = legacyContinueDir ? join(legacyContinueDir, "continue.md") : null;
const continueContent = continueFile ? await loadFile(continueFile) : null;
const legacyContinueContent = !continueContent && legacyContinuePath ? await loadFile(legacyContinuePath) : null;
const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE");
const resumeSection = buildResumeSection(
continueContent,
legacyContinueContent,
continueRelPath,
legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null,
);
const carryForwardSection = await buildCarryForwardSection(priorSummaries, base);
const inlinedTemplates = [
inlineTemplate("task-summary", "Task Summary"),
inlineTemplate("decisions", "Decisions"),
].join("\n\n---\n\n");
const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`;
return loadPrompt("execute-task", {
milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle,
planPath: relSliceFile(base, mid, sid, "PLAN"),
slicePath: relSlicePath(base, mid, sid),
taskPlanPath: taskPlanRelPath,
taskPlanInline,
slicePlanExcerpt,
carryForwardSection,
resumeSection,
priorTaskLines: priorLines,
taskSummaryPath,
inlinedTemplates,
});
}
export async function buildCompleteSlicePrompt(
mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
): Promise<string> {
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
const inlined: string[] = [];
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Slice Plan"));
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
if (requirementsInline) inlined.push(requirementsInline);
// Inline all task summaries for this slice
const tDir = resolveTasksDir(base, mid, sid);
if (tDir) {
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
for (const file of summaryFiles) {
const absPath = join(tDir, file);
const content = await loadFile(absPath);
const sRel = relSlicePath(base, mid, sid);
const relPath = `${sRel}/tasks/${file}`;
if (content) {
inlined.push(`### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`);
}
}
}
inlined.push(inlineTemplate("slice-summary", "Slice Summary"));
inlined.push(inlineTemplate("uat", "UAT"));
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const sliceRel = relSlicePath(base, mid, sid);
const sliceSummaryPath = `${sliceRel}/${sid}-SUMMARY.md`;
const sliceUatPath = `${sliceRel}/${sid}-UAT.md`;
return loadPrompt("complete-slice", {
milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
slicePath: sliceRel,
roadmapPath: roadmapRel,
inlinedContext,
sliceSummaryPath,
sliceUatPath,
});
}
export async function buildCompleteMilestonePrompt(
mid: string, midTitle: string, base: string,
): Promise<string> {
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
const inlined: string[] = [];
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
// Inline all slice summaries (deduplicated by slice ID)
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
if (roadmapContent) {
const roadmap = parseRoadmap(roadmapContent);
const seenSlices = new Set<string>();
for (const slice of roadmap.slices) {
if (seenSlices.has(slice.id)) continue;
seenSlices.add(slice.id);
const summaryPath = resolveSliceFile(base, mid, slice.id, "SUMMARY");
const summaryRel = relSliceFile(base, mid, slice.id, "SUMMARY");
inlined.push(await inlineFile(summaryPath, summaryRel, `${slice.id} Summary`));
}
}
// Inline root GSD files
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
if (requirementsInline) inlined.push(requirementsInline);
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
if (decisionsInline) inlined.push(decisionsInline);
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
if (projectInline) inlined.push(projectInline);
// Inline milestone context file (milestone-level, not GSD root)
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
if (contextInline) inlined.push(contextInline);
inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`;
return loadPrompt("complete-milestone", {
milestoneId: mid,
milestoneTitle: midTitle,
roadmapPath: roadmapRel,
inlinedContext,
milestoneSummaryPath,
});
}
export async function buildReplanSlicePrompt(
mid: string, midTitle: string, sid: string, sTitle: string, base: string,
): Promise<string> {
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
const inlined: string[] = [];
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan"));
// Find the blocker task summary — the completed task with blocker_discovered: true
let blockerTaskId = "";
const tDir = resolveTasksDir(base, mid, sid);
if (tDir) {
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
for (const file of summaryFiles) {
const absPath = join(tDir, file);
const content = await loadFile(absPath);
if (!content) continue;
const summary = parseSummary(content);
const sRel = relSlicePath(base, mid, sid);
const relPath = `${sRel}/tasks/${file}`;
if (summary.frontmatter.blocker_discovered) {
blockerTaskId = summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, "");
inlined.push(`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`);
}
}
}
// Inline decisions
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
if (decisionsInline) inlined.push(decisionsInline);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`;
return loadPrompt("replan-slice", {
milestoneId: mid,
sliceId: sid,
sliceTitle: sTitle,
slicePath: relSlicePath(base, mid, sid),
planPath: slicePlanRel,
blockerTaskId,
inlinedContext,
replanPath,
});
}
export async function buildRunUatPrompt(
mid: string, sliceId: string, uatPath: string, uatContent: string, base: string,
): Promise<string> {
const inlined: string[] = [];
inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`));
const summaryPath = resolveSliceFile(base, mid, sliceId, "SUMMARY");
const summaryRel = relSliceFile(base, mid, sliceId, "SUMMARY");
if (summaryPath) {
const summaryInline = await inlineFileOptional(summaryPath, summaryRel, `${sliceId} Summary`);
if (summaryInline) inlined.push(summaryInline);
}
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
if (projectInline) inlined.push(projectInline);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT");
const uatType = extractUatType(uatContent) ?? "human-experience";
return loadPrompt("run-uat", {
milestoneId: mid,
sliceId,
uatPath,
uatResultPath,
uatType,
inlinedContext,
});
}
export async function buildReassessRoadmapPrompt(
mid: string, midTitle: string, completedSliceId: string, base: string,
): Promise<string> {
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY");
const summaryRel = relSliceFile(base, mid, completedSliceId, "SUMMARY");
const inlined: string[] = [];
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Current Roadmap"));
inlined.push(await inlineFile(summaryPath, summaryRel, `${completedSliceId} Summary`));
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
if (projectInline) inlined.push(projectInline);
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
if (requirementsInline) inlined.push(requirementsInline);
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
if (decisionsInline) inlined.push(decisionsInline);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT");
return loadPrompt("reassess-roadmap", {
milestoneId: mid,
milestoneTitle: midTitle,
completedSliceId,
roadmapPath: roadmapRel,
completedSliceSummaryPath: summaryRel,
assessmentPath,
inlinedContext,
});
}

View file

@ -0,0 +1,450 @@
/**
* Auto-mode Recovery artifact resolution, verification, blocker placeholders,
* skip artifacts, completed-unit persistence, merge state reconciliation,
* self-heal runtime records, and loop remediation steps.
*
* Pure functions that receive all needed state as parameters no module-level
* globals or AutoContext dependency.
*/
import type { ExtensionContext } from "@gsd/pi-coding-agent";
import {
clearUnitRuntimeRecord,
} from "./unit-runtime.js";
import { runGit } from "./git-service.js";
import {
resolveMilestonePath,
resolveSlicePath,
resolveSliceFile,
resolveTasksDir,
relMilestoneFile,
relSliceFile,
relSlicePath,
relTaskFile,
buildMilestoneFileName,
buildSliceFileName,
buildTaskFileName,
resolveMilestoneFile,
clearPathCache,
} from "./paths.js";
import { parseRoadmap } from "./files.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
// ─── Artifact Resolution & Verification ───────────────────────────────────────
/**
* Resolve the expected artifact for a unit to an absolute path.
*/
export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
const parts = unitId.split("/");
const mid = parts[0]!;
const sid = parts[1];
switch (unitType) {
case "research-milestone": {
const dir = resolveMilestonePath(base, mid);
return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null;
}
case "plan-milestone": {
const dir = resolveMilestonePath(base, mid);
return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null;
}
case "research-slice": {
const dir = resolveSlicePath(base, mid, sid!);
return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null;
}
case "plan-slice": {
const dir = resolveSlicePath(base, mid, sid!);
return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null;
}
case "reassess-roadmap": {
const dir = resolveSlicePath(base, mid, sid!);
return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null;
}
case "run-uat": {
const dir = resolveSlicePath(base, mid, sid!);
return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
}
case "execute-task": {
const tid = parts[2];
const dir = resolveSlicePath(base, mid, sid!);
return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
}
case "complete-slice": {
const dir = resolveSlicePath(base, mid, sid!);
return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null;
}
case "complete-milestone": {
const dir = resolveMilestonePath(base, mid);
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
}
default:
return null;
}
}
/**
* Check whether the expected artifact(s) for a unit exist on disk.
* Returns true if all required artifacts exist, or if the unit type has no
* single verifiable artifact (e.g., replan-slice).
*
* complete-slice requires both SUMMARY and UAT files verifying only
* the summary allowed the unit to be marked complete when the LLM
* skipped writing the UAT file (see #176).
*/
export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
// Clear stale directory listing cache so artifact checks see fresh disk state (#431)
clearPathCache();
// Hook units have no standard artifact — always pass. Their lifecycle
// is managed by the hook engine, not the artifact verification system.
if (unitType.startsWith("hook/")) return true;
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
// Unit types with no verifiable artifact always pass (e.g. replan-slice).
// For all other types, null means the parent directory is missing on disk
// — treat as stale completion state so the key gets evicted (#313).
if (!absPath) return unitType === "replan-slice";
if (!existsSync(absPath)) return false;
// execute-task must also have its checkbox marked [x] in the slice plan
if (unitType === "execute-task") {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
const tid = parts[2];
if (mid && sid && tid) {
const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
if (planAbs && existsSync(planAbs)) {
const planContent = readFileSync(planAbs, "utf-8");
const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m");
if (!re.test(planContent)) return false;
}
}
}
// complete-slice must also produce a UAT file AND mark the slice [x] in the roadmap.
// Without the roadmap check, a crash after writing SUMMARY+UAT but before updating
// the roadmap causes an infinite skip loop: the idempotency key says "done" but the
// state machine keeps returning the same complete-slice unit (roadmap still shows
// the slice incomplete), so dispatchNextUnit recurses forever.
if (unitType === "complete-slice") {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
if (mid && sid) {
const dir = resolveSlicePath(base, mid, sid);
if (dir) {
const uatPath = join(dir, buildSliceFileName(sid, "UAT"));
if (!existsSync(uatPath)) return false;
}
// Verify the roadmap has the slice marked [x]. If not, the completion
// record is stale — the unit must re-run to update the roadmap.
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
if (roadmapFile && existsSync(roadmapFile)) {
try {
const roadmapContent = readFileSync(roadmapFile, "utf-8");
const roadmap = parseRoadmap(roadmapContent);
const slice = roadmap.slices.find(s => s.id === sid);
if (slice && !slice.done) return false;
} catch { /* corrupt roadmap — be lenient and treat as verified */ }
}
}
}
return true;
}
/**
* Write a placeholder artifact so the pipeline can advance past a stuck unit.
* Returns the relative path written, or null if the path couldn't be resolved.
*/
export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null {
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
if (!absPath) return null;
const dir = dirname(absPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const content = [
`# BLOCKER — auto-mode recovery failed`,
``,
`Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`,
``,
`**Reason**: ${reason}`,
``,
`This placeholder was written by auto-mode so the pipeline can advance.`,
`Review and replace this file before relying on downstream artifacts.`,
].join("\n");
writeFileSync(absPath, content, "utf-8");
return diagnoseExpectedArtifact(unitType, unitId, base);
}
export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
switch (unitType) {
case "research-milestone":
return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
case "plan-milestone":
return `${relMilestoneFile(base, mid!, "ROADMAP")} (milestone roadmap)`;
case "research-slice":
return `${relSliceFile(base, mid!, sid!, "RESEARCH")} (slice research)`;
case "plan-slice":
return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
case "execute-task": {
const tid = parts[2];
return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
}
case "complete-slice":
return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary + UAT written`;
case "replan-slice":
return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`;
case "reassess-roadmap":
return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`;
case "run-uat":
return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
case "complete-milestone":
return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
default:
return null;
}
}
// ─── Skip / Blocker Artifact Generation ───────────────────────────────────────
/**
* Write skip artifacts for a stuck execute-task: a blocker task summary and
* the [x] checkbox in the slice plan. Returns true if artifacts were written.
*/
export function skipExecuteTask(
base: string, mid: string, sid: string, tid: string,
status: { summaryExists: boolean; taskChecked: boolean },
reason: string, maxAttempts: number,
): boolean {
// Write a blocker task summary if missing.
if (!status.summaryExists) {
const tasksDir = resolveTasksDir(base, mid, sid);
const sDir = resolveSlicePath(base, mid, sid);
const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
if (!targetDir) return false;
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY"));
const content = [
`# BLOCKER — task skipped by auto-mode recovery`,
``,
`Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) failed to complete after ${reason} recovery exhausted ${maxAttempts} attempts.`,
``,
`This placeholder was written by auto-mode so the pipeline can advance.`,
`Review this task manually and replace this file with a real summary.`,
].join("\n");
writeFileSync(summaryPath, content, "utf-8");
}
// Mark [x] in the slice plan if not already checked.
if (!status.taskChecked) {
const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
if (planAbs && existsSync(planAbs)) {
const planContent = readFileSync(planAbs, "utf-8");
const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m");
if (re.test(planContent)) {
writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8");
}
}
}
return true;
}
// ─── Disk-backed completed-unit helpers ───────────────────────────────────────
/** Path to the persisted completed-unit keys file. */
export function completedKeysPath(base: string): string {
return join(base, ".gsd", "completed-units.json");
}
/** Write a completed unit key to disk (read-modify-write append to set). */
export function persistCompletedKey(base: string, key: string): void {
const file = completedKeysPath(base);
let keys: string[] = [];
try {
if (existsSync(file)) {
keys = JSON.parse(readFileSync(file, "utf-8"));
}
} catch { /* corrupt file — start fresh */ }
if (!keys.includes(key)) {
keys.push(key);
// Atomic write: tmp file + rename prevents partial writes on crash
const tmpFile = file + ".tmp";
writeFileSync(tmpFile, JSON.stringify(keys), "utf-8");
renameSync(tmpFile, file);
}
}
/** Remove a stale completed unit key from disk. */
export function removePersistedKey(base: string, key: string): void {
const file = completedKeysPath(base);
try {
if (existsSync(file)) {
let keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
keys = keys.filter(k => k !== key);
writeFileSync(file, JSON.stringify(keys), "utf-8");
}
} catch { /* non-fatal */ }
}
/** Load all completed unit keys from disk into the in-memory set. */
export function loadPersistedKeys(base: string, target: Set<string>): void {
const file = completedKeysPath(base);
try {
if (existsSync(file)) {
const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
for (const k of keys) target.add(k);
}
} catch { /* non-fatal */ }
}
// ─── Merge State Reconciliation ───────────────────────────────────────────────
/**
* Detect leftover merge state from a prior session and reconcile it.
* If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
* If resolved: finalize the commit. If still conflicted: abort and reset.
*
* Returns true if state was dirty and re-derivation is needed.
*/
export function reconcileMergeState(basePath: string, ctx: ExtensionContext): boolean {
const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
const hasMergeHead = existsSync(mergeHeadPath);
const hasSquashMsg = existsSync(squashMsgPath);
if (!hasMergeHead && !hasSquashMsg) return false;
const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (!unmerged || !unmerged.trim()) {
// All conflicts resolved — finalize the merge/squash commit
try {
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
const mode = hasMergeHead ? "merge" : "squash commit";
ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
} catch {
// Commit may already exist; non-fatal
}
} else {
// Still conflicted — abort and reset
if (hasMergeHead) {
runGit(basePath, ["merge", "--abort"], { allowFailure: true });
} else if (hasSquashMsg) {
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
}
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
ctx.ui.notify(
"Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.",
"warning",
);
}
return true;
}
// ─── Self-Heal Runtime Records ────────────────────────────────────────────────
/**
* Self-heal: scan runtime records in .gsd/ and clear any where the expected
* artifact already exists on disk. This repairs incomplete closeouts from
* prior crashes preventing spurious re-dispatch of already-completed units.
*/
export async function selfHealRuntimeRecords(
base: string,
ctx: ExtensionContext,
completedKeySet: Set<string>,
): Promise<void> {
try {
const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
const records = listUnitRuntimeRecords(base);
let healed = 0;
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
const now = Date.now();
for (const record of records) {
const { unitType, unitId } = record;
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
// Case 1: Artifact exists — unit completed but closeout didn't finish
if (artifactPath && existsSync(artifactPath)) {
clearUnitRuntimeRecord(base, unitType, unitId);
// Also persist completion key if missing
const key = `${unitType}/${unitId}`;
if (!completedKeySet.has(key)) {
persistCompletedKey(base, key);
completedKeySet.add(key);
}
healed++;
continue;
}
// Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed)
const age = now - (record.startedAt ?? 0);
if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
clearUnitRuntimeRecord(base, unitType, unitId);
healed++;
continue;
}
}
if (healed > 0) {
ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info");
}
} catch {
// Non-fatal — self-heal should never block auto-mode start
}
}
// ─── Loop Remediation ─────────────────────────────────────────────────────────
/**
* Build concrete, manual remediation steps for a loop-detected unit failure.
* These are shown when automatic reconciliation is not possible.
*/
export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
const tid = parts[2];
switch (unitType) {
case "execute-task": {
if (!mid || !sid || !tid) break;
const planRel = relSliceFile(base, mid, sid, "PLAN");
const summaryRel = relTaskFile(base, mid, sid, tid, "SUMMARY");
return [
` 1. Write ${summaryRel} (even a partial summary is sufficient to unblock the pipeline)`,
` 2. Mark ${tid} [x] in ${planRel}: change "- [ ] **${tid}:" → "- [x] **${tid}:"`,
` 3. Run \`gsd doctor\` to reconcile .gsd/ state`,
` 4. Resume auto-mode — it will pick up from the next task`,
].join("\n");
}
case "plan-slice":
case "research-slice": {
if (!mid || !sid) break;
const artifactRel = unitType === "plan-slice"
? relSliceFile(base, mid, sid, "PLAN")
: relSliceFile(base, mid, sid, "RESEARCH");
return [
` 1. Write ${artifactRel} manually (or with the LLM in interactive mode)`,
` 2. Run \`gsd doctor\` to reconcile .gsd/ state`,
` 3. Resume auto-mode`,
].join("\n");
}
case "complete-slice": {
if (!mid || !sid) break;
return [
` 1. Write the slice summary and UAT file for ${sid} in ${relSlicePath(base, mid, sid)}`,
` 2. Mark ${sid} [x] in ${relMilestoneFile(base, mid, "ROADMAP")}`,
` 3. Run \`gsd doctor\` to reconcile .gsd/ state`,
` 4. Resume auto-mode`,
].join("\n");
}
default:
break;
}
return null;
}

View file

@ -0,0 +1,59 @@
/**
* Auto-mode Supervisor SIGTERM handling and working-tree activity detection.
*
* Pure functions no module-level globals or AutoContext dependency.
*/
import { clearLock } from "./crash-recovery.js";
import { execSync } from "node:child_process";
// ─── SIGTERM Handling ─────────────────────────────────────────────────────────
/**
* Register a SIGTERM handler that clears the lock file and exits cleanly.
* Captures the active base path at registration time so the handler
* always references the correct path even if the module variable changes.
* Removes any previously registered handler before installing the new one.
*
* Returns the new handler so the caller can store and deregister it later.
*/
export function registerSigtermHandler(
currentBasePath: string,
previousHandler: (() => void) | null,
): () => void {
if (previousHandler) process.off("SIGTERM", previousHandler);
const handler = () => {
clearLock(currentBasePath);
process.exit(0);
};
process.on("SIGTERM", handler);
return handler;
}
/** Deregister the SIGTERM handler (called on stop/pause). */
export function deregisterSigtermHandler(handler: (() => void) | null): void {
if (handler) {
process.off("SIGTERM", handler);
}
}
// ─── Working Tree Activity Detection ──────────────────────────────────────────
/**
* Detect whether the agent is producing work on disk by checking git for
* any working-tree changes (staged, unstaged, or untracked). Returns true
* if there are uncommitted changes meaning the agent is actively working,
* even though it hasn't signaled progress through runtime records.
*/
export function detectWorkingTreeActivity(cwd: string): boolean {
try {
const out = execSync("git status --porcelain", {
cwd,
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
return out.toString().trim().length > 0;
} catch {
return false;
}
}

File diff suppressed because it is too large Load diff

View file

@ -81,9 +81,15 @@ const autoSource = readFileSync(
"utf-8",
);
// Check describeNextUnit has the case
const hasDescribeCase = autoSource.includes('case "needs-discussion"');
assert(hasDescribeCase, "auto.ts describeNextUnit should have 'needs-discussion' case");
// describeNextUnit was extracted to auto-dashboard.ts — check there for the case
const dashboardSource = readFileSync(
join(import.meta.dirname, "..", "auto-dashboard.ts"),
"utf-8",
);
// Check describeNextUnit has the case (in auto-dashboard.ts)
const hasDescribeCase = dashboardSource.includes('case "needs-discussion"');
assert(hasDescribeCase, "auto-dashboard.ts describeNextUnit should have 'needs-discussion' case");
// Check dispatchNextUnit has the branch
const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"');