diff --git a/docs/pr-876/01-index.png b/docs/pr-876/01-index.png new file mode 100644 index 000000000..dc2957b92 Binary files /dev/null and b/docs/pr-876/01-index.png differ diff --git a/docs/pr-876/02-summary.png b/docs/pr-876/02-summary.png new file mode 100644 index 000000000..dea9d8cb1 Binary files /dev/null and b/docs/pr-876/02-summary.png differ diff --git a/docs/pr-876/03-progress.png b/docs/pr-876/03-progress.png new file mode 100644 index 000000000..9dec3856b Binary files /dev/null and b/docs/pr-876/03-progress.png differ diff --git a/docs/pr-876/04-depgraph.png b/docs/pr-876/04-depgraph.png new file mode 100644 index 000000000..b1349dead Binary files /dev/null and b/docs/pr-876/04-depgraph.png differ diff --git a/docs/pr-876/05-metrics.png b/docs/pr-876/05-metrics.png new file mode 100644 index 000000000..bb8083030 Binary files /dev/null and b/docs/pr-876/05-metrics.png differ diff --git a/docs/pr-876/06-changelog.png b/docs/pr-876/06-changelog.png new file mode 100644 index 000000000..c79e00f2d Binary files /dev/null and b/docs/pr-876/06-changelog.png differ diff --git a/docs/pr-876/06-timeline.png b/docs/pr-876/06-timeline.png new file mode 100644 index 000000000..62d081703 Binary files /dev/null and b/docs/pr-876/06-timeline.png differ diff --git a/docs/pr-876/07-changelog.png b/docs/pr-876/07-changelog.png new file mode 100644 index 000000000..f279f6d95 Binary files /dev/null and b/docs/pr-876/07-changelog.png differ diff --git a/docs/pr-876/07-knowledge.png b/docs/pr-876/07-knowledge.png new file mode 100644 index 000000000..2e7e32952 Binary files /dev/null and b/docs/pr-876/07-knowledge.png differ diff --git a/docs/pr-876/08-knowledge.png b/docs/pr-876/08-knowledge.png new file mode 100644 index 000000000..14a4dd33b Binary files /dev/null and b/docs/pr-876/08-knowledge.png differ diff --git a/docs/pr-876/09-captures.png b/docs/pr-876/09-captures.png new file mode 100644 index 000000000..f3c29a40e Binary files /dev/null and b/docs/pr-876/09-captures.png differ diff --git a/docs/pr-876/10-artifacts.png b/docs/pr-876/10-artifacts.png new file mode 100644 index 000000000..7aab45ec9 Binary files /dev/null and b/docs/pr-876/10-artifacts.png differ diff --git a/packages/pi-ai/src/providers/openai-completions.ts b/packages/pi-ai/src/providers/openai-completions.ts index 50ae643ca..7372d6880 100644 --- a/packages/pi-ai/src/providers/openai-completions.ts +++ b/packages/pi-ai/src/providers/openai-completions.ts @@ -747,10 +747,13 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto return "toolUse"; case "content_filter": return "error"; - default: { - const _exhaustive: never = reason; - throw new Error(`Unhandled stop reason: ${_exhaustive}`); - } + default: + // Third-party and community models (e.g. Qwen GGUF quants) may emit + // non-standard finish_reason values like "eos_token", "eos", or + // "end_of_turn". The OpenAI spec defines finish_reason as a string, + // so we treat unrecognized values as a normal stop rather than + // throwing — which would abort in-flight tool calls (#863). + return "stop"; } } diff --git a/src/headless.ts b/src/headless.ts index 531fcbcf3..006009edf 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -18,6 +18,7 @@ import { ChildProcess } from 'node:child_process' // RpcClient is not in @gsd/pi-coding-agent's public exports — import from dist directly. // This relative path resolves correctly from both src/ (via tsx) and dist/ (compiled). import { RpcClient } from '../packages/pi-coding-agent/dist/modes/rpc/rpc-client.js' +import { attachJsonlLineReader, serializeJsonLine } from '../packages/pi-coding-agent/dist/modes/rpc/jsonl.js' // --------------------------------------------------------------------------- // Types @@ -34,6 +35,8 @@ export interface HeadlessOptions { auto?: boolean // chain into auto-mode after milestone creation verbose?: boolean // show tool calls in output maxRestarts?: number // auto-restart on crash (default 3, 0 to disable) + supervised?: boolean // supervised mode: forward interactive requests to orchestrator + responseTimeout?: number // timeout for orchestrator response (default 30000ms) } interface ExtensionUIRequest { @@ -99,6 +102,15 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { process.stderr.write('[headless] Error: --max-restarts must be a non-negative integer\n') process.exit(1) } + } else if (arg === '--supervised') { + options.supervised = true + options.json = true // supervised implies json + } else if (arg === '--response-timeout' && i + 1 < args.length) { + options.responseTimeout = parseInt(args[++i], 10) + if (Number.isNaN(options.responseTimeout) || options.responseTimeout <= 0) { + process.stderr.write('[headless] Error: --response-timeout must be a positive integer (milliseconds)\n') + process.exit(1) + } } } else if (!positionalStarted) { positionalStarted = true @@ -111,14 +123,6 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { return options } -// --------------------------------------------------------------------------- -// JSONL Helper -// --------------------------------------------------------------------------- - -function serializeJsonLine(obj: Record): string { - return JSON.stringify(obj) + '\n' -} - // --------------------------------------------------------------------------- // Extension UI Auto-Responder // --------------------------------------------------------------------------- @@ -237,6 +241,8 @@ function isMilestoneReadyNotification(event: Record): boolean { // Quick Command Detection // --------------------------------------------------------------------------- +const FIRE_AND_FORGET_METHODS = new Set(['notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text']) + const QUICK_COMMANDS = new Set([ 'status', 'queue', 'history', 'hooks', 'export', 'stop', 'pause', 'capture', 'skip', 'undo', 'knowledge', 'config', 'prefs', @@ -248,6 +254,49 @@ function isQuickCommand(command: string): boolean { return QUICK_COMMANDS.has(command) } +// --------------------------------------------------------------------------- +// Supervised Stdin Reader +// --------------------------------------------------------------------------- + +function startSupervisedStdinReader( + stdinWriter: (data: string) => void, + client: RpcClient, + onResponse: (id: string) => void, +): () => void { + return attachJsonlLineReader(process.stdin as import('node:stream').Readable, (line) => { + let msg: Record + try { + msg = JSON.parse(line) + } catch { + process.stderr.write(`[headless] Warning: invalid JSON from orchestrator stdin, skipping\n`) + return + } + + const type = String(msg.type ?? '') + + switch (type) { + case 'extension_ui_response': + stdinWriter(line + '\n') + if (typeof msg.id === 'string') { + onResponse(msg.id) + } + break + case 'prompt': + client.prompt(String(msg.message ?? '')) + break + case 'steer': + client.steer(String(msg.message ?? '')) + break + case 'follow_up': + client.followUp(String(msg.message ?? '')) + break + default: + process.stderr.write(`[headless] Warning: unknown message type "${type}" from orchestrator stdin\n`) + break + } + }) +} + // --------------------------------------------------------------------------- // Main Orchestrator // --------------------------------------------------------------------------- @@ -320,6 +369,12 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): const startTime = Date.now() const isNewMilestone = options.command === 'new-milestone' + // Supervised mode cannot share stdin with --context - + if (options.supervised && options.context === '-') { + process.stderr.write('[headless] Error: --supervised cannot be used with --context - (both require stdin)\n') + process.exit(1) + } + // For new-milestone, load context and bootstrap .gsd/ before spawning RPC child if (isNewMilestone) { if (!options.context && !options.contextText) { @@ -408,6 +463,18 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): // Stdin writer for sending extension_ui_response to child let stdinWriter: ((data: string) => void) | null = null + // Supervised mode state + const pendingResponseTimers = new Map>() + let supervisedFallback = false + let stopSupervisedReader: (() => void) | null = null + const onStdinClose = () => { + supervisedFallback = true + process.stderr.write('[headless] Warning: orchestrator stdin closed, falling back to auto-response\n') + } + if (options.supervised) { + process.stdin.on('close', onStdinClose) + } + // Completion promise let resolveCompletion: () => void const completionPromise = new Promise((resolve) => { @@ -428,6 +495,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): } } + // Precompute supervised response timeout + const responseTimeout = options.responseTimeout ?? 30_000 + // Overall timeout const timeoutTimer = setTimeout(() => { process.stderr.write(`[headless] Timeout after ${options.timeout / 1000}s\n`) @@ -466,7 +536,22 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): completed = true } - handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter) + const method = String(eventObj.method ?? '') + const shouldSupervise = options.supervised && !supervisedFallback + && !FIRE_AND_FORGET_METHODS.has(method) + + if (shouldSupervise) { + // Interactive request in supervised mode — let orchestrator respond + const eventId = String(eventObj.id ?? '') + const timer = setTimeout(() => { + pendingResponseTimers.delete(eventId) + handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter!) + process.stdout.write(JSON.stringify({ type: 'supervised_timeout', id: eventId, method }) + '\n') + }, responseTimeout) + pendingResponseTimers.set(eventId, timer) + } else { + handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter) + } // If we detected a terminal notification, resolve after responding if (completed) { @@ -523,6 +608,19 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): internalProcess.stdin!.write(data) } + // Start supervised stdin reader for orchestrator commands + if (options.supervised) { + stopSupervisedReader = startSupervisedStdinReader(stdinWriter, client, (id) => { + const timer = pendingResponseTimers.get(id) + if (timer) { + clearTimeout(timer) + pendingResponseTimers.delete(id) + } + }) + // Ensure stdin is in flowing mode for JSONL reading + process.stdin.resume() + } + // Detect child process crash internalProcess.on('exit', (code) => { if (!completed) { @@ -580,6 +678,10 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): // Cleanup clearTimeout(timeoutTimer) if (idleTimer) clearTimeout(idleTimer) + pendingResponseTimers.forEach((timer) => clearTimeout(timer)) + pendingResponseTimers.clear() + stopSupervisedReader?.() + process.stdin.removeListener('close', onStdinClose) process.removeListener('SIGINT', signalHandler) process.removeListener('SIGTERM', signalHandler) diff --git a/src/help-text.ts b/src/help-text.ts index 8c866b22a..864d85f3d 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -41,6 +41,8 @@ const SUBCOMMAND_HELP: Record = { ' --timeout N Overall timeout in ms (default: 300000)', ' --json JSONL event stream to stdout', ' --model ID Override model', + ' --supervised Forward interactive UI requests to orchestrator via stdout/stdin', + ' --response-timeout N Timeout (ms) for orchestrator response (default: 30000)', '', 'Commands:', ' auto Run all queued units continuously (default)', @@ -62,6 +64,7 @@ const SUBCOMMAND_HELP: Record = { ' gsd headless new-milestone --context spec.md Create milestone from file', ' cat spec.md | gsd headless new-milestone --context - From stdin', ' gsd headless new-milestone --context spec.md --auto Create + auto-execute', + ' gsd headless --supervised auto Supervised orchestrator mode', '', 'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked', ].join('\n'), diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index 4faa1fbed..2b9d49826 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -52,6 +52,7 @@ import { getGroupStatus, pruneDeadProcesses, cleanupAll, + cleanupSessionProcesses, persistManifest, loadManifest, pushAlert, @@ -71,7 +72,7 @@ import { toPosixPath } from "../shared/path-display.js"; // ── Re-exports for consumers ─────────────────────────────────────────────── export type { ProcessStatus, ProcessType, BgProcess, BgProcessInfo, OutputDigest, OutputLine, ProcessEvent } from "./types.js"; -export { processes, startProcess, killProcess, restartProcess, cleanupAll } from "./process-manager.js"; +export { processes, startProcess, killProcess, restartProcess, cleanupAll, cleanupSessionProcesses } from "./process-manager.js"; export { generateDigest, getHighlights, getOutput, formatDigestText } from "./output-formatter.js"; export { waitForReady, probePort } from "./readiness-detector.js"; export { sendAndWait, runOnSession, queryShellEnv } from "./interaction.js"; @@ -136,7 +137,13 @@ export default function (pi: ExtensionAPI) { }); // Session switch resets the agent's context. - pi.on("session_switch", async () => { + pi.on("session_switch", async (event, ctx) => { + latestCtx = ctx; + if (event.reason === "new" && event.previousSessionFile) { + await cleanupSessionProcesses(event.previousSessionFile); + syncLatestCtxCwd(); + if (latestCtx) persistManifest(latestCtx.cwd); + } buildProcessStateAlert("Session was switched."); }); @@ -232,6 +239,7 @@ export default function (pi: ExtensionAPI) { "Use 'run' to execute a command on a persistent shell session and block until it completes — returns structured output + exit code. Shell state (env vars, cwd, virtualenvs) persists across runs.", "Use 'send_and_wait' for interactive CLIs: send input and wait for expected output pattern.", "Use 'env' to check the current working directory and active environment variables of a shell session — useful after cd, source, or export commands.", + "Background processes are session-scoped by default: a new session reaps them unless you set persist_across_sessions:true.", "Use 'restart' to kill and relaunch with the same config — preserves restart count.", "Background processes are auto-classified (server/build/test/watcher) based on the command.", "Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll.", @@ -300,6 +308,12 @@ export default function (pi: ExtensionAPI) { group: Type.Optional( Type.String({ description: "Group name for related processes (for start, group_status)" }), ), + persist_across_sessions: Type.Optional( + Type.Boolean({ + description: "Keep this process running after a new session starts. Default: false.", + default: false, + }), + ), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { @@ -318,6 +332,8 @@ export default function (pi: ExtensionAPI) { const bg = startProcess({ command: params.command, cwd: ctx.cwd, + ownerSessionFile: ctx.sessionManager.getSessionFile() ?? null, + persistAcrossSessions: params.persist_across_sessions ?? false, label: params.label, type: params.type as ProcessType | undefined, readyPattern: params.ready_pattern, @@ -341,6 +357,7 @@ export default function (pi: ExtensionAPI) { text += ` cwd: ${toPosixPath(bg.cwd)}`; if (bg.group) text += `\n group: ${bg.group}`; + if (bg.persistAcrossSessions) text += `\n persist_across_sessions: true`; if (bg.readyPort) text += `\n ready_port: ${bg.readyPort}`; if (bg.readyPattern) text += `\n ready_pattern: ${bg.readyPattern}`; if (bg.ports.length > 0) text += `\n detected ports: ${bg.ports.join(", ")}`; diff --git a/src/resources/extensions/bg-shell/process-manager.ts b/src/resources/extensions/bg-shell/process-manager.ts index c2bbf8a3f..95ee6ccd9 100644 --- a/src/resources/extensions/bg-shell/process-manager.ts +++ b/src/resources/extensions/bg-shell/process-manager.ts @@ -67,6 +67,8 @@ export function getInfo(p: BgProcess): BgProcessInfo { label: p.label, command: p.command, cwd: p.cwd, + ownerSessionFile: p.ownerSessionFile, + persistAcrossSessions: p.persistAcrossSessions, startedAt: p.startedAt, alive: p.alive, exitCode: p.exitCode, @@ -138,6 +140,8 @@ export function startProcess(opts: StartOptions): BgProcess { label: opts.label || command.slice(0, 60), command, cwd: opts.cwd, + ownerSessionFile: opts.ownerSessionFile ?? null, + persistAcrossSessions: opts.persistAcrossSessions ?? false, startedAt: Date.now(), proc, output: [], @@ -170,6 +174,8 @@ export function startProcess(opts: StartOptions): BgProcess { cwd: opts.cwd, label: opts.label || command.slice(0, 60), processType, + ownerSessionFile: opts.ownerSessionFile ?? null, + persistAcrossSessions: opts.persistAcrossSessions ?? false, readyPattern: opts.readyPattern || null, readyPort: opts.readyPort || null, group: opts.group || null, @@ -312,6 +318,8 @@ export async function restartProcess(id: string): Promise { cwd: config.cwd, label: config.label, type: config.processType, + ownerSessionFile: config.ownerSessionFile, + persistAcrossSessions: config.persistAcrossSessions, readyPattern: config.readyPattern || undefined, readyPort: config.readyPort || undefined, group: config.group || undefined, @@ -367,6 +375,41 @@ export function cleanupAll(): void { processes.clear(); } +async function waitForProcessExit(bg: BgProcess, timeoutMs: number): Promise { + if (!bg.alive) return true; + await new Promise((resolve) => { + const done = () => resolve(); + const timer = setTimeout(done, timeoutMs); + bg.proc.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + return !bg.alive; +} + +export async function cleanupSessionProcesses( + sessionFile: string, + options?: { graceMs?: number }, +): Promise { + const graceMs = Math.max(0, options?.graceMs ?? 300); + const matches = Array.from(processes.values()).filter( + (bg) => bg.alive && !bg.persistAcrossSessions && bg.ownerSessionFile === sessionFile, + ); + if (matches.length === 0) return []; + + for (const bg of matches) { + killProcess(bg.id, "SIGTERM"); + } + if (graceMs > 0) { + await Promise.all(matches.map((bg) => waitForProcessExit(bg, graceMs))); + } + for (const bg of matches) { + if (bg.alive) killProcess(bg.id, "SIGKILL"); + } + return matches.map((bg) => bg.id); +} + // ── Persistence ──────────────────────────────────────────────────────────── export function getManifestPath(cwd: string): string { @@ -384,6 +427,8 @@ export function persistManifest(cwd: string): void { label: p.label, command: p.command, cwd: p.cwd, + ownerSessionFile: p.ownerSessionFile, + persistAcrossSessions: p.persistAcrossSessions, startedAt: p.startedAt, processType: p.processType, group: p.group, diff --git a/src/resources/extensions/bg-shell/types.ts b/src/resources/extensions/bg-shell/types.ts index ff35a35bd..fa5131bd4 100644 --- a/src/resources/extensions/bg-shell/types.ts +++ b/src/resources/extensions/bg-shell/types.ts @@ -53,6 +53,10 @@ export interface BgProcess { label: string; command: string; cwd: string; + /** Session file that created this process (used for per-session cleanup) */ + ownerSessionFile: string | null; + /** Whether this process should survive a new-session boundary */ + persistAcrossSessions: boolean; startedAt: number; proc: import("node:child_process").ChildProcess; /** Unified chronologically-interleaved output buffer */ @@ -103,7 +107,17 @@ export interface BgProcess { /** Restart count */ restartCount: number; /** Original start config for restart */ - startConfig: { command: string; cwd: string; label: string; processType: ProcessType; readyPattern: string | null; readyPort: number | null; group: string | null }; + startConfig: { + command: string; + cwd: string; + label: string; + processType: ProcessType; + ownerSessionFile: string | null; + persistAcrossSessions: boolean; + readyPattern: string | null; + readyPort: number | null; + group: string | null; + }; } export interface BgProcessInfo { @@ -111,6 +125,8 @@ export interface BgProcessInfo { label: string; command: string; cwd: string; + ownerSessionFile: string | null; + persistAcrossSessions: boolean; startedAt: number; alive: boolean; exitCode: number | null; @@ -133,6 +149,8 @@ export interface BgProcessInfo { export interface StartOptions { command: string; cwd: string; + ownerSessionFile?: string | null; + persistAcrossSessions?: boolean; label?: string; type?: ProcessType; readyPattern?: string; @@ -154,6 +172,8 @@ export interface ProcessManifest { label: string; command: string; cwd: string; + ownerSessionFile: string | null; + persistAcrossSessions: boolean; startedAt: number; processType: ProcessType; group: string | null; diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 064687e80..8cd4e2ce6 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -241,6 +241,32 @@ const DISPATCH_RULES: DispatchRule[] = [ }; }, }, + { + name: "executing → execute-task (recover missing task plan → plan-slice)", + match: async ({ state, mid, midTitle, basePath }) => { + if (state.phase !== "executing" || !state.activeTask) return null; + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const tid = state.activeTask.id; + + // Guard: if the slice plan exists but the individual task plan files are + // missing, the planner created S##-PLAN.md with task entries but never + // wrote the tasks/ directory files. Dispatch plan-slice to regenerate + // them rather than hard-stopping — fixes the infinite-loop described in + // issue #909. + const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); + if (!taskPlanPath || !existsSync(taskPlanPath)) { + return { + action: "dispatch", + unitType: "plan-slice", + unitId: `${mid}/${sid}`, + prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath), + }; + } + + return null; + }, + }, { name: "executing → execute-task", match: async ({ state, mid, basePath }) => { @@ -250,19 +276,6 @@ const DISPATCH_RULES: DispatchRule[] = [ const tid = state.activeTask.id; const tTitle = state.activeTask.title; - // Guard: refuse to dispatch execute-task when the task plan file is missing. - // This prevents the agent from running blind after a failed plan-slice that - // wrote S{sid}-PLAN.md but omitted the individual T{tid}-PLAN.md files. - // (See issue #739 — missing task plan caused runaway execution and EPIPE crash.) - const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); - if (!taskPlanPath || !existsSync(taskPlanPath)) { - return { - action: "stop", - reason: `Task plan ${tid}-PLAN.md is missing for ${mid}/${sid}/${tid}. Re-run plan-slice to regenerate task plans, or create the file manually and resume.`, - level: "error", - }; - } - return { action: "dispatch", unitType: "execute-task", diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 6daf4f8c6..471e36246 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -642,7 +642,6 @@ export async function buildPlanSlicePrompt( const commitInstruction = commitDocsEnabled ? `Commit: \`docs(${sid}): add slice plan\`` : "Do not commit — planning docs are not tracked in git for this project."; - return loadPrompt("plan-slice", { workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, diff --git a/src/resources/extensions/gsd/auto-unit-closeout.ts b/src/resources/extensions/gsd/auto-unit-closeout.ts index 70c2107d3..db902ce90 100644 --- a/src/resources/extensions/gsd/auto-unit-closeout.ts +++ b/src/resources/extensions/gsd/auto-unit-closeout.ts @@ -13,6 +13,7 @@ export interface CloseoutOptions { baselineCharCount?: number; tier?: string; modelDowngraded?: boolean; + continueHereFired?: boolean; } /** diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index caf809b7d..4d92034f7 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -100,6 +100,7 @@ import { initMetrics, resetMetrics, getLedger, getProjectTotals, formatCost, formatTokenCount, } from "./metrics.js"; +import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js"; import { join } from "node:path"; import { sep as pathSep } from "node:path"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs"; @@ -255,6 +256,8 @@ let originalModelProvider: string | null = null; let unitTimeoutHandle: ReturnType | null = null; let wrapupWarningHandle: ReturnType | null = null; let idleWatchdogHandle: ReturnType | null = null; +/** Context-pressure continue-here monitor — fires once when context usage >= 70% */ +let continueHereHandle: ReturnType | null = null; /** Dispatch gap watchdog — detects when the state machine stalls between units. * After handleAgentEnd completes, if auto-mode is still active but no new unit @@ -394,6 +397,10 @@ function clearUnitTimeout(): void { clearInterval(idleWatchdogHandle); idleWatchdogHandle = null; } + if (continueHereHandle) { + clearInterval(continueHereHandle); + continueHereHandle = null; + } clearInFlightTools(); clearDispatchGapWatchdog(); } @@ -405,6 +412,17 @@ function clearDispatchGapWatchdog(): void { } } +/** Build snapshot metric opts, enriching with continueHereFired from the runtime record. */ +function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFired?: boolean; promptCharCount?: number; baselineCharCount?: number } & Record { + const runtime = currentUnit ? readUnitRuntimeRecord(basePath, unitType, unitId) : null; + return { + promptCharCount: lastPromptCharCount, + baselineCharCount: lastBaselineCharCount, + ...(currentUnitRouting ?? {}), + ...(runtime?.continueHereFired ? { continueHereFired: true } : {}), + }; +} + /** * Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS * after handleAgentEnd completes. This catches the case where the dispatch chain silently @@ -800,6 +818,26 @@ export async function startAuto( // after a discussion that wrote new artifacts) may cause deriveState to // return pre-planning when the roadmap already exists (#800). invalidateAllCaches(); + + // ── Clean stale runtime unit files for completed milestones (#887) ─────── + // After resource-update restart, stale runtime/units/*.json files from + // previously completed milestones can cause deriveState to resume the wrong + // milestone. If a milestone has a SUMMARY file, its unit files are stale. + try { + const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units"); + if (existsSync(runtimeUnitsDir)) { + for (const file of readdirSync(runtimeUnitsDir)) { + if (!file.endsWith(".json")) continue; + const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); + if (!midMatch) continue; + const mid = midMatch[1]; + if (resolveMilestoneFile(base, mid, "SUMMARY")) { + try { unlinkSync(join(runtimeUnitsDir, file)); } catch { /* non-fatal */ } + } + } + } + } catch { /* non-fatal — don't block startup */ } + let state = await deriveState(base); // ── Stale worktree state recovery (#654) ───────────────────────────────── @@ -1546,7 +1584,7 @@ export async function handleAgentEnd( // Dispatch the hook unit instead of normal flow const hookStartedAt = Date.now(); if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt }; writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, { @@ -2068,6 +2106,55 @@ async function dispatchNextUnit( if (vizPrefs?.auto_visualize) { ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); } + // Auto-generate HTML report snapshot on milestone completion (default: on, disable with auto_report: false) + if (vizPrefs?.auto_report !== false) { + try { + const { loadVisualizerData } = await import("./visualizer-data.js"); + const { generateHtmlReport } = await import("./export-html.js"); + const { writeReportSnapshot, reportsDir } = await import("./reports.js"); + const { basename } = await import("node:path"); + const snapData = await loadVisualizerData(basePath); + const completedMs = snapData.milestones.find(m => m.id === currentMilestoneId); + const msTitle = completedMs?.title ?? currentMilestoneId; + const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; + const projName = basename(basePath); + const doneSlices = snapData.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); + const totalSlices = snapData.milestones.reduce((s, m) => s + m.slices.length, 0); + const outPath = writeReportSnapshot({ + basePath, + html: generateHtmlReport(snapData, { + projectName: projName, + projectPath: basePath, + gsdVersion, + milestoneId: currentMilestoneId, + indexRelPath: "index.html", + }), + milestoneId: currentMilestoneId, + milestoneTitle: msTitle, + kind: "milestone", + projectName: projName, + projectPath: basePath, + gsdVersion, + totalCost: snapData.totals?.cost ?? 0, + totalTokens: snapData.totals?.tokens.total ?? 0, + totalDuration: snapData.totals?.duration ?? 0, + doneSlices, + totalSlices, + doneMilestones: snapData.milestones.filter(m => m.status === "complete").length, + totalMilestones: snapData.milestones.length, + phase: snapData.phase, + }); + ctx.ui.notify( + `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, + "info", + ); + } catch (err) { + ctx.ui.notify( + `Report generation failed: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + } // Reset stuck detection for new milestone unitDispatchCount.clear(); unitRecoveryCount.clear(); @@ -2160,7 +2247,7 @@ async function dispatchNextUnit( if (!mid) { // Save final session before stopping if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } const incomplete = state.registry.filter(m => m.status !== "complete"); @@ -2203,7 +2290,7 @@ async function dispatchNextUnit( // After merge guard removal (branchless architecture), mid/midTitle could be undefined if (!mid || !midTitle) { if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } const noMilestoneReason = !mid ? "No active milestone after merge reconciliation" @@ -2219,7 +2306,7 @@ async function dispatchNextUnit( if (state.phase === "complete") { if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } // Clear completed-units.json for the finished milestone so it doesn't grow unbounded. try { @@ -2287,7 +2374,7 @@ async function dispatchNextUnit( if (state.phase === "blocked") { if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; await stopAuto(ctx, pi, blockerMsg); @@ -2396,7 +2483,7 @@ async function dispatchNextUnit( if (dispatchResult.action === "stop") { if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } await stopAuto(ctx, pi, dispatchResult.reason); return; @@ -2616,7 +2703,7 @@ async function dispatchNextUnit( unitLifetimeDispatches.set(dispatchKey, lifetimeCount); if (lifetimeCount > MAX_LIFETIME_DISPATCHES) { if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } else { saveActivityLog(ctx, basePath, unitType, unitId); } @@ -2630,7 +2717,7 @@ async function dispatchNextUnit( } if (prevCount >= MAX_UNIT_DISPATCHES) { if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } else { saveActivityLog(ctx, basePath, unitType, unitId); } @@ -2788,7 +2875,7 @@ async function dispatchNextUnit( // Snapshot metrics + activity log for the PREVIOUS unit before we reassign. // The session still holds the previous unit's data (newSession hasn't fired yet). if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); // Record routing outcome for adaptive learning if (currentUnitRouting) { @@ -3008,7 +3095,7 @@ async function dispatchNextUnit( } if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } else { saveActivityLog(ctx, basePath, unitType, unitId); } @@ -3034,7 +3121,7 @@ async function dispatchNextUnit( phase: "timeout", timeoutAt: Date.now(), }); - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); + await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); } else { saveActivityLog(ctx, basePath, unitType, unitId); } @@ -3049,6 +3136,67 @@ async function dispatchNextUnit( await pauseAuto(ctx, pi); }, hardTimeoutMs); + // ── Continue-here context-pressure monitor ──────────────────────────── + // Polls context usage every 15s. When usage hits the continue-here + // threshold (70%), sends a one-shot wrap-up signal so the agent finishes + // gracefully and the next unit gets a fresh session. This is softer than + // context_pause_threshold which hard-pauses auto-mode entirely. + if (continueHereHandle) { + clearInterval(continueHereHandle); + continueHereHandle = null; + } + const executorContextWindow = resolveExecutorContextWindow( + ctx.modelRegistry as Parameters[0], + prefs as Parameters[1], + ctx.model?.contextWindow, + ); + const continueHereThreshold = computeBudgets(executorContextWindow).continueThresholdPercent; + continueHereHandle = setInterval(() => { + if (!active || !currentUnit || !cmdCtx) return; + // One-shot guard: skip if already fired for this unit + const runtime = readUnitRuntimeRecord(basePath, unitType, unitId); + if (runtime?.continueHereFired) return; + + const contextUsage = cmdCtx.getContextUsage(); + if (!contextUsage || contextUsage.percent == null || contextUsage.percent < continueHereThreshold) return; + + // Fire once — mark runtime record and send wrap-up message + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit!.startedAt, { + continueHereFired: true, + }); + + if (verbose) { + ctx.ui.notify( + `Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`, + "info", + ); + } + + pi.sendMessage( + { + customType: "gsd-auto-wrapup", + display: verbose, + content: [ + "**CONTEXT BUDGET WARNING — wrap up this unit now.**", + `Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`, + "The next unit needs a fresh context to work effectively. Wrap up now:", + "1. Finish any in-progress file writes", + "2. Write or update the required durable artifacts (summary, checkboxes)", + "3. Mark task state on disk correctly", + "4. Leave precise resume notes if anything remains unfinished", + "Do NOT start new sub-tasks or investigations.", + ].join("\n"), + }, + { triggerTurn: true }, + ); + + // Clear the interval after firing — no need to keep polling + if (continueHereHandle) { + clearInterval(continueHereHandle); + continueHereHandle = null; + } + }, 15_000); + // Inject prompt — verify auto-mode still active (guards against race with timeout/pause) if (!active) return; pi.sendMessage( diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 447b977df..0f4315a68 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -625,7 +625,7 @@ function showHelp(ctx: ExtensionCommandContext): void { "", "MAINTENANCE", " /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]", - " /gsd export Export milestone/slice results [--json|--markdown]", + " /gsd export Export milestone/slice results [--json|--markdown|--html]", " /gsd cleanup Remove merged branches or snapshots [branches|snapshots]", " /gsd migrate Upgrade .gsd/ structures to new format", " /gsd remote Control remote auto-mode [slack|discord|status|disconnect]", diff --git a/src/resources/extensions/gsd/doctor-proactive.ts b/src/resources/extensions/gsd/doctor-proactive.ts index 810cd46aa..29fabd288 100644 --- a/src/resources/extensions/gsd/doctor-proactive.ts +++ b/src/resources/extensions/gsd/doctor-proactive.ts @@ -173,14 +173,19 @@ export async function preDispatchHealthGate(basePath: string): Promise/ ${esc(opts.milestoneId)}` + : ''; + + const backLink = opts.indexRelPath + ? `All Reports` + : ''; + + return ` + + + + +GSD Report — ${esc(opts.projectName)}${opts.milestoneId ? ` — ${esc(opts.milestoneId)}` : ''} + + + +
+
+
+ + v${esc(opts.gsdVersion)} +
+
+

${esc(opts.projectName)}${milestoneTag}

+ ${esc(opts.projectPath)} +
+
+ ${backLink} +
${formatDateLong(generated)}
+
+
+
+ +
+${sections.join('\n')} +
+
+ +
+ + +`; +} + +// ─── Section: Summary ───────────────────────────────────────────────────────── + +function buildSummarySection( + data: VisualizerData, + _opts: HtmlReportOptions, + _generated: string, +): string { + const t = data.totals; + const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); + const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); + const doneMilestones = data.milestones.filter(m => m.status === 'complete').length; + const activeMilestone = data.milestones.find(m => m.status === 'active'); + const pct = totalSlices > 0 ? Math.round((doneSlices / totalSlices) * 100) : 0; + + const act = data.agentActivity; + const kv = [ + kvi('Milestones', `${doneMilestones}/${data.milestones.length}`), + kvi('Slices', `${doneSlices}/${totalSlices}`), + kvi('Phase', data.phase), + t ? kvi('Cost', formatCost(t.cost)) : '', + t ? kvi('Tokens', formatTokenCount(t.tokens.total)) : '', + t ? kvi('Duration', formatDuration(t.duration)) : '', + t ? kvi('Tool calls', String(t.toolCalls)) : '', + t ? kvi('Units', String(t.units)) : '', + data.remainingSliceCount > 0 ? kvi('Remaining', String(data.remainingSliceCount)) : '', + act ? kvi('Rate', `${act.completionRate.toFixed(1)}/hr`) : '', + ].filter(Boolean).join(''); + + const activeInfo = activeMilestone ? (() => { + const active = activeMilestone.slices.find(s => s.active); + if (!active) return ''; + return `
+ Executing ${esc(activeMilestone.id)}/${esc(active.id)} — ${esc(active.title)} +
`; + })() : ''; + + const activityHtml = act?.active ? ` +
+ + ${esc(act.currentUnit?.type ?? '')} + ${esc(act.currentUnit?.id ?? '')} + ${formatDuration(act.elapsed)} elapsed +
` : ''; + + return section('summary', 'Summary', ` +
${kv}
+
+
+ ${pct}% +
+ ${activeInfo} + ${activityHtml} + `); +} + +// ─── Section: Health ────────────────────────────────────────────────────────── + +function buildHealthSection(data: VisualizerData): string { + const h = data.health; + const t = data.totals; + + const rows: string[] = []; + rows.push(hRow('Token profile', h.tokenProfile)); + if (h.budgetCeiling !== undefined) { + const spent = t?.cost ?? 0; + const pct = (spent / h.budgetCeiling) * 100; + const status = pct > 90 ? 'warn' : pct > 75 ? 'caution' : 'ok'; + rows.push(hRow( + 'Budget ceiling', + `${formatCost(h.budgetCeiling)} (${formatCost(spent)} spent, ${pct.toFixed(0)}% used)`, + status, + )); + } + rows.push(hRow( + 'Truncation rate', + `${h.truncationRate.toFixed(1)}% per unit (${t?.totalTruncationSections ?? 0} total)`, + h.truncationRate > 20 ? 'warn' : h.truncationRate > 10 ? 'caution' : 'ok', + )); + rows.push(hRow( + 'Continue-here rate', + `${h.continueHereRate.toFixed(1)}% per unit (${t?.continueHereFiredCount ?? 0} total)`, + h.continueHereRate > 15 ? 'warn' : h.continueHereRate > 8 ? 'caution' : 'ok', + )); + if (h.tierSavingsLine) rows.push(hRow('Routing savings', h.tierSavingsLine)); + rows.push(hRow('Tool calls', String(h.toolCalls))); + rows.push(hRow('Messages', `${h.assistantMessages} assistant / ${h.userMessages} user`)); + + const tierRows = h.tierBreakdown.length > 0 ? ` +

Tier breakdown

+ + + + ${h.tierBreakdown.map(tb => + ` + + ` + ).join('')} + +
TierUnitsCostTokens
${esc(tb.tier)}${tb.units}${formatCost(tb.cost)}${formatTokenCount(tb.tokens.total)}
` : ''; + + return section('health', 'Health', ` + ${rows.join('')}
+ ${tierRows} + `); +} + +// ─── Section: Progress ──────────────────────────────────────────────────────── + +function buildProgressSection(data: VisualizerData): string { + if (data.milestones.length === 0) { + return section('progress', 'Progress', '

No milestones found.

'); + } + + const critMS = new Set(data.criticalPath.milestonePath); + const critSL = new Set(data.criticalPath.slicePath); + + const msHtml = data.milestones.map(ms => { + const doneCount = ms.slices.filter(s => s.done).length; + const onCrit = critMS.has(ms.id); + const sliceHtml = ms.slices.length > 0 + ? ms.slices.map(sl => buildSliceRow(sl, critSL, data)).join('') + : '

No slices in roadmap yet.

'; + + return ` +
+ + + ${esc(ms.id)} + ${esc(ms.title)} + ${doneCount}/${ms.slices.length} + ${onCrit ? 'critical path' : ''} + ${ms.dependsOn.length > 0 ? `needs ${ms.dependsOn.map(esc).join(', ')}` : ''} + +
${sliceHtml}
+
`; + }).join(''); + + return section('progress', 'Progress', msHtml); +} + +function buildSliceRow(sl: VisualizerSlice, critSL: Set, data: VisualizerData): string { + const onCrit = critSL.has(sl.id); + const ver = data.sliceVerifications.find(v => v.sliceId === sl.id); + const slack = data.criticalPath.sliceSlack.get(sl.id); + const status = sl.done ? 'complete' : sl.active ? 'active' : 'pending'; + + const taskHtml = sl.tasks.length > 0 ? ` +
    + ${sl.tasks.map(t => ` +
  • + + ${esc(t.id)} + ${esc(t.title)} + ${t.estimate ? `${esc(t.estimate)}` : ''} +
  • `).join('')} +
` : ''; + + const tags = [ + ...(ver?.provides ?? []).map(p => `provides: ${esc(p)}`), + ...(ver?.requires ?? []).map(r => `requires: ${esc(r.provides)}`), + ].join(''); + + const keyDecisions = ver?.keyDecisions?.length + ? `
Decisions
    ${ver.keyDecisions.map(d => `
  • ${esc(d)}
  • `).join('')}
` + : ''; + + const patterns = ver?.patternsEstablished?.length + ? `
Patterns
    ${ver.patternsEstablished.map(p => `
  • ${esc(p)}
  • `).join('')}
` + : ''; + + const verifBadge = ver?.verificationResult + ? `
+ ${ver.blockerDiscovered ? 'Blocker: ' : ''}${esc(ver.verificationResult)} +
` + : ''; + + return ` +
+ + + ${esc(sl.id)} + ${esc(sl.title)} + ${esc(sl.risk || '?')} + ${sl.depends.length > 0 ? `${sl.depends.map(esc).join(', ')}` : ''} + ${onCrit ? 'critical' : ''} + ${slack !== undefined && slack > 0 ? `+${slack} slack` : ''} + +
+ ${tags ? `
${tags}
` : ''} + ${verifBadge} + ${keyDecisions} + ${patterns} + ${taskHtml} +
+
`; +} + +// ─── Section: Dependency Graph ──────────────────────────────────────────────── + +function buildDepGraphSection(data: VisualizerData): string { + const hasSlices = data.milestones.some(ms => ms.slices.length > 0); + if (!hasSlices) return section('depgraph', 'Dependencies', '

No slices to graph.

'); + + const hasDeps = data.milestones.some(ms => ms.slices.some(s => s.depends.length > 0)); + if (!hasDeps) return section('depgraph', 'Dependencies', '

No dependencies defined.

'); + + const svgs = data.milestones + .filter(ms => ms.slices.length > 0) + .map(ms => buildMilestoneDepSVG(ms, data)) + .filter(Boolean) + .join(''); + + return section('depgraph', 'Dependencies', svgs); +} + +function buildMilestoneDepSVG(ms: VisualizerMilestone, data: VisualizerData): string { + const slices = ms.slices; + if (slices.length === 0) return ''; + + const critSL = new Set(data.criticalPath.slicePath); + const slMap = new Map(slices.map(s => [s.id, s])); + + const layerMap = new Map(); + const inDeg = new Map(); + for (const s of slices) inDeg.set(s.id, 0); + for (const s of slices) { + for (const dep of s.depends) { + if (slMap.has(dep)) inDeg.set(s.id, (inDeg.get(s.id) ?? 0) + 1); + } + } + + const visited = new Set(); + const q: string[] = []; + for (const [id, d] of inDeg) { + if (d === 0) { q.push(id); visited.add(id); layerMap.set(id, 0); } + } + + while (q.length > 0) { + const node = q.shift()!; + for (const s of slices) { + if (!s.depends.includes(node)) continue; + const newDeg = (inDeg.get(s.id) ?? 1) - 1; + inDeg.set(s.id, newDeg); + layerMap.set(s.id, Math.max(layerMap.get(s.id) ?? 0, (layerMap.get(node) ?? 0) + 1)); + if (newDeg === 0 && !visited.has(s.id)) { visited.add(s.id); q.push(s.id); } + } + } + for (const s of slices) if (!layerMap.has(s.id)) layerMap.set(s.id, 0); + + const maxLayer = Math.max(...[...layerMap.values()]); + const byLayer = new Map(); + for (const [id, layer] of layerMap) { + const arr = byLayer.get(layer) ?? []; + arr.push(id); + byLayer.set(layer, arr); + } + + const NW = 130, NH = 40, CGAP = 56, RGAP = 14, PAD = 20; + let maxRows = 0; + for (let c = 0; c <= maxLayer; c++) maxRows = Math.max(maxRows, (byLayer.get(c) ?? []).length); + const totalH = PAD * 2 + maxRows * NH + Math.max(0, maxRows - 1) * RGAP; + const totalW = PAD * 2 + (maxLayer + 1) * NW + maxLayer * CGAP; + + const pos = new Map(); + for (let col = 0; col <= maxLayer; col++) { + const ids = byLayer.get(col) ?? []; + const colH = ids.length * NH + Math.max(0, ids.length - 1) * RGAP; + const startY = (totalH - colH) / 2; + ids.forEach((id, i) => pos.set(id, { x: PAD + col * (NW + CGAP), y: startY + i * (NH + RGAP) })); + } + + const edges = slices.flatMap(sl => sl.depends.flatMap(dep => { + if (!pos.has(dep) || !pos.has(sl.id)) return []; + const f = pos.get(dep)!, t = pos.get(sl.id)!; + const x1 = f.x + NW, y1 = f.y + NH / 2; + const x2 = t.x, y2 = t.y + NH / 2; + const mx = (x1 + x2) / 2; + const crit = critSL.has(sl.id) && critSL.has(dep); + return [``]; + })); + + const nodes = slices.map(sl => { + const p = pos.get(sl.id); + if (!p) return ''; + const crit = critSL.has(sl.id); + const sc = sl.done ? 'n-done' : sl.active ? 'n-active' : 'n-pending'; + return ` + + ${esc(truncStr(sl.id, 18))} + ${esc(truncStr(sl.title, 18))} + ${esc(sl.id)}: ${esc(sl.title)} + `; + }); + + const legend = `
+ done + active + pending +
`; + + return ` +
+

${esc(ms.id)}: ${esc(ms.title)}

+ ${legend} +
+ + + + + + + + + + ${edges.join('')} + ${nodes.join('')} + +
+
`; +} + +// ─── Section: Metrics ───────────────────────────────────────────────────────── + +function buildMetricsSection(data: VisualizerData): string { + if (!data.totals) return section('metrics', 'Metrics', '

No metrics data yet.

'); + const t = data.totals; + + const grid = [ + kvi('Total cost', formatCost(t.cost)), + kvi('Total tokens', formatTokenCount(t.tokens.total)), + kvi('Input', formatTokenCount(t.tokens.input)), + kvi('Output', formatTokenCount(t.tokens.output)), + kvi('Cache read', formatTokenCount(t.tokens.cacheRead)), + kvi('Cache write', formatTokenCount(t.tokens.cacheWrite)), + kvi('Duration', formatDuration(t.duration)), + kvi('Units', String(t.units)), + kvi('Tool calls', String(t.toolCalls)), + kvi('Truncations', String(t.totalTruncationSections)), + ].join(''); + + const tokenBreakdown = buildTokenBreakdown(t.tokens); + + const phaseRow = data.byPhase.length > 0 ? ` +
+ ${buildBarChart('Cost by phase', data.byPhase.map(p => ({ + label: p.phase, value: p.cost, display: formatCost(p.cost), sub: `${p.units} units`, + })))} + ${buildBarChart('Tokens by phase', data.byPhase.map(p => ({ + label: p.phase, value: p.tokens.total, display: formatTokenCount(p.tokens.total), sub: formatCost(p.cost), + })))} +
` : ''; + + const sliceModelRow = (data.bySlice.length > 0 || data.byModel.length > 0) ? ` +
+ ${data.bySlice.length > 0 ? buildBarChart('Cost by slice', data.bySlice.map(s => ({ + label: s.sliceId, value: s.cost, display: formatCost(s.cost), + sub: `${s.units} units`, + }))) : ''} + ${data.byModel.length > 0 ? buildBarChart('Cost by model', data.byModel.map(m => ({ + label: shortModel(m.model), value: m.cost, display: formatCost(m.cost), + sub: `${m.units} units`, + }))) : ''} +
` : ''; + + return section('metrics', 'Metrics', ` +
${grid}
+ ${tokenBreakdown} + ${phaseRow} + ${sliceModelRow} + `); +} + +function buildTokenBreakdown(tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }): string { + if (tokens.total === 0) return ''; + const segs = [ + { label: 'Input', value: tokens.input, cls: 'seg-1' }, + { label: 'Output', value: tokens.output, cls: 'seg-2' }, + { label: 'Cache read', value: tokens.cacheRead, cls: 'seg-3' }, + { label: 'Cache write', value: tokens.cacheWrite, cls: 'seg-4' }, + ].filter(s => s.value > 0); + + const bars = segs.map(s => { + const pct = (s.value / tokens.total) * 100; + return `
`; + }).join(''); + + const legend = segs.map(s => { + const pct = ((s.value / tokens.total) * 100).toFixed(1); + return `${s.label}: ${formatTokenCount(s.value)} (${pct}%)`; + }).join(''); + + return ` +
+

Token breakdown

+
${bars}
+
${legend}
+
`; +} + +interface BarEntry { label: string; value: number; display: string; sub?: string; color?: number } + +const CHART_COLORS = 6; + +function buildBarChart(title: string, entries: BarEntry[]): string { + if (entries.length === 0) return ''; + const max = Math.max(...entries.map(e => e.value), 1); + const rows = entries.map((e, i) => { + const pct = (e.value / max) * 100; + const ci = e.color ?? i; + return ` +
+
${esc(truncStr(e.label, 22))}
+
+
${esc(e.display)}
+
+ ${e.sub ? `
${esc(e.sub)}
` : ''}`; + }).join(''); + return `

${esc(title)}

${rows}
`; +} + +// ─── Section: Timeline ──────────────────────────────────────────────────────── + +function buildTimelineSection(data: VisualizerData): string { + if (data.units.length === 0) return section('timeline', 'Timeline', '

No units executed yet.

'); + + const sorted = [...data.units].sort((a, b) => a.startedAt - b.startedAt); + const maxCost = Math.max(...sorted.map(u => u.cost), 0.01); + + const rows = sorted.map((u, i) => { + const dur = u.finishedAt > 0 ? formatDuration(u.finishedAt - u.startedAt) : 'running'; + // Cost heatmap: subtle red background for expensive rows + const intensity = Math.min(u.cost / maxCost, 1); + const heatStyle = intensity > 0.15 ? ` style="background:rgba(239,68,68,${(intensity * 0.15).toFixed(3)})"` : ''; + return ` + + ${i + 1} + ${esc(u.type)} + ${esc(u.id)} + ${esc(shortModel(u.model))} + ${formatDateShort(new Date(u.startedAt).toISOString())} + ${dur} + ${formatCost(u.cost)} + ${formatTokenCount(u.tokens.total)} + ${u.toolCalls} + ${u.tier ?? ''} + ${u.modelDowngraded ? 'routed' : ''} + ${(u.truncationSections ?? 0) > 0 ? u.truncationSections : ''} + ${u.continueHereFired ? 'yes' : ''} + `; + }).join(''); + + return section('timeline', 'Timeline', ` +
+ + + + + + + ${rows} +
#TypeIDModelStartedDurationCostTokensToolsTierRoutedTruncCHF
+
`); +} + +// ─── Section: Changelog ─────────────────────────────────────────────────────── + +function buildChangelogSection(data: VisualizerData): string { + if (data.changelog.entries.length === 0) return section('changelog', 'Changelog', '

No completed slices yet.

'); + + const entries = data.changelog.entries.map(e => { + const filesHtml = e.filesModified.length > 0 ? ` +
+ ${e.filesModified.length} file${e.filesModified.length !== 1 ? 's' : ''} modified +
    + ${e.filesModified.map(f => `
  • ${esc(f.path)}${f.description ? ` — ${esc(f.description)}` : ''}
  • `).join('')} +
+
` : ''; + + const ver = data.sliceVerifications.find(v => v.sliceId === e.sliceId); + const decisionsHtml = ver?.keyDecisions?.length ? ` +
Decisions +
    ${ver.keyDecisions.map(d => `
  • ${esc(d)}
  • `).join('')}
+
` : ''; + + return ` +
+
+ ${esc(e.milestoneId)}/${esc(e.sliceId)} + ${esc(e.title)} + ${e.completedAt ? `${formatDateShort(e.completedAt)}` : ''} +
+ ${e.oneLiner ? `

${esc(e.oneLiner)}

` : ''} + ${decisionsHtml} + ${filesHtml} +
`; + }).join(''); + + return section('changelog', `Changelog ${data.changelog.entries.length}`, entries); +} + +// ─── Section: Knowledge ─────────────────────────────────────────────────────── + +function buildKnowledgeSection(data: VisualizerData): string { + const k = data.knowledge; + if (!k.exists) return section('knowledge', 'Knowledge', '

No KNOWLEDGE.md found.

'); + const total = k.rules.length + k.patterns.length + k.lessons.length; + if (total === 0) return section('knowledge', 'Knowledge', '

KNOWLEDGE.md exists but no entries parsed.

'); + + const rulesHtml = k.rules.length > 0 ? ` +

Rules ${k.rules.length}

+ + + ${k.rules.map(r => ``).join('')} +
IDScopeRule
${esc(r.id)}${esc(r.scope)}${esc(r.content)}
` : ''; + + const patternsHtml = k.patterns.length > 0 ? ` +

Patterns ${k.patterns.length}

+ + + ${k.patterns.map(p => ``).join('')} +
IDPattern
${esc(p.id)}${esc(p.content)}
` : ''; + + const lessonsHtml = k.lessons.length > 0 ? ` +

Lessons ${k.lessons.length}

+ + + ${k.lessons.map(l => ``).join('')} +
IDLesson
${esc(l.id)}${esc(l.content)}
` : ''; + + return section('knowledge', `Knowledge ${total}`, `${rulesHtml}${patternsHtml}${lessonsHtml}`); +} + +// ─── Section: Captures ──────────────────────────────────────────────────────── + +function buildCapturesSection(data: VisualizerData): string { + const c = data.captures; + if (c.totalCount === 0) return section('captures', 'Captures', '

No captures recorded.

'); + + const badge = c.pendingCount > 0 + ? `${c.pendingCount} pending` + : `all triaged`; + + const rows = c.entries.map(e => ` + + ${formatDateShort(new Date(e.timestamp).toISOString())} + ${esc(e.status)} + ${e.classification ?? ''} + ${e.resolution ?? ''} + ${esc(e.text)} + ${e.rationale ?? ''} + ${e.resolvedAt ? formatDateShort(e.resolvedAt) : ''} + ${e.executed !== undefined ? (e.executed ? 'yes' : 'no') : ''} + `).join(''); + + return section('captures', `Captures ${badge}`, ` +
+ + + ${rows} +
CapturedStatusClassResolutionTextRationaleResolvedExecuted
+
`); +} + +// ─── Section: Stats ─────────────────────────────────────────────────────────── + +function buildStatsSection(data: VisualizerData): string { + const s = data.stats; + + const missingHtml = s.missingCount > 0 ? ` +

Missing changelogs ${s.missingCount}

+ + + + ${s.missingSlices.map(sl => ``).join('')} + ${s.missingCount > s.missingSlices.length + ? `` + : ''} + +
MilestoneSliceTitle
${esc(sl.milestoneId)}${esc(sl.sliceId)}${esc(sl.title)}
and ${s.missingCount - s.missingSlices.length} more
` : ''; + + const updatedHtml = s.updatedCount > 0 ? ` +

Recently completed ${s.updatedCount}

+ + + ${s.updatedSlices.map(sl => ` + `).join('')} + +
MilestoneSliceTitleCompleted
${esc(sl.milestoneId)}${esc(sl.sliceId)}${esc(sl.title)}${sl.completedAt ? formatDateShort(sl.completedAt) : ''}
` : ''; + + if (!missingHtml && !updatedHtml) { + return section('stats', 'Artifacts', '

All artifacts accounted for.

'); + } + + return section('stats', 'Artifacts', `${missingHtml}${updatedHtml}`); +} + +// ─── Section: Discussion ────────────────────────────────────────────────────── + +function buildDiscussionSection(data: VisualizerData): string { + if (data.discussion.length === 0) return section('discussion', 'Planning', '

No milestones.

'); + + const rows = data.discussion.map(d => ` + + ${esc(d.milestoneId)} + ${esc(d.title)} + ${d.state} + ${d.hasContext ? 'yes' : ''} + ${d.hasDraft ? 'draft' : ''} + ${d.lastUpdated ? formatDateShort(d.lastUpdated) : ''} + `).join(''); + + return section('discussion', 'Planning', ` + + + ${rows} +
IDMilestoneStateContextDraftUpdated
`); +} + +// ─── Primitives ──────────────────────────────────────────────────────────────── + +function section(id: string, title: string, body: string): string { + return `\n
\n

${title}

\n ${body}\n
`; +} + +function kvi(label: string, value: string): string { + return `
${esc(value)}${esc(label)}
`; +} + +function hRow(label: string, value: string, status?: 'ok' | 'caution' | 'warn'): string { + const cls = status ? ` class="h-${status}"` : ''; + return `${esc(label)}${esc(value)}`; +} + +function shortModel(m: string) { return m.replace(/^claude-/, '').replace(/^anthropic\//, ''); } +function truncStr(s: string, n: number) { return s.length > n ? s.slice(0, n - 1) + '\u2026' : s; } + +function formatDateLong(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }); + } catch { return iso; } +} + +function formatDateShort(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } catch { return iso; } +} + +function esc(s: string | undefined | null): string { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +// ─── CSS ─────────────────────────────────────────────────────────────────────── +// Linear-inspired: restrained palette, one accent, no emoji, no gradients. + +const CSS = ` +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg-0:#0f1115;--bg-1:#16181d;--bg-2:#1e2028;--bg-3:#272a33; + --border-1:#2b2e38;--border-2:#3b3f4c; + --text-0:#ededef;--text-1:#a1a1aa;--text-2:#71717a; + --accent:#5e6ad2;--accent-subtle:rgba(94,106,210,.12); + --ok:#22c55e;--ok-subtle:rgba(34,197,94,.12);--warn:#ef4444;--caution:#eab308; + /* Chart palette — 6 hues for bar charts */ + --c0:#5e6ad2;--c1:#e5796d;--c2:#14b8a6;--c3:#a78bfa;--c4:#f59e0b;--c5:#10b981; + /* Token breakdown — 4 distinct hues */ + --tk-input:#5e6ad2;--tk-output:#e5796d;--tk-cache-r:#2dd4bf;--tk-cache-w:#64748b; + --font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; + --mono:'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,monospace; +} +html{scroll-behavior:smooth;font-size:13px} +body{background:var(--bg-0);color:var(--text-0);font-family:var(--font);line-height:1.6;-webkit-font-smoothing:antialiased} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} +code{font-family:var(--mono);font-size:12px;background:var(--bg-3);padding:1px 5px;border-radius:3px} +.mono{font-family:var(--mono);font-size:12px} +.muted{color:var(--text-2)} +.accent{color:var(--accent)} +.sep{color:var(--border-2);margin:0 4px} +.empty{color:var(--text-2);padding:8px 0;font-size:13px} +.indent{padding-left:12px} +.num{font-variant-numeric:tabular-nums;text-align:right} + +/* Status dots — geometric, no emoji */ +.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;vertical-align:middle} +.dot-sm{width:6px;height:6px} +.dot-complete{background:var(--ok);opacity:.6} +.dot-active{background:var(--accent)} +.dot-pending{background:transparent;border:1.5px solid var(--border-2)} + +/* Header */ +header{background:var(--bg-1);border-bottom:1px solid var(--border-1);padding:12px 32px;position:sticky;top:0;z-index:200} +.header-inner{display:flex;align-items:center;gap:16px;max-width:1280px;margin:0 auto} +.branding{display:flex;align-items:baseline;gap:6px;flex-shrink:0} +.logo{font-size:18px;font-weight:800;letter-spacing:-.5px;color:var(--text-0)} +.version{font-size:10px;color:var(--text-2);font-family:var(--mono)} +.header-meta{flex:1;min-width:0} +.header-meta h1{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.header-path{font-size:11px;color:var(--text-2);font-family:var(--mono);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.header-right{text-align:right;flex-shrink:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px} +.generated{font-size:11px;color:var(--text-2)} +.back-link{font-size:12px;color:var(--text-1)} +.back-link:hover{color:var(--accent)} + +/* TOC nav */ +.toc{background:var(--bg-1);border-bottom:1px solid var(--border-1);overflow-x:auto} +.toc ul{display:flex;list-style:none;max-width:1280px;margin:0 auto;padding:0 32px} +.toc a{display:inline-block;padding:8px 12px;color:var(--text-2);font-size:12px;font-weight:500;border-bottom:2px solid transparent;transition:color .12s,border-color .12s;white-space:nowrap;text-decoration:none} +.toc a:hover{color:var(--text-0);border-bottom-color:var(--border-2)} +.toc a.active{color:var(--text-0);border-bottom-color:var(--accent)} + +/* Layout */ +main{max-width:1280px;margin:0 auto;padding:32px;display:flex;flex-direction:column;gap:48px} +section{scroll-margin-top:82px} +section>h2{font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-1);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-1);display:flex;align-items:center;gap:8px} +h3{font-size:13px;font-weight:600;color:var(--text-1);margin:20px 0 8px} +.count{font-size:11px;font-weight:500;color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} +.count-warn{color:var(--caution)} + +/* KV grid (stats/metrics) */ +.kv-grid{display:flex;flex-wrap:wrap;gap:1px;background:var(--border-1);border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:16px} +.kv{background:var(--bg-1);padding:10px 16px;display:flex;flex-direction:column;gap:2px;min-width:110px;flex:1} +.kv-val{font-size:18px;font-weight:600;color:var(--text-0);font-variant-numeric:tabular-nums} +.kv-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.4px} + +/* Progress bar */ +.progress-wrap{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.progress-track{flex:1;height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.progress-fill{height:100%;background:var(--accent);border-radius:2px} +.progress-label{font-size:12px;font-weight:600;color:var(--text-1);min-width:40px;text-align:right} +.active-info{font-size:12px;color:var(--text-1);margin-bottom:4px} +.activity-line{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-1);padding:6px 0} + +/* Tables */ +.tbl{width:100%;border-collapse:collapse;font-size:12px} +.tbl th{color:var(--text-2);font-weight:500;padding:6px 12px;text-align:left;border-bottom:1px solid var(--border-1);font-size:11px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap} +.tbl td{padding:6px 12px;border-bottom:1px solid var(--border-1);vertical-align:top} +.tbl tr:last-child td{border-bottom:none} +.tbl tbody tr:hover td{background:var(--accent-subtle)} +.tbl-kv td:first-child{color:var(--text-2);width:180px} +.table-scroll{overflow-x:auto;border:1px solid var(--border-1);border-radius:4px} +.table-scroll .tbl{border:none} + +/* Health */ +.h-ok td:first-child{color:var(--text-1)} +.h-caution td{color:var(--caution)} +.h-warn td{color:var(--warn)} + +/* Labels */ +.label{font-size:10px;font-weight:500;color:var(--accent);text-transform:uppercase;letter-spacing:.4px} +.risk{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.3px;flex-shrink:0} +.risk-low{color:var(--text-2)} +.risk-medium{color:var(--caution)} +.risk-high{color:var(--warn)} +.risk-unknown{color:var(--text-2)} + +/* Tags */ +.tag-row{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px} +.tag{font-size:11px;font-family:var(--mono);color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} + +/* Verification */ +.verif{font-size:12px;color:var(--text-1);padding:4px 0;margin-bottom:6px} +.verif-blocker{color:var(--warn)} + +/* Detail blocks */ +.detail-block{font-size:12px;color:var(--text-2);margin-bottom:6px} +.detail-label{font-weight:600;color:var(--text-1);display:block;margin-bottom:2px} +.detail-block ul{padding-left:16px;margin-top:2px} +.detail-block li{margin-bottom:1px} + +/* Progress tree */ +.ms-block{border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:8px} +.ms-summary{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;list-style:none;background:var(--bg-1);user-select:none;font-size:13px} +.ms-summary:hover{background:var(--bg-2)} +.ms-summary::-webkit-details-marker{display:none} +.ms-id{font-weight:600} +.ms-title{flex:1;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ms-body{padding:6px 12px 8px 24px;display:flex;flex-direction:column;gap:4px} + +.sl-block{border:1px solid var(--border-1);border-radius:3px;overflow:hidden} +.sl-summary{display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;list-style:none;background:var(--bg-2);font-size:12px;user-select:none} +.sl-summary:hover{background:var(--bg-3)} +.sl-summary::-webkit-details-marker{display:none} +.sl-crit{border-left:2px solid var(--accent)} +.sl-deps::before{content:'\\2190 ';color:var(--border-2)} +.sl-detail{padding:8px 12px;background:var(--bg-0);border-top:1px solid var(--border-1)} + +.task-list{list-style:none;padding:4px 0 0;display:flex;flex-direction:column;gap:2px} +.task-row{display:flex;align-items:center;gap:6px;font-size:12px;padding:3px 6px;border-radius:2px} + +/* Dep graph */ +.dep-block{margin-bottom:28px} +.dep-legend{display:flex;gap:14px;font-size:12px;color:var(--text-2);margin-bottom:8px;align-items:center} +.dep-legend span{display:flex;align-items:center;gap:4px} +.dep-wrap{overflow-x:auto;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:16px} +.dep-svg{display:block} +.edge{fill:none;stroke:var(--border-2);stroke-width:1.5} +.edge-crit{stroke:var(--accent);stroke-width:2} +.node rect{fill:var(--bg-2);stroke:var(--border-2);stroke-width:1} +.n-done rect{fill:var(--ok-subtle);stroke:rgba(34,197,94,.4)} +.n-active rect{fill:var(--accent-subtle);stroke:var(--accent)} +.n-crit rect{stroke:var(--accent)!important;stroke-width:1.5!important} +.n-id{font-family:var(--mono);font-size:10px;fill:var(--text-1);font-weight:600;text-anchor:middle} +.n-title{font-size:9px;fill:var(--text-2);text-anchor:middle} +.n-active .n-id{fill:var(--accent)} + +/* Metrics */ +.token-block{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px;margin-bottom:16px} +.token-bar{display:flex;height:16px;border-radius:2px;overflow:hidden;gap:1px;margin-bottom:8px} +.tseg{height:100%;min-width:2px} +.seg-1{background:var(--tk-input)} +.seg-2{background:var(--tk-output)} +.seg-3{background:var(--tk-cache-r)} +.seg-4{background:var(--tk-cache-w)} +.token-legend{display:flex;flex-wrap:wrap;gap:12px} +.leg-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-2)} +.leg-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0} +.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px} +@media(max-width:860px){.chart-row{grid-template-columns:1fr}} +.chart-block{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px} +.bar-row{display:grid;grid-template-columns:120px 1fr 68px;align-items:center;gap:6px;margin-bottom:2px} +.bar-lbl{font-size:12px;color:var(--text-2);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bar-track{height:14px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.bar-fill{height:100%;border-radius:2px;background:var(--c0)} +.bar-c0{background:var(--c0)}.bar-c1{background:var(--c1)}.bar-c2{background:var(--c2)} +.bar-c3{background:var(--c3)}.bar-c4{background:var(--c4)}.bar-c5{background:var(--c5)} +.bar-val{font-size:11px;font-variant-numeric:tabular-nums;color:var(--text-1)} +.bar-sub{font-size:10px;color:var(--text-2);padding-left:128px;margin-bottom:6px} + +/* Changelog */ +.cl-entry{border-bottom:1px solid var(--border-1);padding:12px 0} +.cl-entry:last-child{border-bottom:none} +.cl-header{display:flex;align-items:center;gap:8px;margin-bottom:4px} +.cl-title{flex:1;font-weight:500} +.cl-date{margin-left:auto;white-space:nowrap} +.cl-liner{font-size:13px;color:var(--text-1);margin-bottom:6px} +.files-detail summary{font-size:12px;cursor:pointer} +.file-list{list-style:none;padding-left:10px;margin-top:4px;display:flex;flex-direction:column;gap:2px} +.file-list li{font-size:12px;color:var(--text-1)} + +/* Footer */ +footer{border-top:1px solid var(--border-1);padding:20px 32px;margin-top:40px} +.footer-inner{display:flex;align-items:center;gap:6px;justify-content:center;font-size:11px;color:var(--text-2)} + +/* Print */ +@media print{ + header,nav.toc{position:static} + body{background:#fff;color:#1a1a1a} + :root{--bg-0:#fff;--bg-1:#fafafa;--bg-2:#f5f5f5;--bg-3:#ebebeb;--border-1:#e5e5e5;--border-2:#d4d4d4;--text-0:#1a1a1a;--text-1:#525252;--text-2:#a3a3a3;--accent:#4f46e5;--ok:#16a34a;--ok-subtle:rgba(22,163,74,.08);--c0:#4f46e5;--c1:#dc2626;--c2:#0d9488;--c3:#7c3aed;--c4:#d97706;--c5:#059669;--tk-input:#4f46e5;--tk-output:#dc2626;--tk-cache-r:#0d9488;--tk-cache-w:#64748b} + section{page-break-inside:avoid} + .table-scroll{overflow:visible} +} +`; + +// ─── JS ──────────────────────────────────────────────────────────────────────── + +const JS = ` +(function(){ + const sections=document.querySelectorAll('section[id]'); + const links=document.querySelectorAll('.toc a'); + if(!sections.length||!links.length)return; + const obs=new IntersectionObserver(entries=>{ + for(const e of entries){ + if(!e.isIntersecting)continue; + for(const l of links)l.classList.remove('active'); + const a=document.querySelector('.toc a[href="#'+e.target.id+'"]'); + if(a)a.classList.add('active'); + } + },{rootMargin:'-10% 0px -80% 0px',threshold:0}); + for(const s of sections)obs.observe(s); +})(); +`; diff --git a/src/resources/extensions/gsd/export.ts b/src/resources/extensions/gsd/export.ts index 7a5202bd2..f4a23c080 100644 --- a/src/resources/extensions/gsd/export.ts +++ b/src/resources/extensions/gsd/export.ts @@ -93,9 +93,57 @@ export function writeExportFile( } /** - * Export session/milestone data to JSON or markdown. + * Export session/milestone data to JSON, markdown, or HTML. */ export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise { + // HTML report — delegates to the full visualizer-data pipeline + if (args.includes("--html")) { + try { + const { loadVisualizerData } = await import("./visualizer-data.js"); + const { generateHtmlReport } = await import("./export-html.js"); + const { writeReportSnapshot, reportsDir } = await import("./reports.js"); + const { basename: bn } = await import("node:path"); + const data = await loadVisualizerData(basePath); + const projName = basename(basePath); + const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; + const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); + const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); + const outPath = writeReportSnapshot({ + basePath, + html: generateHtmlReport(data, { + projectName: projName, + projectPath: basePath, + gsdVersion, + indexRelPath: "index.html", + }), + milestoneId: data.milestones.find(m => m.status === "active")?.id ?? "manual", + milestoneTitle: data.milestones.find(m => m.status === "active")?.title ?? "", + kind: "manual", + projectName: projName, + projectPath: basePath, + gsdVersion, + totalCost: data.totals?.cost ?? 0, + totalTokens: data.totals?.tokens.total ?? 0, + totalDuration: data.totals?.duration ?? 0, + doneSlices, + totalSlices, + doneMilestones: data.milestones.filter(m => m.status === "complete").length, + totalMilestones: data.milestones.length, + phase: data.phase, + }); + ctx.ui.notify( + `HTML report saved: .gsd/reports/${bn(outPath)}\nBrowse all reports: .gsd/reports/index.html`, + "success", + ); + } catch (err) { + ctx.ui.notify( + `HTML export failed: ${err instanceof Error ? err.message : String(err)}`, + "error", + ); + } + return; + } + const format = args.includes("--json") ? "json" : "markdown"; const ledger = getLedger(); diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 79b5bbc22..b337d141a 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -60,6 +60,7 @@ import { shortcutDesc } from "../shared/terminal.js"; import { Text } from "@gsd/pi-tui"; import { pauseAutoForProviderError } from "./provider-error-pause.js"; import { toPosixPath } from "../shared/path-display.js"; +import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js"; // ── Agent Instructions ──────────────────────────────────────────────────── // Lightweight "always follow" files injected into every GSD agent session. @@ -856,6 +857,12 @@ export default function (pi: ExtensionAPI) { // ── session_shutdown: save activity log on Ctrl+C / SIGTERM ───────────── pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => { + if (isParallelActive()) { + try { + await shutdownParallel(process.cwd()); + } catch { /* best-effort */ } + } + if (!isAutoActive() && !isAutoPaused()) return; // Save the current session — the lock file stays on disk diff --git a/src/resources/extensions/gsd/parallel-orchestrator.ts b/src/resources/extensions/gsd/parallel-orchestrator.ts index db5dfabff..340cfb990 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -8,7 +8,14 @@ */ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; +import { + existsSync, + writeFileSync, + readFileSync, + renameSync, + unlinkSync, + mkdirSync, +} from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { gsdRoot } from "./paths.js"; @@ -58,6 +65,142 @@ export interface OrchestratorState { let state: OrchestratorState | null = null; +// ─── Persistence ────────────────────────────────────────────────────────── + +const ORCHESTRATOR_STATE_FILE = "orchestrator.json"; +const TMP_SUFFIX = ".tmp"; + +export interface PersistedState { + active: boolean; + workers: Array<{ + milestoneId: string; + title: string; + pid: number; + worktreePath: string; + startedAt: number; + state: "running" | "paused" | "stopped" | "error"; + completedUnits: number; + cost: number; + }>; + totalCost: number; + startedAt: number; + configSnapshot: { max_workers: number; budget_ceiling?: number }; +} + +function stateFilePath(basePath: string): string { + return join(gsdRoot(basePath), ORCHESTRATOR_STATE_FILE); +} + +/** + * Persist the current orchestrator state to .gsd/orchestrator.json. + * Uses atomic write (tmp + rename) to prevent partial reads. + */ +export function persistState(basePath: string): void { + if (!state) return; + try { + const dir = gsdRoot(basePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const persisted: PersistedState = { + active: state.active, + workers: [...state.workers.values()].map((w) => ({ + milestoneId: w.milestoneId, + title: w.title, + pid: w.pid, + worktreePath: w.worktreePath, + startedAt: w.startedAt, + state: w.state, + completedUnits: w.completedUnits, + cost: w.cost, + })), + totalCost: state.totalCost, + startedAt: state.startedAt, + configSnapshot: { + max_workers: state.config.max_workers, + budget_ceiling: state.config.budget_ceiling, + }, + }; + + const dest = stateFilePath(basePath); + const tmp = dest + TMP_SUFFIX; + writeFileSync(tmp, JSON.stringify(persisted, null, 2), "utf-8"); + renameSync(tmp, dest); + } catch { /* non-fatal */ } +} + +/** + * Remove the persisted state file. + */ +function removeStateFile(basePath: string): void { + try { + const p = stateFilePath(basePath); + if (existsSync(p)) unlinkSync(p); + } catch { /* non-fatal */ } +} + +function isPidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Restore orchestrator state from .gsd/orchestrator.json. + * Checks PID liveness for each worker: + * - Living PID → state "running", process stays null (no handle) + * - Dead PID → removed from restored state + * Returns null if no state file exists or no workers survive. + */ +export function restoreState(basePath: string): PersistedState | null { + try { + const p = stateFilePath(basePath); + if (!existsSync(p)) return null; + const raw = readFileSync(p, "utf-8"); + const persisted = JSON.parse(raw) as PersistedState; + + // Filter to only workers with living PIDs + persisted.workers = persisted.workers.filter((w) => { + if (w.state === "stopped" || w.state === "error") return false; + return isPidAlive(w.pid); + }); + + if (persisted.workers.length === 0) { + // No surviving workers — clean up and return null + removeStateFile(basePath); + return null; + } + + return persisted; + } catch { + return null; + } +} + +async function waitForWorkerExit(worker: WorkerInfo, timeoutMs: number): Promise { + if (worker.process) { + await new Promise((resolve) => { + const done = () => resolve(); + const timer = setTimeout(done, timeoutMs); + worker.process!.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + return worker.process === null || !isPidAlive(worker.pid); + } + + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (!isPidAlive(worker.pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return !isPidAlive(worker.pid); +} + // ─── Accessors ───────────────────────────────────────────────────────────── /** Returns true if the orchestrator is active and has been initialized. */ @@ -81,12 +224,26 @@ export function getWorkerStatuses(): WorkerInfo[] { /** * Analyze eligibility and prepare for parallel start. * Returns the candidates report without actually starting workers. + * Also detects orphaned sessions from prior crashes. */ export async function prepareParallelStart( basePath: string, _prefs: GSDPreferences | undefined, -): Promise { - return analyzeParallelEligibility(basePath); +): Promise }> { + // Detect orphaned sessions before eligibility analysis + const sessions = readAllSessionStatuses(basePath); + const orphans: Array<{ milestoneId: string; pid: number; alive: boolean }> = []; + for (const session of sessions) { + const alive = isPidAlive(session.pid); + orphans.push({ milestoneId: session.milestoneId, pid: session.pid, alive }); + if (!alive) { + // Clean up dead session + removeSessionStatus(basePath, session.milestoneId); + } + } + + const candidates = await analyzeParallelEligibility(basePath); + return orphans.length > 0 ? { ...candidates, orphans } : candidates; } // ─── Start ───────────────────────────────────────────────────────────────── @@ -106,6 +263,36 @@ export async function startParallel( } const config = resolveParallelConfig(prefs); + + // Try to restore from a previous crash + const restored = restoreState(basePath); + if (restored && restored.workers.length > 0) { + // Adopt surviving workers instead of starting new ones + state = { + active: true, + workers: new Map(), + config, + totalCost: restored.totalCost, + startedAt: restored.startedAt, + }; + const adopted: string[] = []; + for (const w of restored.workers) { + state.workers.set(w.milestoneId, { + milestoneId: w.milestoneId, + title: w.title, + pid: w.pid, + process: null, // no handle for adopted workers + worktreePath: w.worktreePath, + startedAt: w.startedAt, + state: "running", + completedUnits: w.completedUnits, + cost: w.cost, + }); + adopted.push(w.milestoneId); + } + return { started: adopted, errors: [] }; + } + const now = Date.now(); // Initialize orchestrator state @@ -190,6 +377,9 @@ export async function startParallel( state.active = false; } + // Persist state for crash recovery + persistState(basePath); + return { started, errors }; } @@ -485,12 +675,24 @@ export async function stopParallel( try { if (worker.process) { worker.process.kill("SIGTERM"); - } else { + } else if (worker.pid !== process.pid) { process.kill(worker.pid, "SIGTERM"); } } catch { /* process may already be dead */ } } + const exitedAfterTerm = await waitForWorkerExit(worker, 750); + if (!exitedAfterTerm && worker.pid > 0) { + try { + if (worker.process) { + worker.process.kill("SIGKILL"); + } else if (worker.pid !== process.pid) { + process.kill(worker.pid, "SIGKILL"); + } + } catch { /* process may already be dead */ } + await waitForWorkerExit(worker, 250); + } + // Update in-memory state worker.state = "stopped"; worker.process = null; @@ -503,6 +705,15 @@ export async function stopParallel( if (!milestoneId) { state.active = false; } + + // Persist final state and clean up state file + removeStateFile(basePath); +} + +export async function shutdownParallel(basePath: string): Promise { + if (!state) return; + await stopParallel(basePath); + resetOrchestrator(); } // ─── Pause / Resume ──────────────────────────────────────────────────────── @@ -589,6 +800,9 @@ export function refreshWorkerStatuses(basePath: string): void { for (const worker of state.workers.values()) { state.totalCost += worker.cost; } + + // Persist updated state for crash recovery + persistState(basePath); } // ─── Budget ──────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index f61c1627a..762318493 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -75,6 +75,7 @@ const KNOWN_PREFERENCE_KEYS = new Set([ "token_profile", "phases", "auto_visualize", + "auto_report", "parallel", "verification_commands", "verification_auto_fix", @@ -175,6 +176,8 @@ export interface GSDPreferences { token_profile?: TokenProfile; phases?: PhaseSkipPreferences; auto_visualize?: boolean; + /** Generate HTML report snapshot after each milestone completion. Default: true. Set false to disable. */ + auto_report?: boolean; parallel?: import("./types.js").ParallelConfig; verification_commands?: string[]; verification_auto_fix?: boolean; @@ -333,7 +336,7 @@ function resolveSkillReference(ref: string, cwd: string): SkillResolution { try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { - if (!entry.isDirectory()) continue; + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; if (entry.name === expanded) { const skillFile = join(dir, entry.name, "SKILL.md"); if (existsSync(skillFile)) { diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 8d0a9775d..41d36f729 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -154,7 +154,7 @@ Templates showing the expected format for each artifact type are in: **External facts:** Use `search-the-web` + `fetch_page`, or `search_and_read` for one-call extraction. Use `freshness` for recency. Never state current facts from training data without verification. -**Background processes:** Use `bg_shell` with `start` + `wait_for_ready` for servers, watchers, and daemons. Never use `bash` with `&` or `nohup` to background a process — the `bash` tool waits for stdout to close, so backgrounded children that inherit the file descriptors cause it to hang indefinitely. Never poll with `sleep`/retry loops — `wait_for_ready` exists for this. For status checks, use `digest` (~30 tokens), not `output` (~2000 tokens). Use `highlights` (~100 tokens) when you need significant lines only. Use `output` only when actively debugging. +**Background processes:** Use `bg_shell` with `start` + `wait_for_ready` for servers, watchers, and daemons. Never use `bash` with `&` or `nohup` to background a process — the `bash` tool waits for stdout to close, so backgrounded children that inherit the file descriptors cause it to hang indefinitely. Never poll with `sleep`/retry loops — `wait_for_ready` exists for this. For status checks, use `digest` (~30 tokens), not `output` (~2000 tokens). Use `highlights` (~100 tokens) when you need significant lines only. Use `output` only when actively debugging. Background processes are session-scoped by default; set `persist_across_sessions:true` only when you intentionally need them to survive a fresh session. **One-shot commands:** Use `async_bash` for builds, tests, and installs. The result is pushed to you when the command exits — no polling needed. Use `await_job` to block on a specific job. diff --git a/src/resources/extensions/gsd/reports.ts b/src/resources/extensions/gsd/reports.ts new file mode 100644 index 000000000..c31d73bff --- /dev/null +++ b/src/resources/extensions/gsd/reports.ts @@ -0,0 +1,510 @@ +/** + * GSD Reports Registry + * + * Manages .gsd/reports/ — the persistent progression log of HTML snapshots. + * + * Layout: + * .gsd/reports/ + * reports.json lightweight metadata index (never re-parses HTML) + * index.html auto-regenerated on every new snapshot + * M001-20260101T120000.html per-milestone snapshot + * final-20260201T090000.html full-project final snapshot + * + * Auto-triggered: after each milestone completion (when auto_report: true). + * Manual: /gsd export --html + */ + +import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { gsdRoot } from './paths.js'; +import { formatCost, formatTokenCount } from './metrics.js'; +import { formatDuration } from './history.js'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ReportEntry { + /** Filename relative to the reports/ dir, e.g. "M001-20260101T120000.html" */ + filename: string; + /** ISO timestamp when this report was generated */ + generatedAt: string; + /** Milestone ID this snapshot covers, or "final" for a full-project snapshot */ + milestoneId: string | 'final'; + /** Milestone title at snapshot time */ + milestoneTitle: string; + /** Human-readable label shown in the index */ + label: string; + /** Snapshot kind */ + kind: 'milestone' | 'manual' | 'final'; + // Metrics at snapshot time — for the index progression view + totalCost: number; + totalTokens: number; + totalDuration: number; + doneSlices: number; + totalSlices: number; + doneMilestones: number; + totalMilestones: number; + phase: string; +} + +export interface ReportsIndex { + version: 1; + projectName: string; + projectPath: string; + gsdVersion: string; + entries: ReportEntry[]; +} + +// ─── Paths ──────────────────────────────────────────────────────────────────── + +export function reportsDir(basePath: string): string { + return join(gsdRoot(basePath), 'reports'); +} + +function reportsIndexPath(basePath: string): string { + return join(reportsDir(basePath), 'reports.json'); +} + +function reportsHtmlIndexPath(basePath: string): string { + return join(reportsDir(basePath), 'index.html'); +} + +// ─── Registry ───────────────────────────────────────────────────────────────── + +export function loadReportsIndex(basePath: string): ReportsIndex | null { + const p = reportsIndexPath(basePath); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf-8')) as ReportsIndex; + } catch { + return null; + } +} + +function saveReportsIndex(basePath: string, index: ReportsIndex): void { + const dir = reportsDir(basePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(reportsIndexPath(basePath), JSON.stringify(index, null, 2) + '\n', 'utf-8'); +} + +// ─── Write a report snapshot ────────────────────────────────────────────────── + +export interface WriteReportSnapshotArgs { + basePath: string; + html: string; + milestoneId: string | 'final'; + milestoneTitle: string; + kind: 'milestone' | 'manual' | 'final'; + projectName: string; + projectPath: string; + gsdVersion: string; + // metrics + totalCost: number; + totalTokens: number; + totalDuration: number; + doneSlices: number; + totalSlices: number; + doneMilestones: number; + totalMilestones: number; + phase: string; +} + +/** + * Write a report snapshot to .gsd/reports/, update reports.json, regenerate index.html. + * Returns the path of the written report file. + */ +export function writeReportSnapshot(args: WriteReportSnapshotArgs): string { + const dir = reportsDir(args.basePath); + mkdirSync(dir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const prefix = args.milestoneId === 'final' ? 'final' : args.milestoneId; + const filename = `${prefix}-${timestamp}.html`; + const filePath = join(dir, filename); + + writeFileSync(filePath, args.html, 'utf-8'); + + // Load or init registry + const existing = loadReportsIndex(args.basePath); + const index: ReportsIndex = existing ?? { + version: 1, + projectName: args.projectName, + projectPath: args.projectPath, + gsdVersion: args.gsdVersion, + entries: [], + }; + + // Keep metadata fresh + index.projectName = args.projectName; + index.projectPath = args.projectPath; + index.gsdVersion = args.gsdVersion; + + const label = args.milestoneId === 'final' + ? 'Final Report' + : `${args.milestoneId}: ${args.milestoneTitle}`; + + const entry: ReportEntry = { + filename, + generatedAt: new Date().toISOString(), + milestoneId: args.milestoneId, + milestoneTitle: args.milestoneTitle, + label, + kind: args.kind, + totalCost: args.totalCost, + totalTokens: args.totalTokens, + totalDuration: args.totalDuration, + doneSlices: args.doneSlices, + totalSlices: args.totalSlices, + doneMilestones: args.doneMilestones, + totalMilestones: args.totalMilestones, + phase: args.phase, + }; + + index.entries.push(entry); + saveReportsIndex(args.basePath, index); + regenerateHtmlIndex(args.basePath, index); + + return filePath; +} + +// ─── HTML Index Generator ───────────────────────────────────────────────────── + +export function regenerateHtmlIndex(basePath: string, index: ReportsIndex): void { + const html = buildIndexHtml(index); + writeFileSync(reportsHtmlIndexPath(basePath), html, 'utf-8'); +} + +function buildIndexHtml(index: ReportsIndex): string { + const { projectName, projectPath, gsdVersion, entries } = index; + const generated = new Date().toISOString(); + + // Sort oldest → newest for the progression timeline + const sorted = [...entries].sort( + (a, b) => new Date(a.generatedAt).getTime() - new Date(b.generatedAt).getTime() + ); + + const latestEntry = sorted[sorted.length - 1]; + const overallPct = latestEntry + ? (latestEntry.totalSlices > 0 + ? Math.round((latestEntry.doneSlices / latestEntry.totalSlices) * 100) + : 0) + : 0; + + // TOC: group by milestone + const milestoneGroups = new Map(); + for (const e of sorted) { + const key = e.milestoneId; + const arr = milestoneGroups.get(key) ?? []; + arr.push(e); + milestoneGroups.set(key, arr); + } + + const tocHtml = [...milestoneGroups.entries()].map(([mid, group]) => { + const links = group.map(e => + `
  • ${formatDateShort(e.generatedAt)} ${e.kind}
  • ` + ).join(''); + return ` +
    +
    ${esc(mid === 'final' ? 'Final' : mid)}
    +
      ${links}
    +
    `; + }).join(''); + + // Progression cards + const cardHtml = sorted.map((e, i) => { + const pct = e.totalSlices > 0 ? Math.round((e.doneSlices / e.totalSlices) * 100) : 0; + const isLatest = i === sorted.length - 1; + + // Delta vs previous + let deltaHtml = ''; + if (i > 0) { + const prev = sorted[i - 1]; + const dCost = e.totalCost - prev.totalCost; + const dSlices = e.doneSlices - prev.doneSlices; + const dMillestones = e.doneMilestones - prev.doneMilestones; + const parts: string[] = []; + if (dCost > 0) parts.push(`+${formatCost(dCost)}`); + if (dSlices > 0) parts.push(`+${dSlices} slice${dSlices !== 1 ? 's' : ''}`); + if (dMillestones > 0) parts.push(`+${dMillestones} milestone${dMillestones !== 1 ? 's' : ''}`); + if (parts.length > 0) { + deltaHtml = `
    ${parts.map(p => `${esc(p)}`).join('')}
    `; + } + } + + return ` + +
    + ${esc(e.label)} + ${e.kind} +
    +
    ${formatDateShort(e.generatedAt)}
    +
    +
    +
    +
    + ${pct}% +
    +
    + ${esc(formatCost(e.totalCost))} + ${esc(formatTokenCount(e.totalTokens))} + ${esc(formatDuration(e.totalDuration))} + ${e.doneSlices}/${e.totalSlices} slices +
    + ${deltaHtml} + ${isLatest ? '
    Latest
    ' : ''} +
    `; + }).join(''); + + // Cost progression mini-chart (inline SVG sparkline) + const sparklineSvg = sorted.length > 1 ? buildCostSparkline(sorted) : ''; + + // Summary of latest state + const summaryHtml = latestEntry ? ` +
    +
    ${formatCost(latestEntry.totalCost)}Total Cost
    +
    ${formatTokenCount(latestEntry.totalTokens)}Total Tokens
    +
    ${formatDuration(latestEntry.totalDuration)}Duration
    +
    ${latestEntry.doneSlices}/${latestEntry.totalSlices}Slices
    +
    ${latestEntry.doneMilestones}/${latestEntry.totalMilestones}Milestones
    +
    ${entries.length}Reports
    +
    +
    +
    + ${overallPct}% complete +
    ` : '

    No reports generated yet.

    '; + + return ` + + + + +GSD Reports — ${esc(projectName)} + + + +
    +
    +
    + + v${esc(gsdVersion)} +
    +
    +

    ${esc(projectName)} Reports

    + ${esc(projectPath)} +
    +
    + Updated + ${formatDateShort(generated)} +
    +
    +
    + +
    + + + + +
    +
    +

    Project Overview

    + ${summaryHtml} + ${sparklineSvg ? `

    Cost Progression

    ${sparklineSvg}
    ` : ''} +
    + +
    +

    Progression ${entries.length}

    + ${sorted.length > 0 + ? `
    ${cardHtml}
    ` + : '

    No reports generated yet. Run /gsd export --html or enable auto_report: true.

    '} +
    +
    +
    + +
    +
    + GSD v${esc(gsdVersion)} + + ${esc(projectName)} + + ${esc(projectPath)} + + Updated ${formatDateShort(generated)} +
    +
    + +`; +} + +// ─── Cost sparkline (inline SVG) ────────────────────────────────────────────── + +function buildCostSparkline(entries: ReportEntry[]): string { + const costs = entries.map(e => e.totalCost); + const maxCost = Math.max(...costs, 0.001); + const W = 600, H = 60, PAD = 12; + const xStep = entries.length > 1 ? (W - PAD * 2) / (entries.length - 1) : W - PAD * 2; + + const points = costs.map((c, i) => { + const x = PAD + i * xStep; + const y = PAD + (1 - c / maxCost) * (H - PAD * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + + const dots = costs.map((c, i) => { + const x = PAD + i * xStep; + const y = PAD + (1 - c / maxCost) * (H - PAD * 2); + return ` + ${esc(entries[i].label)} — ${formatCost(c)} + `; + }).join(''); + + // Labels at start and end + const startLabel = formatCost(costs[0]); + const endLabel = formatCost(costs[costs.length - 1]); + + return ` +
    + + + ${dots} + ${esc(startLabel)} + ${esc(endLabel)} + +
    + ${entries.map((e, i) => { + const x = (PAD + i * xStep) / W * 100; + return `${esc(e.milestoneId === 'final' ? 'final' : e.milestoneId)}`; + }).join('')} +
    +
    `; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatDateShort(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } catch { return iso; } +} + +function esc(s: string | number | undefined | null): string { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +// ─── Index CSS ──────────────────────────────────────────────────────────────── + +const INDEX_CSS = ` +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg-0:#0f1115;--bg-1:#16181d;--bg-2:#1e2028;--bg-3:#272a33; + --border-1:#2b2e38;--border-2:#3b3f4c; + --text-0:#ededef;--text-1:#a1a1aa;--text-2:#71717a; + --accent:#5e6ad2;--accent-subtle:rgba(94,106,210,.12); + --font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; + --mono:'JetBrains Mono','Fira Code',ui-monospace,monospace; +} +html{font-size:13px} +body{background:var(--bg-0);color:var(--text-0);font-family:var(--font);line-height:1.6;-webkit-font-smoothing:antialiased} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} +h2{font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-1);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-1)} +h3{font-size:13px;font-weight:600;color:var(--text-1);margin:16px 0 8px} +code{font-family:var(--mono);font-size:12px;background:var(--bg-3);padding:1px 5px;border-radius:3px} +.empty{color:var(--text-2);font-size:13px;padding:8px 0} +.count{font-size:11px;font-weight:500;color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} + +/* Header */ +header{background:var(--bg-1);border-bottom:1px solid var(--border-1);padding:12px 32px;position:sticky;top:0;z-index:100} +.hdr-inner{display:flex;align-items:center;gap:16px;max-width:1280px;margin:0 auto} +.branding{display:flex;align-items:baseline;gap:6px;flex-shrink:0} +.logo{font-size:18px;font-weight:800;letter-spacing:-.5px;color:var(--text-0)} +.ver{font-size:10px;color:var(--text-2);font-family:var(--mono)} +.hdr-meta{flex:1;min-width:0} +.hdr-meta h1{font-size:15px;font-weight:600} +.hdr-subtitle{color:var(--text-2);font-weight:400;font-size:13px;margin-left:4px} +.hdr-path{font-size:11px;color:var(--text-2);font-family:var(--mono);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.hdr-right{text-align:right;flex-shrink:0} +.gen-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;display:block} +.gen{font-size:11px;color:var(--text-1)} + +/* Layout */ +.layout{display:grid;grid-template-columns:200px 1fr;gap:0;max-width:1280px;margin:0 auto;min-height:calc(100vh - 120px)} + +/* Sidebar */ +.sidebar{background:var(--bg-1);border-right:1px solid var(--border-1);padding:20px 14px;position:sticky;top:52px;height:calc(100vh - 52px);overflow-y:auto} +.sidebar-title{font-size:10px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px} +.toc-group{margin-bottom:14px} +.toc-group-label{font-size:11px;font-weight:600;color:var(--text-1);margin-bottom:3px;font-family:var(--mono)} +.toc-group ul{list-style:none;display:flex;flex-direction:column;gap:1px} +.toc-group li{display:flex;align-items:center;gap:6px} +.toc-group a{font-size:11px;color:var(--text-2);padding:2px 4px;border-radius:3px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.toc-group a:hover{background:var(--bg-2);color:var(--text-0);text-decoration:none} +.toc-kind{font-size:9px;color:var(--text-2);font-family:var(--mono);flex-shrink:0} + +/* Main */ +main{padding:28px;display:flex;flex-direction:column;gap:40px} + +/* Overview */ +.idx-summary{display:flex;flex-wrap:wrap;gap:1px;background:var(--border-1);border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:16px} +.idx-stat{background:var(--bg-1);padding:10px 16px;display:flex;flex-direction:column;gap:2px;min-width:100px;flex:1} +.idx-val{font-size:18px;font-weight:600;color:var(--text-0);font-variant-numeric:tabular-nums} +.idx-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.4px} +.idx-progress{display:flex;align-items:center;gap:10px;margin-top:10px} +.idx-bar-track{flex:1;height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.idx-bar-fill{height:100%;background:var(--accent);border-radius:2px} +.idx-pct{font-size:12px;font-weight:600;color:var(--text-1);min-width:40px;text-align:right} + +/* Sparkline */ +.sparkline-wrap{margin-top:20px} +.sparkline{position:relative} +.spark-svg{display:block;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;overflow:visible;max-width:100%} +.spark-line{stroke:var(--accent);stroke-width:1.5;fill:none} +.spark-dot{fill:var(--accent);stroke:var(--bg-1);stroke-width:2;cursor:pointer} +.spark-dot:hover{r:4;fill:var(--text-0)} +.spark-lbl{font-size:10px;fill:var(--text-2);font-family:var(--mono)} +.spark-axis{display:flex;position:relative;height:18px;margin-top:2px} +.spark-tick{position:absolute;transform:translateX(-50%);font-size:9px;color:var(--text-2);font-family:var(--mono);white-space:nowrap} + +/* Report cards */ +.cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px} +.report-card{ + display:flex;flex-direction:column;gap:6px; + background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px; + padding:14px;text-decoration:none;color:var(--text-0); + transition:border-color .12s; +} +.report-card:hover{border-color:var(--accent);text-decoration:none} +.card-latest{border-color:var(--accent)} +.card-top{display:flex;align-items:center;gap:8px} +.card-label{flex:1;font-weight:500;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.card-kind{font-size:10px;color:var(--text-2);font-family:var(--mono);flex-shrink:0} +.card-date{font-size:11px;color:var(--text-2)} +.card-progress{display:flex;align-items:center;gap:6px} +.card-bar-track{flex:1;height:3px;background:var(--bg-3);border-radius:2px;overflow:hidden} +.card-bar-fill{height:100%;background:var(--accent);border-radius:2px} +.card-pct{font-size:11px;color:var(--text-2);min-width:30px;text-align:right} +.card-stats{display:flex;gap:8px;flex-wrap:wrap} +.card-stats span{font-size:11px;color:var(--text-2);font-variant-numeric:tabular-nums} +.card-delta{display:flex;gap:4px;flex-wrap:wrap} +.card-delta span{font-size:10px;color:var(--text-1);font-family:var(--mono)} +.card-latest-badge{display:none} + +/* Footer */ +footer{border-top:1px solid var(--border-1);padding:16px 32px} +.ftr-inner{display:flex;align-items:center;gap:6px;justify-content:center;font-size:11px;color:var(--text-2)} +.ftr-sep{color:var(--border-2)} + +@media(max-width:768px){ + .layout{grid-template-columns:1fr} + .sidebar{position:static;height:auto;border-right:none;border-bottom:1px solid var(--border-1)} +} +@media print{ + .sidebar{display:none} + header{position:static} + body{background:#fff;color:#1a1a1a} + :root{--bg-0:#fff;--bg-1:#fafafa;--bg-2:#f5f5f5;--bg-3:#ebebeb;--border-1:#e5e5e5;--border-2:#d4d4d4;--text-0:#1a1a1a;--text-1:#525252;--text-2:#a3a3a3;--accent:#4f46e5} +} +`; diff --git a/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md new file mode 100644 index 000000000..cbb6ec23c --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md @@ -0,0 +1,178 @@ +--- +name: gsd-headless +description: Orchestrate GSD (Get Shit Done) projects programmatically via headless CLI. Use when an agent needs to create milestones from specs, execute software development workflows, monitor task progress, check project status, or control GSD execution (pause/stop/skip/steer). Triggers on requests to "run gsd", "create milestone", "execute project", "check gsd status", "orchestrate development", "run headless workflow", or any programmatic interaction with the GSD project management system. Essential for building orchestrators that coordinate multiple GSD workers. +--- + +# GSD Headless Orchestration + +Run GSD commands without TUI via `gsd headless`. Spawns an RPC child process, auto-responds to UI prompts, streams progress. + +## Command Syntax + +```bash +gsd headless [flags] [command] [args...] +``` + +**Flags:** `--timeout N` (ms, default 300000), `--json` (JSONL to stdout), `--model ID`, `--verbose` +**Exit codes:** 0=complete, 1=error/timeout, 2=blocked + +## Core Workflows + +### 1. Create + Execute a Milestone (end-to-end) + +```bash +gsd headless new-milestone --context spec.md --auto +``` + +Reads spec, bootstraps `.gsd/`, creates milestone, then chains into auto-mode executing all phases (discuss → research → plan → execute → summarize → complete). + +Extra flags for `new-milestone`: `--context ` (use `-` for stdin), `--context-text `, `--auto`. + +### 2. Run All Queued Work + +```bash +gsd headless auto +``` + +Default command. Loops through all pending units until milestone complete or blocked. + +### 3. Run One Unit + +```bash +gsd headless next +``` + +Execute exactly one unit (task/slice/milestone step), then exit. Ideal for step-by-step orchestration with external decision logic between steps. + +### 4. Check Status + +```bash +gsd headless --json status +``` + +Returns project state: active milestone/slice/task, phase, progress counts, blockers. Parse the JSONL output for machine-readable state. + +### 5. Dispatch Specific Phase + +```bash +gsd headless dispatch research|plan|execute|complete|reassess|uat|replan +``` + +Force-route to a specific phase, bypassing normal state-machine routing. + +## Orchestrator Patterns + +### Poll-and-React Loop + +```bash +# Check status, decide what to do +STATUS=$(gsd headless --json status 2>/dev/null) +EXIT=$? + +case $EXIT in + 0) echo "Complete" ;; + 2) echo "Blocked — needs intervention" ;; + *) echo "Error" ;; +esac +``` + +### Step-by-Step with Monitoring + +```bash +while true; do + gsd headless next + EXIT=$? + [ $EXIT -ne 0 ] && break + # Check progress, log, decide whether to continue + gsd headless --json status +done +``` + +### Multi-Session Orchestration + +GSD tracks concurrent workers via file-based IPC in `.gsd/parallel/`. See [references/multi-session.md](references/multi-session.md) for the full architecture. + +**Quick overview:** + +Each worker spawns with `GSD_MILESTONE_LOCK=M00X` + its own git worktree. Workers write heartbeats to `.gsd/parallel/.status.json`. The orchestrator enumerates all status files to get a dashboard of all workers, and sends commands via signal files. + +```bash +# Spawn a worker for milestone M001 in its worktree +GSD_MILESTONE_LOCK=M001 GSD_PARALLEL_WORKER=1 \ + gsd headless --json auto \ + --cwd .gsd/worktrees/M001 2>worker-M001.log & + +# Monitor all workers: read .gsd/parallel/*.status.json +for f in .gsd/parallel/*.status.json; do + jq '{mid: .milestoneId, state: .state, unit: .currentUnit.id, cost: .cost}' "$f" +done + +# Send pause signal to M001 +echo '{"signal":"pause","sentAt":'$(date +%s000)',"from":"coordinator"}' \ + > .gsd/parallel/M001.signal.json +``` + +**Status file fields:** `milestoneId`, `pid`, `state` (running/paused/stopped/error), `currentUnit`, `completedUnits`, `cost`, `lastHeartbeat`, `startedAt`, `worktreePath`. + +**Signal commands:** `pause`, `resume`, `stop`, `rebase`. + +**Liveness detection:** PID alive check (`kill -0 $pid`) + heartbeat freshness (30s timeout). Stale sessions are auto-cleaned. + +**For multiple projects:** each project has its own `.gsd/` directory. The orchestrator must track `(projectPath, milestoneId)` tuples externally. + +### JSONL Event Stream + +Use `--json` to get real-time events on stdout for downstream processing: + +```bash +gsd headless --json auto 2>/dev/null | while read -r line; do + TYPE=$(echo "$line" | jq -r '.type') + case "$TYPE" in + tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;; + extension_ui_request) echo "GSD: $(echo "$line" | jq -r '.message // .title // empty')" ;; + agent_end) echo "Session ended" ;; + esac +done +``` + +Event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `extension_ui_request`, `message_update`, `error`. + +## Answer Injection + +Pre-supply answers for non-interactive runs. See [references/answer-injection.md](references/answer-injection.md) for schema and usage. + +## GSD Project Structure + +All state lives in `.gsd/` as markdown files (version-controllable): + +``` +.gsd/ + milestones/M001/ + M001-CONTEXT.md # Requirements, scope, decisions + M001-ROADMAP.md # Slices with tasks, dependencies, checkboxes + M001-SUMMARY.md # Completion summary + slices/S01/ + S01-PLAN.md # Task list + S01-SUMMARY.md # Slice summary with frontmatter + tasks/T01-PLAN.md # Individual task spec +``` + +State is derived from files on disk — checkboxes in ROADMAP.md are the source of truth for completion. + +## All Headless Commands + +Quick reference — see [references/commands.md](references/commands.md) for the complete list. + +| Command | Purpose | +|---------|---------| +| `auto` | Run all queued units (default) | +| `next` | Run one unit | +| `status` | Progress dashboard | +| `new-milestone` | Create milestone from spec | +| `queue` | Queue/reorder milestones | +| `history` | View execution history | +| `stop` / `pause` | Control auto-mode | +| `dispatch ` | Force specific phase | +| `skip` / `undo` | Unit control | +| `doctor` | Health check + auto-fix | +| `steer ` | Hard-steer plan mid-execution | diff --git a/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md b/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md new file mode 100644 index 000000000..ecf21f87f --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md @@ -0,0 +1,54 @@ +# Answer Injection + +Pre-supply answers to eliminate interactive prompts during headless execution. + +## Answer File Schema + +```json +{ + "questions": { + "question_id": "selected_option_label", + "multi_select_question": ["option_a", "option_b"] + }, + "secrets": { + "API_KEY": "sk-...", + "DATABASE_URL": "postgres://..." + }, + "defaults": { + "strategy": "first_option" + } +} +``` + +### Fields + +- **questions**: Map question ID → answer. String for single-select, string[] for multi-select. +- **secrets**: Map env var name → value. Used for `secure_env_collect` tool calls. Values are never logged. +- **defaults.strategy**: Fallback for unmatched questions. + - `"first_option"` — auto-select first available option + - `"cancel"` — cancel the request + +## How It Works + +Two-phase correlation: +1. **Observe** `tool_execution_start` events for `ask_user_questions` — extracts question metadata (ID, options, allowMultiple) +2. **Match** subsequent `extension_ui_request` events to metadata, respond with pre-supplied answer + +Handles out-of-order events (extension_ui_request can arrive before tool_execution_start in RPC mode) via deferred processing queue. + +## Without Answer Injection + +Headless mode has built-in auto-responders: +- **select** → picks first option +- **confirm** → auto-confirms +- **input** → empty string +- **editor** → returns prefill or empty + +Answer injection overrides these defaults with specific answers when precision matters. + +## Diagnostics + +The injector tracks stats: +- `questionsAnswered` / `questionsDefaulted` +- `secretsProvided` / `secretsMissing` +- `fireAndForgetConsumed` / `confirmationsHandled` diff --git a/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md b/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md new file mode 100644 index 000000000..ac1bf4d00 --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md @@ -0,0 +1,59 @@ +# GSD Commands Reference + +All commands can be run via `gsd headless [command]`. + +## Workflow Commands + +| Command | Description | +|---------|-------------| +| `auto` | Autonomous mode — loop until milestone complete (default) | +| `next` | Step mode — execute one unit, then exit | +| `stop` | Stop auto-mode gracefully | +| `pause` | Pause auto-mode (preserves state, resumable) | +| `new-milestone` | Create milestone from specification (requires `--context`) | +| `dispatch ` | Force-dispatch: research, plan, execute, complete, reassess, uat, replan | + +## Status & Monitoring + +| Command | Description | +|---------|-------------| +| `status` | Progress dashboard (active unit, phase, blockers) | +| `visualize` | Workflow visualizer (deps, metrics, timeline) | +| `history` | Execution history (supports --cost, --phase, --model, limit) | + +## Unit Control + +| Command | Description | +|---------|-------------| +| `skip` | Prevent a unit from auto-mode dispatch | +| `undo` | Revert last completed unit (--force flag) | +| `steer ` | Hard-steer plan documents during execution | +| `queue` | Queue and reorder future milestones | +| `capture` | Fire-and-forget thought capture | +| `triage` | Manually trigger triage of pending captures | + +## Configuration & Health + +| Command | Description | +|---------|-------------| +| `prefs` | Manage preferences (global/project/status/wizard/setup) | +| `config` | Set API keys for external tools | +| `doctor` | Runtime health checks with auto-fix | +| `hooks` | Show configured post-unit and pre-dispatch hooks | +| `knowledge ` | Add persistent project knowledge | +| `cleanup` | Remove merged branches or snapshots | +| `export` | Export results (--json, --markdown) | +| `migrate` | Migrate v1 .planning directory to .gsd format | + +## Phases + +GSD workflows progress through these phases: +`pre-planning` → `needs-discussion` → `discussing` → `researching` → `planning` → `executing` → `verifying` → `summarizing` → `advancing` → `validating-milestone` → `completing-milestone` → `complete` + +Special phases: `paused`, `blocked`, `replanning-slice` + +## Hierarchy + +- **Milestone**: Shippable version (4-10 slices, 1-4 weeks) +- **Slice**: One demoable vertical capability (1-7 tasks, 1-3 days) +- **Task**: One context-window-sized unit of work (one session) diff --git a/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md b/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md new file mode 100644 index 000000000..ff24a9461 --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md @@ -0,0 +1,185 @@ +# Multi-Session Orchestration + +How to run and monitor multiple concurrent GSD sessions. + +## Architecture + +GSD uses **file-based IPC** — no sockets or ports. All coordination happens through JSON files in `.gsd/parallel/`. + +``` +.gsd/parallel/ +├── M001.status.json # Worker heartbeat + state +├── M001.signal.json # Coordinator → worker commands (ephemeral) +├── M002.status.json +├── M003.status.json +└── ... +``` + +## Worker Isolation + +Each worker gets: +1. **`GSD_MILESTONE_LOCK=M00X`** — state derivation only sees this milestone +2. **`GSD_PARALLEL_WORKER=1`** — prevents nested parallel spawns +3. **Own git worktree** at `.gsd/worktrees/M00X/` — branch `milestone/M00X` + +Workers cannot interfere with each other. Each has its own filesystem and git branch. + +## Status File Schema + +Written atomically (`.tmp` + rename) by each worker at `.gsd/parallel/.status.json`: + +```json +{ + "milestoneId": "M001", + "pid": 12345, + "state": "running", + "currentUnit": { + "type": "task", + "id": "T03", + "startedAt": 1710000000000 + }, + "completedUnits": 7, + "cost": 1.23, + "lastHeartbeat": 1710000015000, + "startedAt": 1710000000000, + "worktreePath": ".gsd/worktrees/M001" +} +``` + +**States:** `running`, `paused`, `stopped`, `error` + +## Signal Files + +Coordinator writes to `.gsd/parallel/.signal.json`. Worker consumes and deletes on next dispatch cycle. + +```json +{ + "signal": "pause", + "sentAt": 1710000020000, + "from": "coordinator" +} +``` + +**Signals:** `pause`, `resume`, `stop`, `rebase` + +## Spawning Workers + +```bash +# Spawn worker in its worktree +GSD_MILESTONE_LOCK=M001 \ +GSD_PARALLEL_WORKER=1 \ +GSD_BIN_PATH=$(which gsd) \ + gsd --mode json --print "/gsd auto" \ + 2>logs/M001.log & +WORKER_PID=$! +``` + +Workers emit NDJSON on stdout. Parse `message_end` events for cost tracking: +```bash +# Extract cost from worker output +gsd --mode json --print "/gsd auto" | while read -r line; do + COST=$(echo "$line" | jq -r 'select(.type=="message_end") | .message.usage.cost.total // empty') + [ -n "$COST" ] && echo "Cost update: $COST" +done +``` + +## Monitoring All Workers + +```bash +# Dashboard: enumerate all status files +for f in .gsd/parallel/*.status.json; do + [ -f "$f" ] || continue + jq -r '[.milestoneId, .state, (.currentUnit.id // "idle"), "\(.cost | tostring)$"] | join("\t")' "$f" +done + +# Liveness check +for f in .gsd/parallel/*.status.json; do + PID=$(jq -r '.pid' "$f") + MID=$(jq -r '.milestoneId' "$f") + if kill -0 "$PID" 2>/dev/null; then + echo "$MID: alive (pid=$PID)" + else + echo "$MID: DEAD (pid=$PID) — cleanup needed" + rm "$f" + fi +done +``` + +## Sending Commands + +```bash +# Pause a worker +send_signal() { + local MID=$1 SIGNAL=$2 + echo "{\"signal\":\"$SIGNAL\",\"sentAt\":$(date +%s000),\"from\":\"coordinator\"}" \ + > ".gsd/parallel/${MID}.signal.json" +} + +send_signal M001 pause +send_signal M002 stop +send_signal M003 resume +``` + +## Budget Enforcement + +Track aggregate cost across all workers: +```bash +TOTAL=$(jq -s 'map(.cost) | add // 0' .gsd/parallel/*.status.json) +CEILING=50.00 +if (( $(echo "$TOTAL > $CEILING" | bc -l) )); then + echo "Budget exceeded ($TOTAL > $CEILING) — stopping all" + for f in .gsd/parallel/*.status.json; do + MID=$(jq -r '.milestoneId' "$f") + send_signal "$MID" stop + done +fi +``` + +## Stale Session Cleanup + +A session is stale when: +- PID is dead (`kill -0 $pid` fails), OR +- `lastHeartbeat` is older than 30 seconds + +```bash +NOW=$(date +%s000) +STALE_THRESHOLD=30000 +for f in .gsd/parallel/*.status.json; do + PID=$(jq -r '.pid' "$f") + HB=$(jq -r '.lastHeartbeat' "$f") + AGE=$((NOW - HB)) + if ! kill -0 "$PID" 2>/dev/null || [ "$AGE" -gt "$STALE_THRESHOLD" ]; then + echo "Stale: $(jq -r '.milestoneId' "$f") — removing" + rm "$f" + fi +done +``` + +## Multi-Project Orchestration + +Within one project, milestones are tracked automatically in `.gsd/parallel/`. For orchestrating across **multiple projects**, maintain an external registry: + +```json +{ + "sessions": [ + { "project": "/path/to/project-a", "milestoneId": "M001" }, + { "project": "/path/to/project-b", "milestoneId": "M001" }, + { "project": "/path/to/project-b", "milestoneId": "M002" } + ] +} +``` + +Then poll each project's `.gsd/parallel/` directory. GSD has no cross-project awareness — the orchestrator must bridge this gap. + +## Built-in Parallel Commands + +Inside an interactive GSD session, these commands manage the parallel orchestrator: + +| Command | Description | +|---------|-------------| +| `/gsd parallel start` | Analyze eligibility, spawn workers | +| `/gsd parallel status` | Show all workers, costs, progress | +| `/gsd parallel stop [MID]` | Stop one or all workers | +| `/gsd parallel pause [MID]` | Pause without killing | +| `/gsd parallel resume [MID]` | Resume paused worker | +| `/gsd parallel merge [MID]` | Merge completed milestone branch | diff --git a/src/resources/extensions/gsd/tests/continue-here.test.ts b/src/resources/extensions/gsd/tests/continue-here.test.ts index 6edcbfde1..eb31e084f 100644 --- a/src/resources/extensions/gsd/tests/continue-here.test.ts +++ b/src/resources/extensions/gsd/tests/continue-here.test.ts @@ -201,4 +201,85 @@ describe("continue-here", () => { } }); }); + + describe("context-pressure monitor integration", () => { + it("should fire wrap-up when context >= threshold and mark continueHereFired", async () => { + const { writeUnitRuntimeRecord, readUnitRuntimeRecord, clearUnitRuntimeRecord } = await import("../unit-runtime.js"); + const fs = await import("node:fs"); + const path = await import("node:path"); + const os = await import("node:os"); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-here-monitor-")); + try { + // Simulate the monitor's one-shot logic: + // 1. Write initial runtime record (continueHereFired=false) + const startedAt = Date.now(); + writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, { + phase: "dispatched", + wrapupWarningSent: false, + }); + + const budget = computeBudgets(128_000); + const threshold = budget.continueThresholdPercent; + + // Simulate the monitor poll: context at 75% (above threshold) + const contextPercent = 75; + const runtime = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); + assert.ok(runtime, "runtime record should exist"); + assert.equal(runtime!.continueHereFired, false, "initially false"); + + // Check: should fire + const shouldFire = !runtime!.continueHereFired + && contextPercent >= threshold; + assert.ok(shouldFire, "should fire when context >= threshold and not yet fired"); + + // Mark as fired (what the monitor does) + writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, { + continueHereFired: true, + }); + + // Verify one-shot: second poll should NOT fire + const runtime2 = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); + assert.ok(runtime2, "runtime record should still exist"); + assert.equal(runtime2!.continueHereFired, true, "should be marked as fired"); + + const shouldFireAgain = !runtime2!.continueHereFired + && contextPercent >= threshold; + assert.equal(shouldFireAgain, false, "must not fire again — one-shot guard"); + + // Clean up + clearUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("should not fire when context is below threshold", () => { + const budget = computeBudgets(200_000); + const threshold = budget.continueThresholdPercent; + + // Simulate monitor poll with context at 50% + const contextPercent = 50; + const continueHereFired = false; + const shouldFire = !continueHereFired && contextPercent >= threshold; + assert.equal(shouldFire, false, "50% should not trigger continue-here"); + }); + + it("should not fire when contextUsage is null/undefined", () => { + const budget = computeBudgets(128_000); + const threshold = budget.continueThresholdPercent; + + // Simulate the full guard chain from the monitor + const usageUndefined = undefined as { percent: number | null } | undefined; + const shouldFire1 = usageUndefined != null + && usageUndefined.percent != null + && usageUndefined.percent >= threshold; + assert.equal(shouldFire1, false, "undefined usage must not fire"); + + const usageNullPercent: { percent: number | null } = { percent: null }; + const shouldFire2 = usageNullPercent.percent != null + && usageNullPercent.percent >= threshold; + assert.equal(shouldFire2, false, "null percent must not fire"); + }); + }); }); diff --git a/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts b/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts new file mode 100644 index 000000000..1c92b64a0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts @@ -0,0 +1,132 @@ +/** + * Regression test for issue #909. + * + * When S##-PLAN.md exists (causing deriveState → phase:'executing') but the + * individual task plan files (tasks/T01-PLAN.md, etc.) are absent, the dispatch + * table must recover by re-running plan-slice — NOT hard-stop. + * + * Prior behaviour: action:"stop" → infinite loop on restart. + * Fixed behaviour: action:"dispatch" unitType:"plan-slice". + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolveDispatch } from "../auto-dispatch.ts"; +import type { DispatchContext } from "../auto-dispatch.ts"; +import type { GSDState } from "../types.ts"; + +function makeState(overrides: Partial = {}): GSDState { + return { + activeMilestone: { id: "M002", title: "Test Milestone" }, + activeSlice: { id: "S03", title: "Third Slice" }, + activeTask: { id: "T01", title: "First Task" }, + phase: "executing", + recentDecisions: [], + blockers: [], + nextAction: "", + registry: [], + ...overrides, + }; +} + +function makeContext(basePath: string, stateOverrides?: Partial): DispatchContext { + return { + basePath, + mid: "M002", + midTitle: "Test Milestone", + state: makeState(stateOverrides), + prefs: undefined, + }; +} + +// ─── Scaffold helpers ────────────────────────────────────────────────────── + +function scaffoldSlicePlan(basePath: string, mid: string, sid: string): void { + const dir = join(basePath, ".gsd", "milestones", mid, "slices", sid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}-PLAN.md`), [ + `# ${sid}: Third Slice`, + "", + "## Tasks", + "- [ ] **T01: Do something** `est:1h`", + "- [ ] **T02: Do another thing** `est:30m`", + "", + ].join("\n")); +} + +function scaffoldTaskPlan(basePath: string, mid: string, sid: string, tid: string): void { + const dir = join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${tid}-PLAN.md`), [ + `# ${tid}: Do something`, + "", + "## Steps", + "- [ ] Step 1", + "", + ].join("\n")); +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async () => { + const tmp = mkdtempSync(join(tmpdir(), "gsd-909-")); + try { + // Slice plan exists with tasks, but tasks/ directory is empty + scaffoldSlicePlan(tmp, "M002", "S03"); + + const ctx = makeContext(tmp); + const result = await resolveDispatch(ctx); + + assert.equal(result.action, "dispatch", "should dispatch, not stop"); + assert.ok(result.action === "dispatch" && result.unitType === "plan-slice", + `unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`); + assert.ok(result.action === "dispatch" && result.unitId === "M002/S03", + `unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: present task plan proceeds to execute-task normally", async () => { + const tmp = mkdtempSync(join(tmpdir(), "gsd-909-ok-")); + try { + scaffoldSlicePlan(tmp, "M002", "S03"); + scaffoldTaskPlan(tmp, "M002", "S03", "T01"); + + const ctx = makeContext(tmp); + const result = await resolveDispatch(ctx); + + assert.equal(result.action, "dispatch"); + assert.ok(result.action === "dispatch" && result.unitType === "execute-task", + `unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`); + assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01", + `unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async () => { + // Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write). + // Dispatch should still re-dispatch plan-slice, not hard-stop. + const tmp = mkdtempSync(join(tmpdir(), "gsd-909-loop-")); + try { + scaffoldSlicePlan(tmp, "M002", "S03"); + + const ctx = makeContext(tmp); + const r1 = await resolveDispatch(ctx); + assert.equal(r1.action, "dispatch"); + assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice"); + + // Still no task plan written — dispatch again + const r2 = await resolveDispatch(ctx); + assert.equal(r2.action, "dispatch"); + assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice", + "should keep dispatching plan-slice until task plans appear"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/tests/doctor-proactive.test.ts b/src/resources/extensions/gsd/tests/doctor-proactive.test.ts index 4e4d86bb8..0bbbf2a83 100644 --- a/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-proactive.test.ts @@ -193,6 +193,20 @@ async function main(): Promise { assertEq(result.issues.length, 0, "no issues on clean state"); } + console.log("\n=== health gate: missing STATE.md does NOT block dispatch (#889) ==="); + { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-"))); + cleanups.push(dir); + // Create milestones dir but no STATE.md — mimics fresh worktree + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(dir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap\n"); + + const result = await preDispatchHealthGate(dir); + assertTrue(result.proceed, "gate must NOT block when STATE.md is missing (deadlock #889)"); + assertEq(result.issues.length, 0, "missing STATE.md is not a blocking issue"); + assertTrue(result.fixesApplied.some((f: string) => f.includes("STATE.md")), "reports STATE.md status as info"); + } + console.log("\n=== health gate: stale crash lock auto-cleared ==="); { const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-"))); diff --git a/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts b/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts new file mode 100644 index 000000000..9e38c7262 --- /dev/null +++ b/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts @@ -0,0 +1,298 @@ +/** + * Tests for parallel orchestrator crash recovery. + * + * Validates that orchestrator state is persisted to disk and can be + * restored after a coordinator crash, with PID liveness filtering. + */ + +import { + mkdtempSync, + mkdirSync, + readFileSync, + writeFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + persistState, + restoreState, + resetOrchestrator, + getOrchestratorState, + type PersistedState, +} from "../parallel-orchestrator.ts"; +import { writeSessionStatus, readAllSessionStatuses, removeSessionStatus } from "../session-status-io.ts"; +import { createTestContext } from './test-helpers.ts'; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "gsd-crash-recovery-")); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + return dir; +} + +function stateFilePath(basePath: string): string { + return join(basePath, ".gsd", "orchestrator.json"); +} + +function writeStateFile(basePath: string, state: PersistedState): void { + writeFileSync(stateFilePath(basePath), JSON.stringify(state, null, 2), "utf-8"); +} + +function makePersistedState(overrides: Partial = {}): PersistedState { + return { + active: true, + workers: [], + totalCost: 0, + startedAt: Date.now(), + configSnapshot: { max_workers: 3 }, + ...overrides, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +// Test 1: persistState writes valid JSON +{ + const basePath = makeTempDir(); + try { + // We can't call persistState directly without internal state set up, + // so we test the round-trip by writing a state file and reading it back + const state = makePersistedState({ + workers: [ + { + milestoneId: "M001", + title: "M001", + pid: process.pid, + worktreePath: "/tmp/wt-M001", + startedAt: Date.now(), + state: "running", + completedUnits: 3, + cost: 0.15, + }, + ], + totalCost: 0.15, + }); + writeStateFile(basePath, state); + + const raw = readFileSync(stateFilePath(basePath), "utf-8"); + const parsed = JSON.parse(raw) as PersistedState; + assertEq(parsed.active, true, "persistState: active field preserved"); + assertEq(parsed.workers.length, 1, "persistState: worker count preserved"); + assertEq(parsed.workers[0].milestoneId, "M001", "persistState: milestoneId preserved"); + assertEq(parsed.workers[0].cost, 0.15, "persistState: cost preserved"); + assertEq(parsed.totalCost, 0.15, "persistState: totalCost preserved"); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +} + +// Test 2: restoreState returns null for missing file +{ + const basePath = makeTempDir(); + try { + const result = restoreState(basePath); + assertEq(result, null, "restoreState: returns null when no state file"); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +} + +// Test 3: restoreState filters dead PIDs +{ + const basePath = makeTempDir(); + try { + // PID 99999999 is almost certainly not alive + const state = makePersistedState({ + workers: [ + { + milestoneId: "M001", + title: "M001", + pid: 99999999, + worktreePath: "/tmp/wt-M001", + startedAt: Date.now(), + state: "running", + completedUnits: 0, + cost: 0, + }, + { + milestoneId: "M002", + title: "M002", + pid: 99999998, + worktreePath: "/tmp/wt-M002", + startedAt: Date.now(), + state: "running", + completedUnits: 0, + cost: 0, + }, + ], + }); + writeStateFile(basePath, state); + + const result = restoreState(basePath); + // Both PIDs are dead, so result should be null and file should be cleaned up + assertEq(result, null, "restoreState: returns null when all PIDs dead"); + assertTrue(!existsSync(stateFilePath(basePath)), "restoreState: cleans up state file when all dead"); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +} + +// Test 4: restoreState keeps alive PIDs +{ + const basePath = makeTempDir(); + try { + // Use current process PID (definitely alive) + const state = makePersistedState({ + workers: [ + { + milestoneId: "M001", + title: "M001", + pid: process.pid, + worktreePath: "/tmp/wt-M001", + startedAt: Date.now(), + state: "running", + completedUnits: 5, + cost: 0.25, + }, + { + milestoneId: "M002", + title: "M002", + pid: 99999999, // dead + worktreePath: "/tmp/wt-M002", + startedAt: Date.now(), + state: "running", + completedUnits: 0, + cost: 0, + }, + ], + totalCost: 0.25, + }); + writeStateFile(basePath, state); + + const result = restoreState(basePath); + assertTrue(result !== null, "restoreState: returns state when alive PID exists"); + assertEq(result!.workers.length, 1, "restoreState: filters out dead PID"); + assertEq(result!.workers[0].milestoneId, "M001", "restoreState: keeps alive worker"); + assertEq(result!.workers[0].pid, process.pid, "restoreState: preserves PID"); + assertEq(result!.workers[0].completedUnits, 5, "restoreState: preserves progress"); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +} + +// Test 5: restoreState skips stopped/error workers even with alive PIDs +{ + const basePath = makeTempDir(); + try { + const state = makePersistedState({ + workers: [ + { + milestoneId: "M001", + title: "M001", + pid: process.pid, + worktreePath: "/tmp/wt-M001", + startedAt: Date.now(), + state: "stopped", + completedUnits: 10, + cost: 0.50, + }, + ], + }); + writeStateFile(basePath, state); + + const result = restoreState(basePath); + assertEq(result, null, "restoreState: skips stopped workers"); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +} + +// Test 6: orphan detection finds stale sessions +{ + const basePath = makeTempDir(); + try { + // Write a session status with a dead PID + mkdirSync(join(basePath, ".gsd", "parallel"), { recursive: true }); + writeSessionStatus(basePath, { + milestoneId: "M001", + pid: 99999999, + state: "running", + currentUnit: null, + completedUnits: 3, + cost: 0.10, + lastHeartbeat: Date.now(), + startedAt: Date.now(), + worktreePath: "/tmp/wt-M001", + }); + + // Write a session status with alive PID + writeSessionStatus(basePath, { + milestoneId: "M002", + pid: process.pid, + state: "running", + currentUnit: null, + completedUnits: 1, + cost: 0.05, + lastHeartbeat: Date.now(), + startedAt: Date.now(), + worktreePath: "/tmp/wt-M002", + }); + + // Read all sessions — both should exist initially + const before = readAllSessionStatuses(basePath); + assertEq(before.length, 2, "orphan: both sessions exist before detection"); + + // Now simulate orphan detection logic (same as prepareParallelStart) + const sessions = readAllSessionStatuses(basePath); + const orphans: Array<{ milestoneId: string; pid: number; alive: boolean }> = []; + for (const session of sessions) { + let alive: boolean; + try { + process.kill(session.pid, 0); + alive = true; + } catch { + alive = false; + } + orphans.push({ milestoneId: session.milestoneId, pid: session.pid, alive }); + if (!alive) { + removeSessionStatus(basePath, session.milestoneId); + } + } + + assertTrue(orphans.length === 2, "orphan: detected both sessions"); + const deadOrphan = orphans.find(o => o.milestoneId === "M001"); + assertTrue(deadOrphan !== undefined && !deadOrphan.alive, "orphan: M001 detected as dead"); + const aliveOrphan = orphans.find(o => o.milestoneId === "M002"); + assertTrue(aliveOrphan !== undefined && aliveOrphan.alive, "orphan: M002 detected as alive"); + + // Dead session should be cleaned up + const after = readAllSessionStatuses(basePath); + assertEq(after.length, 1, "orphan: dead session cleaned up"); + assertEq(after[0].milestoneId, "M002", "orphan: alive session remains"); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +} + +// Test 7: restoreState handles corrupt JSON gracefully +{ + const basePath = makeTempDir(); + try { + writeFileSync(stateFilePath(basePath), "{ not valid json !!!", "utf-8"); + const result = restoreState(basePath); + assertEq(result, null, "restoreState: returns null for corrupt JSON"); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +} + +// Clean up module state +resetOrchestrator(); + +report(); diff --git a/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts b/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts index 7cf7b80be..9be5bbe48 100644 --- a/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +++ b/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts @@ -35,6 +35,7 @@ import { getWorkerStatuses, startParallel, stopParallel, + shutdownParallel, pauseWorker, resumeWorker, getAggregateCost, @@ -338,6 +339,14 @@ describe("parallel-orchestrator: lifecycle", () => { assert.ok(signal); assert.equal(signal.signal, "pause"); }); + + it("shutdownParallel deactivates the orchestrator state", async () => { + await startParallel(base, ["M001"], undefined); + assert.equal(isParallelActive(), true); + await shutdownParallel(base); + assert.equal(isParallelActive(), false); + assert.equal(getOrchestratorState(), null); + }); }); describe("parallel-orchestrator: budget", () => { diff --git a/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts b/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts new file mode 100644 index 000000000..9cdb0dbd9 --- /dev/null +++ b/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTestContext } from './test-helpers.ts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const worktreePromptsDir = join(__dirname, "..", "prompts"); + +const { assertTrue, report } = createTestContext(); + +function loadPromptFromWorktree(name: string, vars: Record = {}): string { + const path = join(worktreePromptsDir, `${name}.md`); + let content = readFileSync(path, "utf-8"); + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(`{{${key}}}`, value); + } + return content.trim(); +} + +const BASE_VARS = { + workingDirectory: "/tmp/test-project", + milestoneId: "M001", + sliceId: "S01", + sliceTitle: "Test Slice", + slicePath: ".gsd/milestones/M001/slices/S01", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + researchPath: ".gsd/milestones/M001/slices/S01/S01-RESEARCH.md", + outputPath: "/tmp/test-project/.gsd/milestones/M001/slices/S01/S01-PLAN.md", + inlinedContext: "--- test inlined context ---", + dependencySummaries: "", + executorContextConstraints: "", +}; + +async function main(): Promise { + + // ─── commit_docs=true (default): commit step is present ───────────────── + console.log("\n=== plan-slice prompt: commit_docs default (true) ==="); + { + const commitInstruction = `Commit: \`docs(S01): add slice plan\``; + const result = loadPromptFromWorktree("plan-slice", { ...BASE_VARS, commitInstruction }); + + assertTrue(result.includes("docs(S01): add slice plan"), "commit step present when commit_docs is not false"); + assertTrue(result.includes("Update `.gsd/STATE.md`"), "STATE.md update step present"); + assertTrue(!result.includes("{{commitInstruction}}"), "no unresolved placeholder"); + } + + // ─── commit_docs=false: no commit step, only STATE.md update ──────────── + console.log("\n=== plan-slice prompt: commit_docs=false ==="); + { + const commitInstruction = "Do not commit — planning docs are not tracked in git for this project."; + const result = loadPromptFromWorktree("plan-slice", { ...BASE_VARS, commitInstruction }); + + assertTrue(!result.includes("docs(S01): add slice plan"), "commit step absent when commit_docs=false"); + assertTrue(result.includes("Do not commit"), "no-commit instruction present"); + assertTrue(result.includes("Update `.gsd/STATE.md`"), "STATE.md update step still present"); + assertTrue(!result.includes("{{commitInstruction}}"), "no unresolved placeholder"); + } + + // ─── all base variables are substituted ───────────────────────────────── + console.log("\n=== plan-slice prompt: all variables substituted ==="); + { + const commitInstruction = `Commit: \`docs(S01): add slice plan\``; + const result = loadPromptFromWorktree("plan-slice", { ...BASE_VARS, commitInstruction }); + + assertTrue(!result.includes("{{"), "no unresolved placeholders remain"); + assertTrue(result.includes("M001"), "milestoneId substituted"); + assertTrue(result.includes("S01"), "sliceId substituted"); + } +} + +main().then(report); diff --git a/src/resources/extensions/gsd/tests/replan-slice.test.ts b/src/resources/extensions/gsd/tests/replan-slice.test.ts index d682a2b20..9d98afed0 100644 --- a/src/resources/extensions/gsd/tests/replan-slice.test.ts +++ b/src/resources/extensions/gsd/tests/replan-slice.test.ts @@ -493,4 +493,45 @@ console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue rmSync(base, { recursive: true, force: true }); } +// ═══════════════════════════════════════════════════════════════════════════ +// Artifact Resolution: resolveExpectedArtifactPath for replan-slice (#858) +// ═══════════════════════════════════════════════════════════════════════════ + +import { resolveExpectedArtifactPath, verifyExpectedArtifact } from '../auto-recovery.ts'; + +console.log('\n=== artifact: resolveExpectedArtifactPath returns REPLAN.md path for replan-slice ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + + const path = resolveExpectedArtifactPath('replan-slice', 'M001/S01', base); + assertTrue(path !== null, 'resolveExpectedArtifactPath returns non-null for replan-slice'); + assertTrue(path!.endsWith('S01-REPLAN.md'), 'path ends with S01-REPLAN.md'); + rmSync(base, { recursive: true, force: true }); +} + +console.log('\n=== artifact: verifyExpectedArtifact fails when REPLAN.md missing (#858) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + + const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base); + assertEq(result, false, 'verifyExpectedArtifact returns false when REPLAN.md is missing'); + rmSync(base, { recursive: true, force: true }); +} + +console.log('\n=== artifact: verifyExpectedArtifact passes when REPLAN.md exists (#858) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeReplanFile(base, 'M001', 'S01', '# Replan\n\nBlocker addressed.'); + + const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base); + assertEq(result, true, 'verifyExpectedArtifact returns true when REPLAN.md exists'); + rmSync(base, { recursive: true, force: true }); +} + report(); diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index 943154fb0..774051028 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -12,7 +12,7 @@ * Uses JSON mode to capture structured output from subagents. */ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; @@ -38,6 +38,44 @@ import { registerWorker, updateWorker } from "./worker-registry.js"; const MAX_PARALLEL_TASKS = 8; const MAX_CONCURRENCY = 4; const COLLAPSED_ITEM_COUNT = 10; +const liveSubagentProcesses = new Set(); + +async function stopLiveSubagents(): Promise { + const active = Array.from(liveSubagentProcesses); + if (active.length === 0) return; + + for (const proc of active) { + try { + proc.kill("SIGTERM"); + } catch { + /* ignore */ + } + } + + await Promise.all( + active.map( + (proc) => + new Promise((resolve) => { + const done = () => resolve(); + const timer = setTimeout(done, 500); + proc.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }), + ), + ); + + for (const proc of active) { + if (proc.exitCode === null) { + try { + proc.kill("SIGKILL"); + } catch { + /* ignore */ + } + } + } +} function formatTokens(count: number): string { if (count < 1000) return count.toString(); @@ -302,6 +340,7 @@ async function runSingleAgent( [process.env.GSD_BIN_PATH!, ...extensionArgs, ...args], { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }, ); + liveSubagentProcesses.add(proc); let buffer = ""; const processLine = (line: string) => { @@ -353,11 +392,13 @@ async function runSingleAgent( }); proc.on("close", (code) => { + liveSubagentProcesses.delete(proc); if (buffer.trim()) processLine(buffer); resolve(code ?? 0); }); proc.on("error", () => { + liveSubagentProcesses.delete(proc); resolve(1); }); @@ -432,6 +473,10 @@ const SubagentParams = Type.Object({ }); export default function (pi: ExtensionAPI) { + pi.on("session_shutdown", async () => { + await stopLiveSubagents(); + }); + // /subagent command - list available agents pi.registerCommand("subagent", { description: "List available subagents", diff --git a/src/tests/bg-shell-session-cleanup.test.ts b/src/tests/bg-shell-session-cleanup.test.ts new file mode 100644 index 000000000..6ac74f7f1 --- /dev/null +++ b/src/tests/bg-shell-session-cleanup.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + startProcess, + cleanupAll, + cleanupSessionProcesses, + processes, +} from "../resources/extensions/bg-shell/process-manager.ts"; + +function isPidAlive(pid: number | undefined): boolean { + if (!pid || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// Use a shell-native sleeper so the test exercises bg_shell's real spawn path +// without relying on platform-specific quoting for `node -e "..."` +const sleeperCommand = "sleep 30"; + +test("cleanupSessionProcesses reaps only session-scoped processes from the previous session", async () => { + const owned = startProcess({ + command: sleeperCommand, + cwd: process.cwd(), + ownerSessionFile: "session-a", + }); + const persistent = startProcess({ + command: sleeperCommand, + cwd: process.cwd(), + ownerSessionFile: "session-a", + persistAcrossSessions: true, + }); + const foreign = startProcess({ + command: sleeperCommand, + cwd: process.cwd(), + ownerSessionFile: "session-b", + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(isPidAlive(owned.proc.pid), true, "owned process should be alive before cleanup"); + assert.equal(isPidAlive(persistent.proc.pid), true, "persistent process should be alive before cleanup"); + assert.equal(isPidAlive(foreign.proc.pid), true, "foreign process should be alive before cleanup"); + + const removed = await cleanupSessionProcesses("session-a", { graceMs: 200 }); + assert.deepEqual(removed.sort(), [owned.id], "only the session-scoped process should be reaped"); + + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(isPidAlive(owned.proc.pid), false, "owned process should be terminated"); + assert.equal(isPidAlive(persistent.proc.pid), true, "persistent process should survive cleanup"); + assert.equal(isPidAlive(foreign.proc.pid), true, "foreign process should survive cleanup"); + assert.equal(processes.get(owned.id)?.persistAcrossSessions, false); + assert.equal(processes.get(persistent.id)?.persistAcrossSessions, true); + } finally { + cleanupAll(); + } +});