merge: resolve 12 conflicts with main — integrate continueHere feature into refactored closeoutUnit

Conflicts arose because main added continueHereHandle cleanup and
buildSnapshotOpts (with continueHereFired) while the PR extracted
inline closeout code into closeoutUnit(). Resolution: use closeoutUnit()
with buildSnapshotOpts() to pass all fields including continueHereFired.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-17 13:20:20 -06:00
commit 2e013d70b5
44 changed files with 3421 additions and 54 deletions

BIN
docs/pr-876/01-index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

BIN
docs/pr-876/02-summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

BIN
docs/pr-876/03-progress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
docs/pr-876/04-depgraph.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
docs/pr-876/05-metrics.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

BIN
docs/pr-876/06-timeline.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

BIN
docs/pr-876/09-captures.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

View file

@ -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";
}
}

View file

@ -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, unknown>): string {
return JSON.stringify(obj) + '\n'
}
// ---------------------------------------------------------------------------
// Extension UI Auto-Responder
// ---------------------------------------------------------------------------
@ -237,6 +241,8 @@ function isMilestoneReadyNotification(event: Record<string, unknown>): 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<string, unknown>
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<string, ReturnType<typeof setTimeout>>()
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<void>((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)

View file

@ -41,6 +41,8 @@ const SUBCOMMAND_HELP: Record<string, string> = {
' --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<string, string> = {
' 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'),

View file

@ -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(", ")}`;

View file

@ -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<BgProcess | null> {
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<boolean> {
if (!bg.alive) return true;
await new Promise<void>((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<string[]> {
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,

View file

@ -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;

View file

@ -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",

View file

@ -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,

View file

@ -13,6 +13,7 @@ export interface CloseoutOptions {
baselineCharCount?: number;
tier?: string;
modelDowngraded?: boolean;
continueHereFired?: boolean;
}
/**

View file

@ -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<typeof setTimeout> | null = null;
let wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
/** Context-pressure continue-here monitor — fires once when context usage >= 70% */
let continueHereHandle: ReturnType<typeof setInterval> | 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<string, unknown> {
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<typeof resolveExecutorContextWindow>[0],
prefs as Parameters<typeof resolveExecutorContextWindow>[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(

View file

@ -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]",

View file

@ -173,14 +173,19 @@ export async function preDispatchHealthGate(basePath: string): Promise<PreDispat
}
// ── STATE.md existence check ──
// If STATE.md is missing, rebuild it now so the next unit has accurate
// context. Non-blocking — if the rebuild throws, dispatch continues anyway.
// If STATE.md is missing, attempt to rebuild it for the next unit's context.
// Non-blocking — fresh worktrees won't have it until the first unit completes (#889).
try {
const stateFile = resolveGsdRootFile(basePath, "STATE");
const milestonesDir = join(gsdRoot(basePath), "milestones");
if (existsSync(milestonesDir) && !existsSync(stateFile)) {
await rebuildState(basePath);
fixesApplied.push("rebuilt missing STATE.md before dispatch");
try {
await rebuildState(basePath);
fixesApplied.push("rebuilt missing STATE.md before dispatch");
} catch {
// Rebuild failed — non-blocking, dispatch continues
fixesApplied.push("STATE.md missing — will rebuild after first unit completes");
}
}
} catch {
// Non-fatal — dispatch continues without STATE.md if rebuild fails

File diff suppressed because it is too large Load diff

View file

@ -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<void> {
// 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();

View file

@ -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

View file

@ -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<boolean> {
if (worker.process) {
await new Promise<void>((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<ParallelCandidates> {
return analyzeParallelEligibility(basePath);
): Promise<ParallelCandidates & { orphans?: Array<{ milestoneId: string; pid: number; alive: boolean }> }> {
// 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<void> {
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 ────────────────────────────────────────────────────────────────

View file

@ -75,6 +75,7 @@ const KNOWN_PREFERENCE_KEYS = new Set<string>([
"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)) {

View file

@ -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.

View file

@ -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<string, ReportEntry[]>();
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 =>
`<li><a href="${esc(e.filename)}">${formatDateShort(e.generatedAt)}</a> <span class="toc-kind toc-${e.kind}">${e.kind}</span></li>`
).join('');
return `
<div class="toc-group">
<div class="toc-group-label">${esc(mid === 'final' ? 'Final' : mid)}</div>
<ul>${links}</ul>
</div>`;
}).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 = `<div class="card-delta">${parts.map(p => `<span>${esc(p)}</span>`).join('')}</div>`;
}
}
return `
<a class="report-card${isLatest ? ' card-latest' : ''}" href="${esc(e.filename)}">
<div class="card-top">
<span class="card-label">${esc(e.label)}</span>
<span class="card-kind card-kind-${e.kind}">${e.kind}</span>
</div>
<div class="card-date">${formatDateShort(e.generatedAt)}</div>
<div class="card-progress">
<div class="card-bar-track">
<div class="card-bar-fill" style="width:${pct}%"></div>
</div>
<span class="card-pct">${pct}%</span>
</div>
<div class="card-stats">
<span>${esc(formatCost(e.totalCost))}</span>
<span>${esc(formatTokenCount(e.totalTokens))}</span>
<span>${esc(formatDuration(e.totalDuration))}</span>
<span>${e.doneSlices}/${e.totalSlices} slices</span>
</div>
${deltaHtml}
${isLatest ? '<div class="card-latest-badge">Latest</div>' : ''}
</a>`;
}).join('');
// Cost progression mini-chart (inline SVG sparkline)
const sparklineSvg = sorted.length > 1 ? buildCostSparkline(sorted) : '';
// Summary of latest state
const summaryHtml = latestEntry ? `
<div class="idx-summary">
<div class="idx-stat"><span class="idx-val">${formatCost(latestEntry.totalCost)}</span><span class="idx-lbl">Total Cost</span></div>
<div class="idx-stat"><span class="idx-val">${formatTokenCount(latestEntry.totalTokens)}</span><span class="idx-lbl">Total Tokens</span></div>
<div class="idx-stat"><span class="idx-val">${formatDuration(latestEntry.totalDuration)}</span><span class="idx-lbl">Duration</span></div>
<div class="idx-stat"><span class="idx-val">${latestEntry.doneSlices}/${latestEntry.totalSlices}</span><span class="idx-lbl">Slices</span></div>
<div class="idx-stat"><span class="idx-val">${latestEntry.doneMilestones}/${latestEntry.totalMilestones}</span><span class="idx-lbl">Milestones</span></div>
<div class="idx-stat"><span class="idx-val">${entries.length}</span><span class="idx-lbl">Reports</span></div>
</div>
<div class="idx-progress">
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:${overallPct}%"></div></div>
<span class="idx-pct">${overallPct}% complete</span>
</div>` : '<p class="empty">No reports generated yet.</p>';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GSD Reports ${esc(projectName)}</title>
<style>${INDEX_CSS}</style>
</head>
<body>
<header>
<div class="hdr-inner">
<div class="branding">
<span class="logo">GSD</span>
<span class="ver">v${esc(gsdVersion)}</span>
</div>
<div class="hdr-meta">
<h1>${esc(projectName)} <span class="hdr-subtitle">Reports</span></h1>
<span class="hdr-path">${esc(projectPath)}</span>
</div>
<div class="hdr-right">
<span class="gen-lbl">Updated</span>
<span class="gen">${formatDateShort(generated)}</span>
</div>
</div>
</header>
<div class="layout">
<!-- Sidebar TOC -->
<aside class="sidebar">
<div class="sidebar-title">Reports</div>
${sorted.length > 0 ? tocHtml : '<p class="empty">No reports yet.</p>'}
</aside>
<!-- Main content -->
<main>
<section class="idx-overview">
<h2>Project Overview</h2>
${summaryHtml}
${sparklineSvg ? `<div class="sparkline-wrap"><h3>Cost Progression</h3>${sparklineSvg}</div>` : ''}
</section>
<section class="idx-cards">
<h2>Progression <span class="sec-count">${entries.length}</span></h2>
${sorted.length > 0
? `<div class="cards-grid">${cardHtml}</div>`
: '<p class="empty">No reports generated yet. Run <code>/gsd export --html</code> or enable <code>auto_report: true</code>.</p>'}
</section>
</main>
</div>
<footer>
<div class="ftr-inner">
<span class="ftr-brand">GSD v${esc(gsdVersion)}</span>
<span class="ftr-sep"></span>
<span>${esc(projectName)}</span>
<span class="ftr-sep"></span>
<span>${esc(projectPath)}</span>
<span class="ftr-sep"></span>
<span>Updated ${formatDateShort(generated)}</span>
</div>
</footer>
</body>
</html>`;
}
// ─── 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 `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" class="spark-dot">
<title>${esc(entries[i].label)} ${formatCost(c)}</title>
</circle>`;
}).join('');
// Labels at start and end
const startLabel = formatCost(costs[0]);
const endLabel = formatCost(costs[costs.length - 1]);
return `
<div class="sparkline">
<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" class="spark-svg">
<polyline points="${esc(points)}" class="spark-line" fill="none"/>
${dots}
<text x="${PAD}" y="${H - 2}" class="spark-lbl">${esc(startLabel)}</text>
<text x="${W - PAD}" y="${H - 2}" text-anchor="end" class="spark-lbl">${esc(endLabel)}</text>
</svg>
<div class="spark-axis">
${entries.map((e, i) => {
const x = (PAD + i * xStep) / W * 100;
return `<span class="spark-tick" style="left:${x.toFixed(1)}%" title="${esc(e.generatedAt)}">${esc(e.milestoneId === 'final' ? 'final' : e.milestoneId)}</span>`;
}).join('')}
</div>
</div>`;
}
// ─── 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ─── 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}
}
`;

View file

@ -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 <path>` (use `-` for stdin), `--context-text <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/<milestoneId>.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 <phase>` | Force specific phase |
| `skip` / `undo` | Unit control |
| `doctor` | Health check + auto-fix |
| `steer <desc>` | Hard-steer plan mid-execution |

View file

@ -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`

View file

@ -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 <phase>` | 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 <desc>` | 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 <rule\|pattern\|lesson>` | 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)

View file

@ -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/<milestoneId>.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/<milestoneId>.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 |

View file

@ -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");
});
});
});

View file

@ -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> = {}): 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<GSDState>): 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 });
}
});

View file

@ -193,6 +193,20 @@ async function main(): Promise<void> {
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-")));

View file

@ -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> = {}): 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();

View file

@ -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", () => {

View file

@ -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, string> = {}): 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<void> {
// ─── 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);

View file

@ -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();

View file

@ -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<ChildProcess>();
async function stopLiveSubagents(): Promise<void> {
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<void>((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",

View file

@ -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();
}
});