Merge branch 'main' into fix/arrow-keys-escape-sequence-splitting-493
This commit is contained in:
commit
b6e5d8e538
7 changed files with 1828 additions and 1594 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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": [
|
||||
|
|
|
|||
432
src/resources/extensions/gsd/auto-dashboard.ts
Normal file
432
src/resources/extensions/gsd/auto-dashboard.ts
Normal 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);
|
||||
}
|
||||
785
src/resources/extensions/gsd/auto-prompts.ts
Normal file
785
src/resources/extensions/gsd/auto-prompts.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
450
src/resources/extensions/gsd/auto-recovery.ts
Normal file
450
src/resources/extensions/gsd/auto-recovery.ts
Normal 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;
|
||||
}
|
||||
59
src/resources/extensions/gsd/auto-supervisor.ts
Normal file
59
src/resources/extensions/gsd/auto-supervisor.ts
Normal 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
|
|
@ -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"');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue