M001: The Minimal Machine — linear auto-loop, sole-authority state, sidecar queue, WorktreeResolver (#1419)

* refactor: replace recursive auto-dispatch with linear autoLoop, delete ~3k lines of dead code

Replace the complex recursive dispatch system (dispatchNextUnit, reentrancy
guards, stall detection, idempotency tracking, skip-depth machinery) with a
simple linear while(s.active) loop in auto-loop.ts.

Key changes:
- New auto-loop.ts with autoLoop(), runUnit(), resolveAgentEnd()
- Deleted auto-idempotency.ts, auto-stuck-detection.ts, session-lock.ts,
  mechanical-completion.ts, progress-score.ts, auto-constants.ts, unit-id.ts
- Extracted WorktreeResolver class for worktree path resolution
- Added auto-worktree-sync.ts for worktree synchronization
- Simplified auto.ts from ~1400 lines to ~400 lines
- Fixed 9 TypeScript errors (NotifyCtx type widening, capture typing)
- Comprehensive test coverage: 32 auto-loop tests + worktree resolver/DB tests

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

* fix: address 6 audit findings in auto-loop refactor

1. CRITICAL: Move pendingResolve to AutoSession + queue orphaned agent_end
   events instead of silently dropping them. Prevents permanent stalls when
   error-recovery sendMessage retries fire between loop iterations.

2. HIGH: Scope pendingResolve per-session via _activeSession ref, preventing
   concurrent /gsd auto sessions from corrupting each other's promises.

3. HIGH: Replace console.log in dispatchHookUnit with debugLog to prevent
   hook prompt content (potentially containing secrets) from leaking to stdout.

4. HIGH: Restore parked milestone handling in state.ts — Phase 1 skips
   parked milestones so they don't satisfy depends_on, Phase 2 registers
   them as 'parked' status. Add 'parked' to MilestoneRegistryEntry type.

5. MEDIUM: Restore queuePhaseActive parameter in shouldBlockContextWrite
   and re-export setQueuePhaseActive for guided-flow-queue.ts consumers.

6. MEDIUM: Add MAX_LOOP_ITERATIONS (500) lifetime cap to autoLoop to prevent
   runaway loops when units alternate between IDs.

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

* fix: resolve build breakers, add correctness fixes, and graduated recovery

Build breakers (CRITICAL):
- Restore unit-id.ts (deleted but still imported by complexity-classifier.ts, metrics.ts)
- Restore progress-score.ts (deleted but still imported by commands.ts, dashboard-overlay.ts, doctor.ts)
- Rewrite worktree-sync-milestones.test.ts to use new syncProjectRootToWorktree API

Correctness fixes (MEDIUM):
- Cap pendingAgentEndQueue to 3 entries to prevent unbounded growth from stale events
- Add milestoneId path traversal validation in WorktreeResolver
- Clear depthVerificationDone on session_start to prevent cross-session leaks in RPC mode
- Add verification gate for non-hook sidecar units (triage, quick-tasks)
- Remove dead handleAgentEnd import from index.ts

Graduated recovery (Jeremy's feedback):
- Blanket try/catch around loop body — one bad iteration no longer kills the session
- Graduated stuck recovery: at count 3 try artifact verification + cache invalidation,
  at count 5 hard stop (was: binary stop at 5 with no recovery attempt)
- Graduated error recovery: 1st error retries, 2nd invalidates caches, 3rd stops

Test results: 32/32 auto-loop, 28/28 worktree-resolver, 11/11 sidecar-queue, tsc clean.

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

* fix: restore copyWorktreeDb/reconcileWorktreeDb exports and fix loadToolApiKeys import

Two missing exports caused ~90% of the 120 pre-existing test failures:

1. copyWorktreeDb + reconcileWorktreeDb — imported by auto-worktree.ts but
   never added to gsd-db.ts. Restored with the original implementations.
2. loadToolApiKeys — moved to commands-config.ts but index.ts still imported
   from commands.ts. Fixed the import path.

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

* fix: move loadToolApiKeys import to commands-config.js

loadToolApiKeys was moved to commands-config.ts but index.ts still
imported it from commands.ts, causing runtime failures in all tests
that transitively load the extension entry point.

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

* test: fix provider error assertion on windows

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-19 14:56:00 -06:00 committed by GitHub
parent f2657e1ba0
commit d761e45a41
87 changed files with 9779 additions and 11255 deletions

File diff suppressed because it is too large Load diff

View file

@ -85,12 +85,7 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt
* Delegates to the native Rust implementation.
*/
export function visibleWidth(str: string): number {
try {
return nativeVisibleWidth(str);
} catch {
// JS fallback — strip ANSI codes and return length (#1418)
return str.replace(/\x1b\[[0-9;]*m/g, "").length;
}
return nativeVisibleWidth(str);
}
/**
@ -102,28 +97,7 @@ export function visibleWidth(str: string): number {
* @returns Array of wrapped lines (NOT padded to width)
*/
export function wrapTextWithAnsi(text: string, width: number): string[] {
try {
return nativeWrapTextWithAnsi(text, width);
} catch {
// JS fallback when native addon is unavailable (e.g., glibc mismatch on older Linux) (#1418)
const lines: string[] = [];
for (const line of text.split("\n")) {
if (line.length <= width) {
lines.push(line);
} else {
// Simple word-wrap without ANSI awareness
let remaining = line;
while (remaining.length > width) {
const breakAt = remaining.lastIndexOf(" ", width);
const splitPoint = breakAt > 0 ? breakAt : width;
lines.push(remaining.slice(0, splitPoint));
remaining = remaining.slice(splitPoint).trimStart();
}
if (remaining) lines.push(remaining);
}
}
return lines;
}
return nativeWrapTextWithAnsi(text, width);
}
/**

View file

@ -1,93 +0,0 @@
#!/usr/bin/env node
/**
* Generate OpenRouter model entries for models.generated.ts
*
* Fetches the full model list from OpenRouter's API and generates
* TypeScript model entries matching the existing registry format.
*
* Usage: node scripts/generate-openrouter-models.mjs > /tmp/openrouter-models.ts
*
* The output is a partial TypeScript object that can be merged into
* packages/pi-ai/src/models.generated.ts under the "openrouter" key.
*/
const API_URL = "https://openrouter.ai/api/v1/models";
async function fetchModels() {
const resp = await fetch(API_URL);
if (!resp.ok) throw new Error(`API returned ${resp.status}`);
const data = await resp.json();
return data.data || [];
}
function inferApi(model) {
// Models that support the responses API
if (model.id.startsWith("openai/") || model.id.startsWith("anthropic/")) {
return "openai-completions";
}
return "openai-completions";
}
function inferReasoning(model) {
const id = model.id.toLowerCase();
return id.includes("o1") || id.includes("o3") || id.includes("o4") ||
id.includes("reasoning") || id.includes("think");
}
function inferInput(model) {
const arch = model.architecture || {};
const modality = (arch.input_modalities || []).join(",").toLowerCase();
if (modality.includes("image")) return '["text", "image"]';
return '["text"]';
}
function formatCost(pricing) {
if (!pricing) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
// OpenRouter pricing is per-token in dollars; our format is per-million-tokens
const toPerMillion = (v) => Math.round(parseFloat(v || "0") * 1_000_000 * 100) / 100;
return {
input: toPerMillion(pricing.prompt),
output: toPerMillion(pricing.completion),
cacheRead: 0,
cacheWrite: 0,
};
}
async function main() {
const models = await fetchModels();
console.log('\t"openrouter": {');
for (const m of models.sort((a, b) => a.id.localeCompare(b.id))) {
const cost = formatCost(m.pricing);
const contextWindow = m.context_length || 128000;
const maxOutput = m.top_provider?.max_completion_tokens || Math.min(contextWindow, 16384);
const reasoning = inferReasoning(m);
const input = inferInput(m);
console.log(`\t\t"${m.id}": {`);
console.log(`\t\t\tid: "${m.id}",`);
console.log(`\t\t\tname: ${JSON.stringify(m.name || m.id)},`);
console.log(`\t\t\tapi: "${inferApi(m)}",`);
console.log(`\t\t\tprovider: "openrouter",`);
console.log(`\t\t\tbaseUrl: "https://openrouter.ai/api/v1",`);
console.log(`\t\t\treasoning: ${reasoning},`);
console.log(`\t\t\tinput: ${input},`);
console.log(`\t\t\tcost: {`);
console.log(`\t\t\t\tinput: ${cost.input},`);
console.log(`\t\t\t\toutput: ${cost.output},`);
console.log(`\t\t\t\tcacheRead: ${cost.cacheRead},`);
console.log(`\t\t\t\tcacheWrite: ${cost.cacheWrite},`);
console.log(`\t\t\t},`);
console.log(`\t\t\tcontextWindow: ${contextWindow},`);
console.log(`\t\t\tmaxOutput: ${maxOutput},`);
console.log(`\t\t},`);
}
console.log("\t},");
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,18 @@
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Resolve bundled raw resource files from the package root.
*
* Both `src/*.ts` and compiled `dist/*.js` entry points need to load the same
* raw `.ts` resource modules via jiti. Those modules are shipped under
* `src/resources/**`, not next to the compiled entry point.
*/
export function resolveBundledSourceResource(
importUrl: string,
...segments: string[]
): string {
const moduleDir = dirname(fileURLToPath(importUrl));
const packageRoot = resolve(moduleDir, "..");
return join(packageRoot, "src", "resources", ...segments);
}

View file

@ -16,17 +16,18 @@
import { createJiti } from '@mariozechner/jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import type { GSDState } from './resources/extensions/gsd/types.js'
import { resolveBundledSourceResource } from './bundled-resource-path.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false })
const gsdExtensionPath = (...segments: string[]) =>
resolveBundledSourceResource(import.meta.url, 'extensions', 'gsd', ...segments)
async function loadExtensionModules() {
const stateModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/state.ts'), {}) as any
const dispatchModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/auto-dispatch.ts'), {}) as any
const sessionModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/session-status-io.ts'), {}) as any
const prefsModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/preferences.ts'), {}) as any
const stateModule = await jiti.import(gsdExtensionPath('state.ts'), {}) as any
const dispatchModule = await jiti.import(gsdExtensionPath('auto-dispatch.ts'), {}) as any
const sessionModule = await jiti.import(gsdExtensionPath('session-status-io.ts'), {}) as any
const prefsModule = await jiti.import(gsdExtensionPath('preferences.ts'), {}) as any
return {
deriveState: stateModule.deriveState as (basePath: string) => Promise<GSDState>,
resolveDispatch: dispatchModule.resolveDispatch as (opts: any) => Promise<any>,

View file

@ -1,6 +0,0 @@
/**
* Shared constants for auto-mode modules (auto.ts, auto-post-unit.ts, etc.).
*/
/** Throttle STATE.md rebuilds — at most once per 30 seconds. */
export const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;

View file

@ -11,7 +11,6 @@ import type { GSDState } from "./types.js";
import { getCurrentBranch } from "./worktree.js";
import { getActiveHook } from "./post-unit-hooks.js";
import { getLedger, getProjectTotals, formatCost, formatTokenCount, formatTierSavings } from "./metrics.js";
import { getHealthTrend, getConsecutiveErrorUnits } from "./doctor-proactive.js";
import {
resolveMilestoneFile,
resolveSliceFile,
@ -20,7 +19,6 @@ import { parseRoadmap, parsePlan } from "./files.js";
import { readFileSync, existsSync } from "node:fs";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
import { makeUI, GLYPH, INDENT } from "../shared/mod.js";
import { parseUnitId } from "./unit-id.js";
// ─── Dashboard Data ───────────────────────────────────────────────────────────
@ -49,34 +47,40 @@ export interface AutoDashboardData {
// ─── Unit Description Helpers ─────────────────────────────────────────────────
/** Canonical verb and phase label for each known unit type. */
const UNIT_TYPE_INFO: Record<string, { verb: string; phaseLabel: string }> = {
"research-milestone": { verb: "researching", phaseLabel: "RESEARCH" },
"research-slice": { verb: "researching", phaseLabel: "RESEARCH" },
"plan-milestone": { verb: "planning", phaseLabel: "PLAN" },
"plan-slice": { verb: "planning", phaseLabel: "PLAN" },
"execute-task": { verb: "executing", phaseLabel: "EXECUTE" },
"complete-slice": { verb: "completing", phaseLabel: "COMPLETE" },
"replan-slice": { verb: "replanning", phaseLabel: "REPLAN" },
"rewrite-docs": { verb: "rewriting", phaseLabel: "REWRITE" },
"reassess-roadmap": { verb: "reassessing", phaseLabel: "REASSESS" },
"run-uat": { verb: "running UAT", phaseLabel: "UAT" },
};
export function unitVerb(unitType: string): string {
if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`;
return UNIT_TYPE_INFO[unitType]?.verb ?? unitType;
switch (unitType) {
case "research-milestone":
case "research-slice": return "researching";
case "plan-milestone":
case "plan-slice": return "planning";
case "execute-task": return "executing";
case "complete-slice": return "completing";
case "replan-slice": return "replanning";
case "rewrite-docs": return "rewriting";
case "reassess-roadmap": return "reassessing";
case "run-uat": return "running UAT";
default: return unitType;
}
}
export function unitPhaseLabel(unitType: string): string {
if (unitType.startsWith("hook/")) return "HOOK";
return UNIT_TYPE_INFO[unitType]?.phaseLabel ?? unitType.toUpperCase();
switch (unitType) {
case "research-milestone": return "RESEARCH";
case "research-slice": return "RESEARCH";
case "plan-milestone": return "PLAN";
case "plan-slice": return "PLAN";
case "execute-task": return "EXECUTE";
case "complete-slice": return "COMPLETE";
case "replan-slice": return "REPLAN";
case "rewrite-docs": return "REWRITE";
case "reassess-roadmap": return "REASSESS";
case "run-uat": return "UAT";
default: return unitType.toUpperCase();
}
}
/**
* Describe the expected next step after the current unit completes.
* Unit types here mirror the keys in UNIT_TYPE_INFO above.
*/
function peekNext(unitType: string, state: GSDState): string {
// Show active hook info in progress display
const activeHookState = getActiveHook();
@ -305,16 +309,6 @@ export function updateProgressWidget(
}
if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`;
// Set a string-array fallback first — this is the only version RPC mode will
// see, since the factory widget set below is not supported in RPC mode.
const progressText = buildProgressTextLines(
verb, phaseLabel, unitId, mid, slice, task, next,
accessors, tierBadge, widgetPwd,
);
ctx.ui.setWidget("gsd-progress", progressText);
// Set the factory-based widget — in TUI mode this replaces the string-array
// version with a dynamic, animated widget. In RPC mode this call is a no-op.
ctx.ui.setWidget("gsd-progress", (tui, theme) => {
let pulseBright = true;
let cachedLines: string[] | undefined;
@ -372,11 +366,7 @@ export function updateProgressWidget(
lines.push("");
const isHook = unitType.startsWith("hook/");
const hookParsed = isHook ? parseUnitId(unitId) : undefined;
const target = isHook
? (hookParsed!.task ?? hookParsed!.slice ?? unitId)
: (task ? `${task.id}: ${task.title}` : unitId);
const target = task ? `${task.id}: ${task.title}` : unitId;
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`;
@ -396,10 +386,7 @@ export function updateProgressWidget(
let meta = theme.fg("dim", `${done}/${total} slices`);
if (activeSliceTasks && activeSliceTasks.total > 0) {
// For hooks, show the trigger task number (done), not the next task (done + 1)
const taskNum = isHook
? Math.max(activeSliceTasks.done, 1)
: Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
const taskNum = Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`);
}
@ -467,7 +454,6 @@ export function updateProgressWidget(
sp.push(`\u26A1${hitRate}%`);
}
if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`);
else if (autoTotals?.apiRequests) sp.push(`${autoTotals.apiRequests} reqs`);
const cxDisplay = cxPct === "?"
? `?/${formatWidgetTokens(cxWindow)}`
@ -526,95 +512,6 @@ export function updateProgressWidget(
});
}
// ─── Text Fallback for RPC Mode ───────────────────────────────────────────
/**
* Build a compact string-array representation of the progress widget.
* Used as a fallback when the factory-based widget cannot render (RPC mode).
*/
// ─── Model Health Indicator ───────────────────────────────────────────────────
/**
* Compute a traffic-light health indicator from observable signals.
* 🟢 progressing well no errors, trend stable/improving
* 🟡 struggling some errors or degrading trend
* 🔴 stuck consecutive errors, likely needs attention
*/
export function getModelHealthIndicator(): { emoji: string; label: string } {
const trend = getHealthTrend();
const consecutiveErrors = getConsecutiveErrorUnits();
if (consecutiveErrors >= 3) {
return { emoji: "🔴", label: "stuck" };
}
if (consecutiveErrors >= 1 || trend === "degrading") {
return { emoji: "🟡", label: "struggling" };
}
if (trend === "improving") {
return { emoji: "🟢", label: "progressing well" };
}
// stable or unknown
return { emoji: "🟢", label: "progressing" };
}
function buildProgressTextLines(
verb: string,
phaseLabel: string,
unitId: string,
mid: { id: string; title: string } | null,
slice: { id: string; title: string } | null,
task: { id: string; title: string } | null,
next: string,
accessors: WidgetStateAccessors,
tierBadge: string | undefined,
widgetPwd: string,
): string[] {
const mode = accessors.isStepMode() ? "step" : "auto";
const elapsed = formatAutoElapsed(accessors.getAutoStartTime());
const tierStr = tierBadge ? ` [${tierBadge}]` : "";
const lines: string[] = [];
lines.push(`[GSD ${mode}] ${verb} ${unitId}${tierStr}${elapsed ? `${elapsed}` : ""}`);
if (mid) lines.push(` Milestone: ${mid.id}${mid.title}`);
if (slice) lines.push(` Slice: ${slice.id}${slice.title}`);
if (task) lines.push(` Task: ${task.id}${task.title}`);
// Progress bar
const sp = cachedSliceProgress;
if (sp && sp.total > 0) {
const pct = Math.round((sp.done / sp.total) * 100);
const taskInfo = sp.activeSliceTasks
? ` (tasks: ${sp.activeSliceTasks.done}/${sp.activeSliceTasks.total})`
: "";
lines.push(` Progress: ${sp.done}/${sp.total} slices (${pct}%)${taskInfo}`);
}
// Cost / tokens
const ledger = getLedger();
const totals = ledger ? getProjectTotals(ledger.units) : null;
if (totals) {
const parts: string[] = [];
if (totals.tokens.input || totals.tokens.output) {
parts.push(`tokens: ${formatWidgetTokens(totals.tokens.input)}${formatWidgetTokens(totals.tokens.output)}`);
}
if (totals.cost > 0) {
parts.push(`cost: ${formatCost(totals.cost)}`);
}
if (parts.length > 0) lines.push(` ${parts.join(" — ")}`);
}
if (next) lines.push(` Next: ${next}`);
// Model health indicator
const health = getModelHealthIndicator();
lines.push(` Health: ${health.emoji} ${health.label}`);
lines.push(` ${widgetPwd}`);
return lines;
}
// ─── Right-align Helper ───────────────────────────────────────────────────────
/** Right-align helper: build a line with left content and right content. */

View file

@ -182,10 +182,15 @@ export async function dispatchDirectPhase(
ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning");
return;
}
const uatContent = await loadFile(uatFile);
if (!uatContent) {
ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning");
return;
}
const uatPath = relSliceFile(base, mid, sid, "UAT");
unitType = "run-uat";
unitId = `${mid}/${sid}`;
prompt = await buildRunUatPrompt(mid, sid, uatPath, base);
prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base);
break;
}

View file

@ -11,10 +11,15 @@
import type { GSDState } from "./types.js";
import type { GSDPreferences } from "./preferences.js";
import { loadFile, loadActiveOverrides, parseRoadmap } from "./files.js";
import type { UatType } from "./files.js";
import { loadFile, extractUatType, loadActiveOverrides } from "./files.js";
import {
resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile,
relSliceFile, buildMilestoneFileName,
resolveMilestoneFile,
resolveMilestonePath,
resolveSliceFile,
resolveTaskFile,
relSliceFile,
buildMilestoneFileName,
} from "./paths.js";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@ -38,7 +43,13 @@ import {
// ─── Types ────────────────────────────────────────────────────────────────
export type DispatchAction =
| { action: "dispatch"; unitType: string; unitId: string; prompt: string }
| {
action: "dispatch";
unitType: string;
unitId: string;
prompt: string;
pauseAfterDispatch?: boolean;
}
| { action: "stop"; reason: string; level: "info" | "warning" | "error" }
| { action: "skip" };
@ -57,6 +68,14 @@ interface DispatchRule {
match: (ctx: DispatchContext) => Promise<DispatchAction | null>;
}
function missingSliceStop(mid: string, phase: string): DispatchAction {
return {
action: "stop",
reason: `${mid}: phase "${phase}" has no active slice — run /gsd doctor.`,
level: "error",
};
}
// ─── Rewrite Circuit Breaker ──────────────────────────────────────────────
const MAX_REWRITE_ATTEMPTS = 3;
@ -65,28 +84,6 @@ export function resetRewriteCircuitBreaker(): void {
rewriteAttemptCount = 0;
}
/**
* Guard for accessing activeSlice/activeTask in dispatch rules.
* Returns a stop action if the expected ref is null (corrupt state).
*/
function requireSlice(state: GSDState): { sid: string; sTitle: string } | DispatchAction {
if (!state.activeSlice) {
return { action: "stop", reason: `Phase "${state.phase}" but no active slice — run /gsd doctor.`, level: "error" };
}
return { sid: state.activeSlice.id, sTitle: state.activeSlice.title };
}
function requireTask(state: GSDState): { sid: string; sTitle: string; tid: string; tTitle: string } | DispatchAction {
if (!state.activeSlice || !state.activeTask) {
return { action: "stop", reason: `Phase "${state.phase}" but no active slice/task — run /gsd doctor.`, level: "error" };
}
return { sid: state.activeSlice.id, sTitle: state.activeSlice.title, tid: state.activeTask.id, tTitle: state.activeTask.title };
}
function isStopAction(v: unknown): v is DispatchAction {
return typeof v === "object" && v !== null && "action" in v;
}
// ─── Rules ────────────────────────────────────────────────────────────────
const DISPATCH_RULES: DispatchRule[] = [
@ -107,7 +104,13 @@ const DISPATCH_RULES: DispatchRule[] = [
action: "dispatch",
unitType: "rewrite-docs",
unitId,
prompt: await buildRewriteDocsPrompt(mid, midTitle, state.activeSlice, basePath, pendingOverrides),
prompt: await buildRewriteDocsPrompt(
mid,
midTitle,
state.activeSlice,
basePath,
pendingOverrides,
),
};
},
},
@ -115,74 +118,63 @@ const DISPATCH_RULES: DispatchRule[] = [
name: "summarizing → complete-slice",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "summarizing") return null;
const sliceRef = requireSlice(state);
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
const { sid, sTitle } = sliceRef;
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
return {
action: "dispatch",
unitType: "complete-slice",
unitId: `${mid}/${sid}`,
prompt: await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, basePath),
prompt: await buildCompleteSlicePrompt(
mid,
midTitle,
sid,
sTitle,
basePath,
),
};
},
},
{
name: "uat-verdict-gate (non-PASS blocks progression)",
match: async ({ mid, basePath, prefs }) => {
// Only applies when UAT dispatch is enabled
if (!prefs?.uat_dispatch) return null;
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (!roadmapContent) return null;
const roadmap = parseRoadmap(roadmapContent);
for (const slice of roadmap.slices.filter(s => s.done)) {
const resultFile = resolveSliceFile(basePath, mid, slice.id, "UAT-RESULT");
if (!resultFile) continue;
const content = await loadFile(resultFile);
if (!content) continue;
const verdictMatch = content.match(/verdict:\s*([\w-]+)/i);
const verdict = verdictMatch?.[1]?.toLowerCase();
if (verdict && verdict !== "pass" && verdict !== "passed") {
return {
action: "stop" as const,
reason: `UAT verdict for ${slice.id} is "${verdict}" — blocking progression until resolved.\nReview the UAT result and update the verdict to PASS, or re-run /gsd auto after fixing.`,
level: "warning" as const,
};
}
}
return null;
},
},
{
name: "run-uat (post-completion)",
match: async ({ state, mid, basePath, prefs }) => {
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
if (!needsRunUat) return null;
const { sliceId } = needsRunUat;
const { sliceId, uatType } = needsRunUat;
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
const uatContent = await loadFile(uatFile);
return {
action: "dispatch",
unitType: "run-uat",
unitId: `${mid}/${sliceId}`,
prompt: await buildRunUatPrompt(
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), basePath,
mid,
sliceId,
relSliceFile(basePath, mid, sliceId, "UAT"),
uatContent ?? "",
basePath,
),
pauseAfterDispatch: uatType !== "artifact-driven",
};
},
},
{
name: "reassess-roadmap (post-completion)",
match: async ({ state, mid, midTitle, basePath, prefs }) => {
// Reassess is opt-in: only fire when explicitly enabled
if (!prefs?.phases?.reassess_after_slice) return null;
if (prefs?.phases?.skip_reassess || !prefs?.phases?.reassess_after_slice)
return null;
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
if (!needsReassess) return null;
return {
action: "dispatch",
unitType: "reassess-roadmap",
unitId: `${mid}/${needsReassess.sliceId}`,
prompt: await buildReassessRoadmapPrompt(mid, midTitle, needsReassess.sliceId, basePath),
prompt: await buildReassessRoadmapPrompt(
mid,
midTitle,
needsReassess.sliceId,
basePath,
),
};
},
},
@ -202,7 +194,7 @@ const DISPATCH_RULES: DispatchRule[] = [
match: async ({ state, mid, basePath }) => {
if (state.phase !== "pre-planning") return null;
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
const hasContext = !!(contextFile && await loadFile(contextFile));
const hasContext = !!(contextFile && (await loadFile(contextFile)));
if (hasContext) return null; // fall through to next rule
return {
action: "stop",
@ -244,21 +236,32 @@ const DISPATCH_RULES: DispatchRule[] = [
match: async ({ state, mid, midTitle, basePath, prefs }) => {
if (state.phase !== "planning") return null;
// Phase skip: skip research when preference or profile says so
if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null;
const sliceRef = requireSlice(state);
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
const { sid, sTitle } = sliceRef;
if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research)
return null;
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
if (researchFile) return null; // has research, fall through
// Skip slice research for S01 when milestone research already exists —
// the milestone research already covers the same ground for the first slice.
const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
const milestoneResearchFile = resolveMilestoneFile(
basePath,
mid,
"RESEARCH",
);
if (milestoneResearchFile && sid === "S01") return null; // fall through to plan-slice
return {
action: "dispatch",
unitType: "research-slice",
unitId: `${mid}/${sid}`,
prompt: await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, basePath),
prompt: await buildResearchSlicePrompt(
mid,
midTitle,
sid,
sTitle,
basePath,
),
};
},
},
@ -266,14 +269,20 @@ const DISPATCH_RULES: DispatchRule[] = [
name: "planning → plan-slice",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "planning") return null;
const sliceRef = requireSlice(state);
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
const { sid, sTitle } = sliceRef;
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
return {
action: "dispatch",
unitType: "plan-slice",
unitId: `${mid}/${sid}`,
prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
prompt: await buildPlanSlicePrompt(
mid,
midTitle,
sid,
sTitle,
basePath,
),
};
},
},
@ -281,14 +290,20 @@ const DISPATCH_RULES: DispatchRule[] = [
name: "replanning-slice → replan-slice",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "replanning-slice") return null;
const sliceRef = requireSlice(state);
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
const { sid, sTitle } = sliceRef;
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
return {
action: "dispatch",
unitType: "replan-slice",
unitId: `${mid}/${sid}`,
prompt: await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
prompt: await buildReplanSlicePrompt(
mid,
midTitle,
sid,
sTitle,
basePath,
),
};
},
},
@ -296,9 +311,9 @@ 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 sliceRef = requireSlice(state);
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
const { sid, sTitle } = sliceRef;
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
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
@ -312,7 +327,13 @@ const DISPATCH_RULES: DispatchRule[] = [
action: "dispatch",
unitType: "plan-slice",
unitId: `${mid}/${sid}`,
prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
prompt: await buildPlanSlicePrompt(
mid,
midTitle,
sid,
sTitle,
basePath,
),
};
}
@ -323,9 +344,9 @@ const DISPATCH_RULES: DispatchRule[] = [
name: "executing → execute-task",
match: async ({ state, mid, basePath }) => {
if (state.phase !== "executing" || !state.activeTask) return null;
const sliceRef = requireSlice(state);
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
const { sid, sTitle } = sliceRef;
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
const tid = state.activeTask.id;
const tTitle = state.activeTask.title;
@ -333,7 +354,14 @@ const DISPATCH_RULES: DispatchRule[] = [
action: "dispatch",
unitType: "execute-task",
unitId: `${mid}/${sid}/${tid}`,
prompt: await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath),
prompt: await buildExecuteTaskPrompt(
mid,
sid,
sTitle,
tid,
tTitle,
basePath,
),
};
},
},
@ -346,7 +374,10 @@ const DISPATCH_RULES: DispatchRule[] = [
const mDir = resolveMilestonePath(basePath, mid);
if (mDir) {
if (!existsSync(mDir)) mkdirSync(mDir, { recursive: true });
const validationPath = join(mDir, buildMilestoneFileName(mid, "VALIDATION"));
const validationPath = join(
mDir,
buildMilestoneFileName(mid, "VALIDATION"),
);
const content = [
"---",
"verdict: pass",
@ -381,6 +412,17 @@ const DISPATCH_RULES: DispatchRule[] = [
};
},
},
{
name: "complete → stop",
match: async ({ state }) => {
if (state.phase !== "complete") return null;
return {
action: "stop",
reason: "All milestones complete.",
level: "info",
};
},
},
];
// ─── Resolver ─────────────────────────────────────────────────────────────
@ -389,7 +431,9 @@ const DISPATCH_RULES: DispatchRule[] = [
* Evaluate dispatch rules in order. Returns the first matching action,
* or a "stop" action if no rule matches (unhandled phase).
*/
export async function resolveDispatch(ctx: DispatchContext): Promise<DispatchAction> {
export async function resolveDispatch(
ctx: DispatchContext,
): Promise<DispatchAction> {
for (const rule of DISPATCH_RULES) {
const result = await rule.match(ctx);
if (result) return result;
@ -405,5 +449,5 @@ export async function resolveDispatch(ctx: DispatchContext): Promise<DispatchAct
/** Exposed for testing — returns the rule names in evaluation order. */
export function getDispatchRuleNames(): string[] {
return DISPATCH_RULES.map(r => r.name);
return DISPATCH_RULES.map((r) => r.name);
}

View file

@ -1,151 +0,0 @@
/**
* Idempotency checks for auto-mode unit dispatch.
*
* Handles completed-key membership, artifact cross-validation,
* consecutive skip counting, phantom skip loop detection, key eviction,
* and fallback persistence.
*
* Extracted from dispatchNextUnit() in auto.ts. Pure decision logic
* with set mutations does NOT call dispatchNextUnit or stopAuto.
*/
import { invalidateAllCaches } from "./cache.js";
import {
verifyExpectedArtifact,
persistCompletedKey,
removePersistedKey,
} from "./auto-recovery.js";
import { resolveMilestoneFile } from "./paths.js";
import { MAX_CONSECUTIVE_SKIPS, MAX_LIFETIME_DISPATCHES } from "./auto/session.js";
import type { AutoSession } from "./auto/session.js";
import { parseUnitId } from "./unit-id.js";
export interface IdempotencyContext {
s: AutoSession;
unitType: string;
unitId: string;
basePath: string;
/** Notification callback */
notify: (message: string, level: "info" | "warning" | "error") => void;
}
export type IdempotencyResult =
| { action: "skip"; reason: string }
| { action: "rerun"; reason: string }
| { action: "proceed" }
| { action: "stop"; reason: string };
/**
* Check whether a unit should be skipped (already completed), rerun
* (stale completion record), or dispatched normally.
*
* Mutates s.completedKeySet, s.unitConsecutiveSkips, s.unitLifetimeDispatches,
* and s.recentlyEvictedKeys as needed.
*/
export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
const { s, unitType, unitId, basePath, notify } = ictx;
const idempotencyKey = `${unitType}/${unitId}`;
// ── Primary path: key exists in completed set ──
if (s.completedKeySet.has(idempotencyKey)) {
const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
if (artifactExists) {
// Guard against infinite skip loops
const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
if (skipCount > MAX_CONSECUTIVE_SKIPS) {
// Cross-check: verify the unit's milestone is still active (#790)
const skippedMid = parseUnitId(unitId).milestone;
const skippedMilestoneComplete = skippedMid
? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
: false;
if (skippedMilestoneComplete) {
s.unitConsecutiveSkips.delete(idempotencyKey);
invalidateAllCaches();
notify(
`Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`,
"info",
);
return { action: "skip", reason: "phantom-loop-cleared" };
}
s.unitConsecutiveSkips.delete(idempotencyKey);
s.completedKeySet.delete(idempotencyKey);
s.recentlyEvictedKeys.add(idempotencyKey);
removePersistedKey(basePath, idempotencyKey);
invalidateAllCaches();
notify(
`Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`,
"warning",
);
return { action: "skip", reason: "evicted" };
}
// Count toward lifetime cap
const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
if (lifeSkip > MAX_LIFETIME_DISPATCHES) {
return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` };
}
notify(
`Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
"info",
);
return { action: "skip", reason: "completed" };
} else {
// Stale completion record — artifact missing. Remove and re-run.
s.completedKeySet.delete(idempotencyKey);
removePersistedKey(basePath, idempotencyKey);
notify(
`Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
"warning",
);
return { action: "rerun", reason: "stale-key" };
}
}
// ── Fallback: key missing but artifact exists ──
if (verifyExpectedArtifact(unitType, unitId, basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) {
persistCompletedKey(basePath, idempotencyKey);
s.completedKeySet.add(idempotencyKey);
invalidateAllCaches();
// Same consecutive-skip guard as the primary path
const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
const skippedMid2 = parseUnitId(unitId).milestone;
const skippedMilestoneComplete2 = skippedMid2
? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
: false;
if (skippedMilestoneComplete2) {
s.unitConsecutiveSkips.delete(idempotencyKey);
invalidateAllCaches();
notify(
`Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`,
"info",
);
return { action: "skip", reason: "phantom-loop-cleared" };
}
s.unitConsecutiveSkips.delete(idempotencyKey);
s.completedKeySet.delete(idempotencyKey);
removePersistedKey(basePath, idempotencyKey);
invalidateAllCaches();
notify(
`Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`,
"warning",
);
return { action: "skip", reason: "evicted" };
}
// Count toward lifetime cap
const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) {
return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` };
}
notify(
`Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
"info",
);
return { action: "skip", reason: "fallback-persisted" };
}
return { action: "proceed" };
}

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,6 @@ import {
formatValidationIssues,
} from "./observability-validator.js";
import type { ValidationIssue } from "./observability-validator.js";
import { parseUnitId } from "./unit-id.js";
export async function collectObservabilityWarnings(
ctx: ExtensionContext,
@ -23,7 +22,10 @@ export async function collectObservabilityWarnings(
// Hook units have custom artifacts — skip standard observability checks
if (unitType.startsWith("hook/")) return [];
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
const tid = parts[2];
if (!mid || !sid) return [];

View file

@ -11,7 +11,7 @@
* Extracted from handleAgentEnd() in auto.ts.
*/
import type { ExtensionContext, ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent";
import type { ExtensionContext, ExtensionAPI } from "@gsd/pi-coding-agent";
import { deriveState } from "./state.js";
import { loadFile, parseSummary, resolveAllOverrides } from "./files.js";
import { loadPrompt } from "./prompt-loader.js";
@ -19,7 +19,6 @@ import {
resolveSliceFile,
resolveTaskFile,
resolveMilestoneFile,
gsdRoot,
} from "./paths.js";
import { invalidateAllCaches } from "./cache.js";
import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
@ -29,30 +28,23 @@ import {
} from "./worktree.js";
import {
verifyExpectedArtifact,
persistCompletedKey,
removePersistedKey,
} from "./auto-recovery.js";
import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.js";
import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences } from "./preferences.js";
import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js";
import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js";
import { syncStateToProjectRoot } from "./auto-worktree-sync.js";
import { resetRewriteCircuitBreaker } from "./auto-dispatch.js";
import { isDbAvailable } from "./gsd-db.js";
import { consumeSignal } from "./session-status-io.js";
import {
checkPostUnitHooks,
getActiveHook,
resetHookState,
isRetryPending,
consumeRetryTrigger,
persistHookState,
} from "./post-unit-hooks.js";
import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js";
import { writeLock } from "./crash-recovery.js";
import { hasPendingCaptures, loadPendingCaptures } from "./captures.js";
import { debugLog } from "./debug-logger.js";
import type { AutoSession } from "./auto/session.js";
import type { WidgetStateAccessors, AutoDashboardData } from "./auto-dashboard.js";
import {
updateProgressWidget as _updateProgressWidget,
updateSliceProgressCache,
@ -60,32 +52,9 @@ import {
hideFooter,
} from "./auto-dashboard.js";
import { join } from "node:path";
import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js";
import { parseUnitId } from "./unit-id.js";
/**
* Initialize a unit dispatch: stamp the current time, set `s.currentUnit`,
* and persist the initial runtime record. Returns `startedAt` for callers
* that need the timestamp.
*/
function dispatchUnit(
s: AutoSession,
basePath: string,
unitType: string,
unitId: string,
): number {
const startedAt = Date.now();
s.currentUnit = { type: unitType, id: unitId, startedAt };
writeUnitRuntimeRecord(basePath, unitType, unitId, startedAt, {
phase: "dispatched",
wrapupWarningSent: false,
timeoutAt: null,
lastProgressAt: startedAt,
progressCount: 0,
lastProgressKind: "dispatch",
});
return startedAt;
}
/** Throttle STATE.md rebuilds — at most once per 30 seconds */
const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
export interface PostUnitContext {
s: AutoSession;
@ -135,7 +104,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
let taskContext: TaskCommitContext | undefined;
if (s.currentUnit.type === "execute-task") {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
const parts = s.currentUnit.id.split("/");
const [mid, sid, tid] = parts;
if (mid && sid && tid) {
const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
if (summaryPath) {
@ -167,8 +137,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
// Doctor: fix mechanical bookkeeping
try {
const { milestone, slice } = parseUnitId(s.currentUnit.id);
const doctorScope = slice ? `${milestone}/${slice}` : milestone;
const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
const doctorScope = scopeParts.join("/");
const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]);
const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const;
const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
@ -176,17 +146,13 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
}
// Proactive health tracking — exclude completion-transition codes at task level
// since they are expected after the last task and resolved by complete-slice
const issuesForHealth = effectiveFixLevel === "task"
? report.issues.filter(i => !COMPLETION_TRANSITION_CODES.has(i.code))
: report.issues;
const summary = summarizeDoctorIssues(issuesForHealth);
// Proactive health tracking
const summary = summarizeDoctorIssues(report.issues);
recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length);
// Check if we should escalate to LLM-assisted heal
if (summary.errors > 0) {
const unresolvedErrors = issuesForHealth
const unresolvedErrors = report.issues
.filter(i => i.severity === "error" && !i.fixable)
.map(i => ({ code: i.code, message: i.message, unitId: i.unitId }));
const escalation = checkHealEscalation(summary.errors, unresolvedErrors);
@ -223,17 +189,23 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
}
}
// Prune dead bg-shell processes and kill non-persistent live ones.
// Without killing live processes between units, dev servers spawned during
// one task keep ports bound, causing conflicts in subsequent tasks (#1209).
// Prune dead bg-shell processes
try {
const { pruneDeadProcesses, killSessionProcesses } = await import("../bg-shell/process-manager.js");
const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js");
pruneDeadProcesses();
killSessionProcesses();
} catch {
// Non-fatal
}
// Sync worktree state back to project root
if (s.originalBasePath && s.originalBasePath !== s.basePath) {
try {
syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId);
} catch {
// Non-fatal
}
}
// Rewrite-docs completion
if (s.currentUnit.type === "rewrite-docs") {
try {
@ -286,17 +258,12 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
}
}
// Artifact verification and completion persistence
// Artifact verification
let triggerArtifactVerified = false;
if (!s.currentUnit.type.startsWith("hook/")) {
try {
triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
if (triggerArtifactVerified) {
const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
if (!s.completedKeySet.has(completionKey)) {
persistCompletedKey(s.basePath, completionKey);
s.completedKeySet.add(completionKey);
}
invalidateAllCaches();
}
} catch {
@ -324,13 +291,15 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
* Post-verification processing: DB dual-write, post-unit hooks, triage
* capture dispatch, quick-task dispatch.
*
* Sidecar work (hooks, triage, quick-tasks) is enqueued on `s.sidecarQueue`
* for the main loop to drain via `runUnit()`.
*
* Returns:
* - "dispatched" a hook/triage/quick-task was dispatched (sendMessage sent)
* - "continue" proceed to normal dispatchNextUnit
* - "continue" proceed to sidecar drain / normal dispatch
* - "step-wizard" step mode, show wizard instead
* - "stopped" stopAuto was called
*/
export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"dispatched" | "continue" | "step-wizard" | "stopped"> {
export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"continue" | "step-wizard" | "stopped"> {
const { s, ctx, pi, buildSnapshotOpts, lockBase, stopAuto, pauseAuto, updateProgressWidget } = pctx;
// ── DB dual-write ──
@ -343,45 +312,6 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
}
}
// ── Mechanical completion (ADR-003) ──
// After task execution, attempt mechanical slice and milestone completion
// instead of dispatching LLM sessions for complete-slice / validate-milestone.
if (s.currentUnit?.type === "execute-task" && !s.stepMode) {
try {
const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id);
if (mid && sid) {
const state = await deriveState(s.basePath);
if (state.phase === "summarizing" && state.activeSlice?.id === sid) {
const { mechanicalSliceCompletion } = await import("./mechanical-completion.js");
const ok = await mechanicalSliceCompletion(s.basePath, mid, sid);
if (ok) {
invalidateAllCaches();
autoCommitCurrentBranch(s.basePath, "mechanical-completion", `${mid}/${sid}`);
ctx.ui.notify(`Mechanical completion: ${sid} summary + roadmap updated.`, "info");
// Re-derive state — check if milestone is now ready for validation
invalidateAllCaches();
const postSliceState = await deriveState(s.basePath);
if (postSliceState.phase === "validating-milestone" || postSliceState.phase === "completing-milestone") {
const { aggregateMilestoneVerification, generateMilestoneSummary } = await import("./mechanical-completion.js");
const validation = await aggregateMilestoneVerification(s.basePath, mid);
if (validation.verdict !== "failed") {
await generateMilestoneSummary(s.basePath, mid);
invalidateAllCaches();
autoCommitCurrentBranch(s.basePath, "mechanical-milestone-completion", mid);
ctx.ui.notify(`Mechanical completion: ${mid} validation + summary written.`, "info");
}
}
}
// If !ok, summarizing phase persists → dispatch rule fires as LLM fallback
}
}
} catch (err) {
process.stderr.write(`gsd-mechanical: completion failed: ${(err as Error).message}\n`);
// Non-fatal — fall through to normal dispatch
}
}
// ── Post-unit hooks ──
if (s.currentUnit && !s.stepMode) {
const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath);
@ -389,79 +319,36 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
if (s.currentUnit) {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
}
dispatchUnit(s, s.basePath, hookUnit.unitType, hookUnit.unitId);
const state = await deriveState(s.basePath);
updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state);
const hookState = getActiveHook();
ctx.ui.notify(
`Running post-unit hook: ${hookUnit.hookName} (cycle ${hookState?.cycle ?? 1})`,
"info",
);
// Switch model if the hook specifies one
if (hookUnit.model) {
const availableModels = ctx.modelRegistry.getAvailable();
const match = availableModels.find(m =>
m.id === hookUnit.model || `${m.provider}/${m.id}` === hookUnit.model,
);
if (match) {
try {
await pi.setModel(match);
} catch { /* non-fatal */ }
}
}
const result = await s.cmdCtx!.newSession();
if (result.cancelled) {
resetHookState();
await stopAuto(ctx, pi, "Hook session cancelled");
return "stopped";
}
const sessionFile = ctx.sessionManager.getSessionFile();
writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, s.completedUnits.length, sessionFile);
persistHookState(s.basePath);
// Start supervision timers for hook units
const supervisor = resolveAutoSupervisorConfig();
const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
s.unitTimeoutHandle = setTimeout(async () => {
s.unitTimeoutHandle = null;
if (!s.active) return;
if (s.currentUnit) {
writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, s.currentUnit.startedAt, {
phase: "timeout",
timeoutAt: Date.now(),
});
}
ctx.ui.notify(
`Hook ${hookUnit.hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`,
"warning",
);
resetHookState();
await pauseAuto(ctx, pi);
}, hookHardTimeoutMs);
s.sidecarQueue.push({
kind: "hook",
unitType: hookUnit.unitType,
unitId: hookUnit.unitId,
prompt: hookUnit.prompt,
model: hookUnit.model,
});
if (!s.active) return "stopped";
pi.sendMessage(
{ customType: "gsd-auto", content: hookUnit.prompt, display: s.verbose },
{ triggerTurn: true },
);
return "dispatched";
debugLog("postUnitPostVerification", {
phase: "sidecar-enqueue",
kind: "hook",
unitType: hookUnit.unitType,
unitId: hookUnit.unitId,
hookName: hookUnit.hookName,
});
return "continue";
}
// Check if a hook requested a retry of the trigger unit
if (isRetryPending()) {
const trigger = consumeRetryTrigger();
if (trigger) {
const triggerKey = `${trigger.unitType}/${trigger.unitId}`;
s.completedKeySet.delete(triggerKey);
removePersistedKey(s.basePath, triggerKey);
ctx.ui.notify(
`Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
"info",
);
// Fall through to normal dispatch
// Fall through to normal dispatch — deriveState will re-derive the unit
}
}
}
@ -500,46 +387,31 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
roadmapContext: roadmapContext || "(no active roadmap)",
});
if (s.currentUnit) {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
}
const triageUnitId = `${mid}/${sid}/triage`;
s.sidecarQueue.push({
kind: "triage",
unitType: "triage-captures",
unitId: triageUnitId,
prompt,
});
debugLog("postUnitPostVerification", {
phase: "sidecar-enqueue",
kind: "triage",
unitId: triageUnitId,
pendingCount: pending.length,
});
ctx.ui.notify(
`Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`,
"info",
);
if (s.currentUnit) {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
}
const triageUnitType = "triage-captures";
const triageUnitId = `${mid}/${sid}/triage`;
dispatchUnit(s, s.basePath, triageUnitType, triageUnitId);
updateProgressWidget(ctx, triageUnitType, triageUnitId, state);
const result = await s.cmdCtx!.newSession();
if (result.cancelled) {
await stopAuto(ctx, pi);
return "stopped";
}
const sessionFile = ctx.sessionManager.getSessionFile();
writeLock(lockBase(), triageUnitType, triageUnitId, s.completedUnits.length, sessionFile);
const supervisor = resolveAutoSupervisorConfig();
const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
s.unitTimeoutHandle = setTimeout(async () => {
s.unitTimeoutHandle = null;
if (!s.active) return;
ctx.ui.notify(
`Triage unit exceeded timeout. Pausing auto-mode.`,
"warning",
);
await pauseAuto(ctx, pi);
}, triageTimeoutMs);
if (!s.active) return "stopped";
pi.sendMessage(
{ customType: "gsd-auto", content: prompt, display: s.verbose },
{ triggerTurn: true },
);
return "dispatched";
return "continue";
}
}
}
@ -561,49 +433,34 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
const { markCaptureExecuted } = await import("./captures.js");
const prompt = buildQuickTaskPrompt(capture);
if (s.currentUnit) {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
}
markCaptureExecuted(s.basePath, capture.id);
const qtUnitId = `${s.currentMilestoneId}/${capture.id}`;
s.sidecarQueue.push({
kind: "quick-task",
unitType: "quick-task",
unitId: qtUnitId,
prompt,
captureId: capture.id,
});
debugLog("postUnitPostVerification", {
phase: "sidecar-enqueue",
kind: "quick-task",
unitId: qtUnitId,
captureId: capture.id,
});
ctx.ui.notify(
`Executing quick-task: ${capture.id} — "${capture.text}"`,
"info",
);
if (s.currentUnit) {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
}
const qtUnitType = "quick-task";
const qtUnitId = `${s.currentMilestoneId}/${capture.id}`;
dispatchUnit(s, s.basePath, qtUnitType, qtUnitId);
const state = await deriveState(s.basePath);
updateProgressWidget(ctx, qtUnitType, qtUnitId, state);
const result = await s.cmdCtx!.newSession();
if (result.cancelled) {
await stopAuto(ctx, pi);
return "stopped";
}
const sessionFile = ctx.sessionManager.getSessionFile();
writeLock(lockBase(), qtUnitType, qtUnitId, s.completedUnits.length, sessionFile);
markCaptureExecuted(s.basePath, capture.id);
const supervisor = resolveAutoSupervisorConfig();
const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
s.unitTimeoutHandle = setTimeout(async () => {
s.unitTimeoutHandle = null;
if (!s.active) return;
ctx.ui.notify(
`Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`,
"warning",
);
await pauseAuto(ctx, pi);
}, qtTimeoutMs);
if (!s.active) return "stopped";
pi.sendMessage(
{ customType: "gsd-auto", content: prompt, display: s.verbose },
{ triggerTurn: true },
);
return "dispatched";
return "continue";
} catch {
// Non-fatal — proceed to normal dispatch
}

View file

@ -55,6 +55,55 @@ function formatExecutorConstraints(): string {
].join("\n");
}
function buildSourceFilePaths(
base: string,
mid: string,
sid?: string,
): string {
const paths: string[] = [];
const projectPath = resolveGsdRootFile(base, "PROJECT");
if (existsSync(projectPath)) {
paths.push(`- **Project**: \`${relGsdRootFile("PROJECT")}\``);
}
const requirementsPath = resolveGsdRootFile(base, "REQUIREMENTS");
if (existsSync(requirementsPath)) {
paths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``);
}
const decisionsPath = resolveGsdRootFile(base, "DECISIONS");
if (existsSync(decisionsPath)) {
paths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``);
}
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
if (contextPath) {
paths.push(`- **Milestone Context**: \`${relMilestoneFile(base, mid, "CONTEXT")}\``);
}
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (roadmapPath) {
paths.push(`- **Roadmap**: \`${relMilestoneFile(base, mid, "ROADMAP")}\``);
}
if (sid) {
const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH");
if (researchPath) {
paths.push(`- **Slice Research**: \`${relSliceFile(base, mid, sid, "RESEARCH")}\``);
}
} else {
const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
if (researchPath) {
paths.push(`- **Milestone Research**: \`${relMilestoneFile(base, mid, "RESEARCH")}\``);
}
}
return paths.length > 0
? paths.join("\n")
: "- Use `rg --files` and targeted reads to identify the relevant source files before planning.";
}
// ─── Inline Helpers ───────────────────────────────────────────────────────
/**
@ -188,38 +237,6 @@ export async function inlineGsdRootFile(
// ─── DB-Aware Inline Helpers ──────────────────────────────────────────────
/**
* Shared DB-fallback pattern: attempt a DB query via the context-store, format
* the result, and fall back to the filesystem file when the DB is unavailable
* or the query yields no results.
*
* @param base Project root for filesystem fallback
* @param label Section heading (e.g. "Decisions")
* @param filename Filesystem fallback file (e.g. "decisions.md")
* @param queryDb Async callback receiving the dynamically-imported
* context-store module. Returns formatted markdown or null.
*/
async function inlineFromDbOrFile(
base: string,
label: string,
filename: string,
queryDb: (cs: typeof import("./context-store.js")) => string | null,
): Promise<string | null> {
try {
const { isDbAvailable } = await import("./gsd-db.js");
if (isDbAvailable()) {
const contextStore = await import("./context-store.js");
const content = queryDb(contextStore);
if (content) {
return `### ${label}\nSource: \`.gsd/${filename.toUpperCase().replace(/\.MD$/i, "")}.md\`\n\n${content}`;
}
}
} catch {
// DB not available — fall through to filesystem
}
return inlineGsdRootFile(base, filename, label);
}
/**
* Inline decisions with optional milestone scoping from the DB.
* Falls back to filesystem via inlineGsdRootFile when DB unavailable or empty.
@ -228,13 +245,23 @@ export async function inlineDecisionsFromDb(
base: string, milestoneId?: string, scope?: string, level?: InlineLevel,
): Promise<string | null> {
const inlineLevel = level ?? resolveInlineLevel();
return inlineFromDbOrFile(base, "Decisions", "decisions.md", (cs) => {
const decisions = cs.queryDecisions({ milestoneId, scope });
if (decisions.length === 0) return null;
return inlineLevel !== "full"
? formatDecisionsCompact(decisions)
: cs.formatDecisionsForPrompt(decisions);
});
try {
const { isDbAvailable } = await import("./gsd-db.js");
if (isDbAvailable()) {
const { queryDecisions, formatDecisionsForPrompt } = await import("./context-store.js");
const decisions = queryDecisions({ milestoneId, scope });
if (decisions.length > 0) {
// Use compact format for non-full levels to save ~35% tokens
const formatted = inlineLevel !== "full"
? formatDecisionsCompact(decisions)
: formatDecisionsForPrompt(decisions);
return `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${formatted}`;
}
}
} catch {
// DB not available — fall through to filesystem
}
return inlineGsdRootFile(base, "decisions.md", "Decisions");
}
/**
@ -245,13 +272,23 @@ export async function inlineRequirementsFromDb(
base: string, sliceId?: string, level?: InlineLevel,
): Promise<string | null> {
const inlineLevel = level ?? resolveInlineLevel();
return inlineFromDbOrFile(base, "Requirements", "requirements.md", (cs) => {
const requirements = cs.queryRequirements({ sliceId });
if (requirements.length === 0) return null;
return inlineLevel !== "full"
? formatRequirementsCompact(requirements)
: cs.formatRequirementsForPrompt(requirements);
});
try {
const { isDbAvailable } = await import("./gsd-db.js");
if (isDbAvailable()) {
const { queryRequirements, formatRequirementsForPrompt } = await import("./context-store.js");
const requirements = queryRequirements({ sliceId });
if (requirements.length > 0) {
// Use compact format for non-full levels to save ~40% tokens
const formatted = inlineLevel !== "full"
? formatRequirementsCompact(requirements)
: formatRequirementsForPrompt(requirements);
return `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${formatted}`;
}
}
} catch {
// DB not available — fall through to filesystem
}
return inlineGsdRootFile(base, "requirements.md", "Requirements");
}
/**
@ -261,9 +298,19 @@ export async function inlineRequirementsFromDb(
export async function inlineProjectFromDb(
base: string,
): Promise<string | null> {
return inlineFromDbOrFile(base, "Project", "project.md", (cs) => {
return cs.queryProject();
});
try {
const { isDbAvailable } = await import("./gsd-db.js");
if (isDbAvailable()) {
const { queryProject } = await import("./context-store.js");
const content = queryProject();
if (content) {
return `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${content}`;
}
}
} catch {
// DB not available — fall through to filesystem
}
return inlineGsdRootFile(base, "project.md", "Project");
}
// ─── Skill Discovery ──────────────────────────────────────────────────────
@ -326,27 +373,6 @@ function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
/** Build the standard inlined-context section used by all prompt builders. */
function buildInlinedContextSection(inlined: string[]): string {
return `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
}
/** Build the formatted list of available GSD source files for planners to read on demand. */
function buildSourceFileList(base: string, opts?: { includeProject?: boolean }): string {
const paths: string[] = [];
if (opts?.includeProject && existsSync(resolveGsdRootFile(base, "PROJECT")))
paths.push(`- **Project**: \`${relGsdRootFile("PROJECT")}\``);
if (existsSync(resolveGsdRootFile(base, "REQUIREMENTS")))
paths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``);
if (existsSync(resolveGsdRootFile(base, "DECISIONS")))
paths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``);
if (paths.length === 0) {
const types = opts?.includeProject ? "project/requirements/decisions" : "requirements/decisions";
return `_No ${types} files found._`;
}
return paths.join("\n");
}
// ─── Section Builders ──────────────────────────────────────────────────────
export function buildResumeSection(
@ -492,17 +518,6 @@ export async function checkNeedsReassessment(
if (hasAssessment) return null;
// Fallback: check the expected path directly via existsSync.
// resolveSliceFile relies on directory listing (readdirSync) which may not
// reflect a freshly written file in git worktree directories on some
// filesystems (observed on macOS APFS). A direct existsSync on the
// constructed path bypasses directory listing entirely. (#1112)
const sliceDir = resolveSlicePath(base, mid, lastCompleted.id);
if (sliceDir) {
const directPath = join(sliceDir, `${lastCompleted.id}-ASSESSMENT.md`);
if (existsSync(directPath)) return null;
}
// Also need a summary to reassess against
const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY");
const hasSummary = !!(summaryFile && await loadFile(summaryFile));
@ -553,21 +568,15 @@ export async function checkNeedsRunUat(
const uatContent = await loadFile(uatFile);
if (!uatContent) return null;
// If a UAT result already exists, the UAT unit has already run and must not
// be re-dispatched. PASS means progression can continue; any non-PASS verdict
// must be handled by the dispatch table's verdict gate, which stops progression
// with a human-action message instead of replaying the same run-uat unit.
// If UAT result already exists, skip (idempotent)
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
if (uatResultFile) {
const resultContent = await loadFile(uatResultFile);
if (resultContent) return null;
const hasResult = !!(await loadFile(uatResultFile));
if (hasResult) return null;
}
// Classify UAT type; skip non-artifact-driven types — auto-mode can only
// execute mechanical checks. Non-artifact UATs are tracked in the dashboard
// but don't block auto-mode progression.
// Classify UAT type; unknown type → treat as human-experience (human review)
const uatType = extractUatType(uatContent) ?? "human-experience";
if (uatType !== "artifact-driven") return null;
return { sliceId: sid, uatType };
}
@ -590,7 +599,7 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string
if (knowledgeInlineRM) inlined.push(knowledgeInlineRM);
inlined.push(inlineTemplate("research", "Research"));
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
return loadPrompt("research-milestone", {
@ -618,8 +627,14 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
const { inlinePriorMilestoneSummary } = await import("./files.js");
const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base);
if (priorSummaryInline) inlined.push(priorSummaryInline);
const sourceFilePaths = buildSourceFileList(base, { includeProject: true });
if (inlineLevel !== "minimal") {
const projectInline = await inlineProjectFromDb(base);
if (projectInline) inlined.push(projectInline);
const requirementsInline = await inlineRequirementsFromDb(base, undefined, inlineLevel);
if (requirementsInline) inlined.push(requirementsInline);
const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel);
if (decisionsInline) inlined.push(decisionsInline);
}
const knowledgeInlinePM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
if (knowledgeInlinePM) inlined.push(knowledgeInlinePM);
inlined.push(inlineTemplate("roadmap", "Roadmap"));
@ -634,22 +649,22 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
inlined.push(inlineTemplate("task-plan", "Task Plan"));
}
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
const researchOutputPath = join(base, relMilestoneFile(base, mid, "RESEARCH"));
const secretsOutputPath = join(base, relMilestoneFile(base, mid, "SECRETS"));
const researchOutputRelPath = relMilestoneFile(base, mid, "RESEARCH");
return loadPrompt("plan-milestone", {
workingDirectory: base,
milestoneId: mid, milestoneTitle: midTitle,
milestonePath: relMilestonePath(base, mid),
contextPath: contextRel,
researchPath: researchRel,
researchOutputPath,
outputPath: join(base, outputRelPath),
secretsOutputPath,
inlinedContext,
sourceFilePaths,
researchOutputPath: join(base, researchOutputRelPath),
sourceFilePaths: buildSourceFilePaths(base, mid),
...buildSkillDiscoveryVars(),
});
}
@ -683,7 +698,7 @@ export async function buildResearchSlicePrompt(
const overridesInline = formatOverridesSection(activeOverrides);
if (overridesInline) inlined.unshift(overridesInline);
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
return loadPrompt("research-slice", {
@ -713,8 +728,12 @@ export async function buildPlanSlicePrompt(
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research");
if (researchInline) inlined.push(researchInline);
const sliceSourceFilePaths = buildSourceFileList(base);
if (inlineLevel !== "minimal") {
const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel);
if (decisionsInline) inlined.push(decisionsInline);
const requirementsInline = await inlineRequirementsFromDb(base, sid, inlineLevel);
if (requirementsInline) inlined.push(requirementsInline);
}
const knowledgeInlinePS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
if (knowledgeInlinePS) inlined.push(knowledgeInlinePS);
inlined.push(inlineTemplate("plan", "Slice Plan"));
@ -727,13 +746,17 @@ export async function buildPlanSlicePrompt(
const planOverridesInline = formatOverridesSection(planActiveOverrides);
if (planOverridesInline) inlined.unshift(planOverridesInline);
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
// Build executor context constraints from the budget engine
const executorContextConstraints = formatExecutorConstraints();
const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
const commitInstruction = "Do not commit planning artifacts — .gsd/ is managed externally.";
const prefs = loadEffectiveGSDPreferences();
const commitDocsEnabled = prefs?.preferences?.git?.commit_docs !== false;
const commitInstruction = commitDocsEnabled
? `Commit the plan files only: \`git add ${relSlicePath(base, mid, sid)}/ .gsd/DECISIONS.md .gitignore && git commit -m "docs(${sid}): add slice plan"\`. Do not stage .gsd/STATE.md or other runtime files — the system manages those.`
: "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,
@ -743,9 +766,9 @@ export async function buildPlanSlicePrompt(
outputPath: join(base, outputRelPath),
inlinedContext,
dependencySummaries: depContent,
sourceFilePaths: buildSourceFilePaths(base, mid, sid),
executorContextConstraints,
commitInstruction,
sourceFilePaths: sliceSourceFilePaths,
});
}
@ -902,7 +925,7 @@ export async function buildCompleteSlicePrompt(
const completeOverridesInline = formatOverridesSection(completeActiveOverrides);
if (completeOverridesInline) inlined.unshift(completeOverridesInline);
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const sliceRel = relSlicePath(base, mid, sid);
const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`);
@ -961,7 +984,7 @@ export async function buildCompleteMilestonePrompt(
if (contextInline) inlined.push(contextInline);
inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const milestoneSummaryPath = join(base, `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`);
@ -1032,7 +1055,7 @@ export async function buildValidateMilestonePrompt(
const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
if (contextInline) inlined.push(contextInline);
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const validationOutputPath = join(base, `${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`);
const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`;
@ -1086,7 +1109,7 @@ export async function buildReplanSlicePrompt(
const replanOverridesInline = formatOverridesSection(replanActiveOverrides);
if (replanOverridesInline) inlined.unshift(replanOverridesInline);
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const replanPath = join(base, `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`);
@ -1119,7 +1142,7 @@ export async function buildReplanSlicePrompt(
}
export async function buildRunUatPrompt(
mid: string, sliceId: string, uatPath: string, base: string,
mid: string, sliceId: string, uatPath: string, uatContent: string, base: string,
): Promise<string> {
const inlined: string[] = [];
inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`));
@ -1134,9 +1157,10 @@ export async function buildRunUatPrompt(
const projectInline = await inlineProjectFromDb(base);
if (projectInline) inlined.push(projectInline);
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT-RESULT"));
const uatType = extractUatType(uatContent) ?? "human-experience";
return loadPrompt("run-uat", {
workingDirectory: base,
@ -1144,6 +1168,7 @@ export async function buildRunUatPrompt(
sliceId,
uatPath,
uatResultPath,
uatType,
inlinedContext,
});
}
@ -1171,7 +1196,7 @@ export async function buildReassessRoadmapPrompt(
const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
if (knowledgeInlineRA) inlined.push(knowledgeInlineRA);
const inlinedContext = buildInlinedContextSection(inlined);
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
const assessmentPath = join(base, relSliceFile(base, mid, completedSliceId, "ASSESSMENT"));
@ -1189,7 +1214,11 @@ export async function buildReassessRoadmapPrompt(
// Non-fatal — captures module may not be available
}
const reassessCommitInstruction = "Do not commit planning artifacts — .gsd/ is managed externally.";
const reassessPrefs = loadEffectiveGSDPreferences();
const reassessCommitDocsEnabled = reassessPrefs?.preferences?.git?.commit_docs !== false;
const reassessCommitInstruction = reassessCommitDocsEnabled
? `Commit: \`docs(${mid}): reassess roadmap after ${completedSliceId}\`. Stage only the .gsd/milestones/ files you changed — do not stage .gsd/STATE.md or other runtime files.`
: "Do not commit — planning docs are not tracked in git for this project.";
return loadPrompt("reassess-roadmap", {
workingDirectory: base,

View file

@ -1,6 +1,6 @@
/**
* Auto-mode Recovery artifact resolution, verification, blocker placeholders,
* skip artifacts, completed-unit persistence, merge state reconciliation,
* skip artifacts, merge state reconciliation,
* self-heal runtime records, and loop remediation steps.
*
* Pure functions that receive all needed state as parameters no module-level
@ -8,10 +8,9 @@
*/
import type { ExtensionContext } from "@gsd/pi-coding-agent";
import {
clearUnitRuntimeRecord,
} from "./unit-runtime.js";
import { clearUnitRuntimeRecord } from "./unit-runtime.js";
import { clearParseCache, parseRoadmap, parsePlan } from "./files.js";
import { isValidationTerminal } from "./state.js";
import {
nativeConflictFiles,
nativeCommit,
@ -35,22 +34,29 @@ import {
resolveMilestoneFile,
clearPathCache,
resolveGsdRootFile,
gsdRoot,
} from "./paths.js";
import { isValidationTerminal } from "./state.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { atomicWriteSync } from "./atomic-write.js";
import { loadJsonFileOrNull } from "./json-persistence.js";
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { parseUnitId } from "./unit-id.js";
// ─── Artifact Resolution & Verification ───────────────────────────────────────
/**
* Resolve the expected artifact for a unit to an absolute path.
*/
export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
export function resolveExpectedArtifactPath(
unitType: string,
unitId: string,
base: string,
): string | null {
const parts = unitId.split("/");
const mid = parts[0]!;
const sid = parts[1];
switch (unitType) {
case "research-milestone": {
const dir = resolveMilestonePath(base, mid);
@ -77,8 +83,11 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
}
case "execute-task": {
const tid = parts[2];
const dir = resolveSlicePath(base, mid, sid!);
return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
return dir && tid
? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY"))
: null;
}
case "complete-slice": {
const dir = resolveSlicePath(base, mid, sid!);
@ -112,7 +121,11 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
* the summary allowed the unit to be marked complete when the LLM
* skipped writing the UAT file (see #176).
*/
export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
export function verifyExpectedArtifact(
unitType: string,
unitId: string,
base: string,
): boolean {
// Hook units have no standard artifact — always pass. Their lifecycle
// is managed by the hook engine, not the artifact verification system.
if (unitType.startsWith("hook/")) return true;
@ -138,19 +151,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
if (!absPath) return false;
if (!existsSync(absPath)) return false;
// validate-milestone must have a VALIDATION file with a terminal verdict
// (pass, needs-attention, or needs-remediation). Without this check, a
// VALIDATION file with missing/malformed frontmatter or an unrecognized
// verdict is treated as "complete" by the artifact check but deriveState
// still returns phase:"validating-milestone" (because isValidationTerminal
// returns false), creating an infinite skip loop that hits the lifetime cap.
if (unitType === "validate-milestone") {
try {
const validationContent = readFileSync(absPath, "utf-8");
if (!isValidationTerminal(validationContent)) return false;
} catch {
return false;
}
const validationContent = readFileSync(absPath, "utf-8");
if (!isValidationTerminal(validationContent)) return false;
}
// plan-slice must produce a plan with actual task entries, not just a scaffold.
@ -165,7 +168,10 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
// execute-task must also have its checkbox marked [x] in the slice plan
if (unitType === "execute-task") {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
const tid = parts[2];
if (mid && sid && tid) {
const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
if (planAbs && existsSync(planAbs)) {
@ -182,7 +188,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
// but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task
// to dispatch with a missing task plan (see issue #739).
if (unitType === "plan-slice") {
const { milestone: mid, slice: sid } = parseUnitId(unitId);
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
if (mid && sid) {
try {
const planContent = readFileSync(absPath, "utf-8");
@ -206,8 +214,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
// state machine keeps returning the same complete-slice unit (roadmap still shows
// the slice incomplete), so dispatchNextUnit recurses forever.
if (unitType === "complete-slice") {
const { milestone: mid, slice: sid } = parseUnitId(unitId);
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
if (mid && sid) {
const dir = resolveSlicePath(base, mid, sid);
if (dir) {
@ -221,7 +230,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
try {
const roadmapContent = readFileSync(roadmapFile, "utf-8");
const roadmap = parseRoadmap(roadmapContent);
const slice = (roadmap.slices ?? []).find(s => s.id === sid);
const slice = roadmap.slices.find((s) => s.id === sid);
if (slice && !slice.done) return false;
} catch {
// Corrupt/unparseable roadmap — fail verification so the unit
@ -240,7 +249,12 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
* Write a placeholder artifact so the pipeline can advance past a stuck unit.
* Returns the relative path written, or null if the path couldn't be resolved.
*/
export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null {
export function writeBlockerPlaceholder(
unitType: string,
unitId: string,
base: string,
reason: string,
): string | null {
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
if (!absPath) return null;
const dir = dirname(absPath);
@ -259,8 +273,14 @@ export function writeBlockerPlaceholder(unitType: string, unitId: string, base:
return diagnoseExpectedArtifact(unitType, unitId, base);
}
export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
export function diagnoseExpectedArtifact(
unitType: string,
unitId: string,
base: string,
): string | null {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
switch (unitType) {
case "research-milestone":
return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
@ -271,6 +291,7 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base:
case "plan-slice":
return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
case "execute-task": {
const tid = parts[2];
return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
}
case "complete-slice":
@ -299,9 +320,13 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base:
* the [x] checkbox in the slice plan. Returns true if artifacts were written.
*/
export function skipExecuteTask(
base: string, mid: string, sid: string, tid: string,
base: string,
mid: string,
sid: string,
tid: string,
status: { summaryExists: boolean; taskChecked: boolean },
reason: string, maxAttempts: number,
reason: string,
maxAttempts: number,
): boolean {
// Write a blocker task summary if missing.
if (!status.summaryExists) {
@ -343,48 +368,6 @@ export function skipExecuteTask(
return true;
}
// ─── Disk-backed completed-unit helpers ───────────────────────────────────────
function isStringArray(data: unknown): data is string[] {
return Array.isArray(data) && data.every(item => typeof item === "string");
}
/** Path to the persisted completed-unit keys file. */
export function completedKeysPath(base: string): string {
return join(gsdRoot(base), "completed-units.json");
}
/** Write a completed unit key to disk (read-modify-write append to set). */
export function persistCompletedKey(base: string, key: string): void {
const file = completedKeysPath(base);
const keys = loadJsonFileOrNull(file, isStringArray) ?? [];
const keySet = new Set(keys);
if (!keySet.has(key)) {
keys.push(key);
atomicWriteSync(file, JSON.stringify(keys));
}
}
/** Remove a stale completed unit key from disk. */
export function removePersistedKey(base: string, key: string): void {
const file = completedKeysPath(base);
const keys = loadJsonFileOrNull(file, isStringArray);
if (!keys) return;
const filtered = keys.filter(k => k !== key);
if (filtered.length !== keys.length) {
atomicWriteSync(file, JSON.stringify(filtered));
}
}
/** Load all completed unit keys from disk into the in-memory set. */
export function loadPersistedKeys(base: string, target: Set<string>): void {
const file = completedKeysPath(base);
const keys = loadJsonFileOrNull(file, isStringArray);
if (keys) {
for (const k of keys) target.add(k);
}
}
// ─── Merge State Reconciliation ───────────────────────────────────────────────
/**
@ -394,7 +377,10 @@ export function loadPersistedKeys(base: string, target: Set<string>): void {
*
* Returns true if state was dirty and re-derivation is needed.
*/
export function reconcileMergeState(basePath: string, ctx: ExtensionContext): boolean {
export function reconcileMergeState(
basePath: string,
ctx: ExtensionContext,
): boolean {
const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
const hasMergeHead = existsSync(mergeHeadPath);
@ -405,7 +391,7 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
if (conflictedFiles.length === 0) {
// All conflicts resolved — finalize the merge/squash commit
try {
nativeCommit(basePath, ""); // --no-edit equivalent: use empty message placeholder
nativeCommit(basePath, ""); // --no-edit equivalent: use empty message placeholder
const mode = hasMergeHead ? "merge" : "squash commit";
ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
} catch {
@ -413,8 +399,8 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
}
} else {
// Still conflicted — try auto-resolving .gsd/ state file conflicts (#530)
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/"));
const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/"));
const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/"));
const codeConflicts = conflictedFiles.filter((f) => !f.startsWith(".gsd/"));
if (gsdConflicts.length > 0 && codeConflicts.length === 0) {
// All conflicts are in .gsd/ state files — auto-resolve by accepting theirs
@ -427,7 +413,10 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
}
if (resolved) {
try {
nativeCommit(basePath, "chore: auto-resolve .gsd/ state file conflicts");
nativeCommit(
basePath,
"chore: auto-resolve .gsd/ state file conflicts",
);
ctx.ui.notify(
`Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`,
"info",
@ -438,11 +427,23 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
}
if (!resolved) {
if (hasMergeHead) {
try { nativeMergeAbort(basePath); } catch { /* best-effort */ }
try {
nativeMergeAbort(basePath);
} catch {
/* best-effort */
}
} else if (hasSquashMsg) {
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
try {
unlinkSync(squashMsgPath);
} catch {
/* best-effort */
}
}
try {
nativeResetHard(basePath);
} catch {
/* best-effort */
}
try { nativeResetHard(basePath); } catch { /* best-effort */ }
ctx.ui.notify(
"Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.",
"warning",
@ -451,11 +452,23 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
} else {
// Code conflicts present — abort and reset
if (hasMergeHead) {
try { nativeMergeAbort(basePath); } catch { /* best-effort */ }
try {
nativeMergeAbort(basePath);
} catch {
/* best-effort */
}
} else if (hasSquashMsg) {
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
try {
unlinkSync(squashMsgPath);
} catch {
/* best-effort */
}
}
try {
nativeResetHard(basePath);
} catch {
/* best-effort */
}
try { nativeResetHard(basePath); } catch { /* best-effort */ }
ctx.ui.notify(
"Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.",
"warning",
@ -468,14 +481,14 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
// ─── Self-Heal Runtime Records ────────────────────────────────────────────────
/**
* Self-heal: scan runtime records in .gsd/ and clear any where the expected
* artifact already exists on disk. This repairs incomplete closeouts from
* prior crashes preventing spurious re-dispatch of already-completed units.
* Self-heal: scan runtime records in .gsd/ and clear stale ones.
* Clears dispatched records older than 1 hour (process crashed before
* completing the unit). deriveState() handles re-derivation no need
* for completion key persistence here.
*/
export async function selfHealRuntimeRecords(
base: string,
ctx: ExtensionContext,
completedKeySet: Set<string>,
): Promise<void> {
try {
const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
@ -485,26 +498,8 @@ export async function selfHealRuntimeRecords(
const now = Date.now();
for (const record of records) {
const { unitType, unitId } = record;
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
// Case 1: Artifact exists — unit completed but closeout didn't finish.
// Use verifyExpectedArtifact (not just existsSync) so that execute-task
// also checks the plan checkbox is marked [x]. Without this, a task
// whose summary exists but checkbox is unchecked would be incorrectly
// marked as completed, causing deriveState to re-dispatch it endlessly.
if (artifactPath && existsSync(artifactPath) && verifyExpectedArtifact(unitType, unitId, base)) {
clearUnitRuntimeRecord(base, unitType, unitId);
// Also persist completion key if missing
const key = `${unitType}/${unitId}`;
if (!completedKeySet.has(key)) {
persistCompletedKey(base, key);
completedKeySet.add(key);
}
healed++;
continue;
}
// Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed)
// Clear stale dispatched records (dispatched > 1h ago, process crashed)
const age = now - (record.startedAt ?? 0);
if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
clearUnitRuntimeRecord(base, unitType, unitId);
@ -513,7 +508,10 @@ export async function selfHealRuntimeRecords(
}
}
if (healed > 0) {
ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info");
ctx.ui.notify(
`Self-heal: cleared ${healed} stale runtime record(s).`,
"info",
);
}
} catch (e) {
// Non-fatal — self-heal should never block auto-mode start
@ -527,8 +525,15 @@ export async function selfHealRuntimeRecords(
* Build concrete, manual remediation steps for a loop-detected unit failure.
* These are shown when automatic reconciliation is not possible.
*/
export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
export function buildLoopRemediationSteps(
unitType: string,
unitId: string,
base: string,
): string | null {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
const tid = parts[2];
switch (unitType) {
case "execute-task": {
if (!mid || !sid || !tid) break;
@ -544,9 +549,10 @@ export function buildLoopRemediationSteps(unitType: string, unitId: string, base
case "plan-slice":
case "research-slice": {
if (!mid || !sid) break;
const artifactRel = unitType === "plan-slice"
? relSliceFile(base, mid, sid, "PLAN")
: relSliceFile(base, mid, sid, "RESEARCH");
const artifactRel =
unitType === "plan-slice"
? relSliceFile(base, mid, sid, "PLAN")
: relSliceFile(base, mid, sid, "RESEARCH");
return [
` 1. Write ${artifactRel} manually (or with the LLM in interactive mode)`,
` 2. Run \`gsd doctor\` to reconcile .gsd/ state`,

View file

@ -15,61 +15,73 @@ import type {
} from "@gsd/pi-coding-agent";
import { deriveState } from "./state.js";
import { loadFile, getManifestStatus } from "./files.js";
import { loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js";
import { isInsideWorktree, ensureGsdSymlink } from "./repo-identity.js";
import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js";
import { sendDesktopNotification } from "./notifications.js";
import { sendRemoteNotification } from "../remote-questions/notify.js";
import {
gsdRoot,
resolveMilestoneFile,
milestonesDir,
} from "./paths.js";
loadEffectiveGSDPreferences,
resolveSkillDiscoveryMode,
getIsolationMode,
} from "./preferences.js";
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
import { gsdRoot, resolveMilestoneFile, milestonesDir } from "./paths.js";
import { invalidateAllCaches } from "./cache.js";
import { synthesizeCrashRecovery } from "./session-forensics.js";
import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js";
import {
writeLock,
clearLock,
readCrashLock,
formatCrashInfo,
isLockProcessAlive,
} from "./crash-recovery.js";
import {
acquireSessionLock,
updateSessionLock,
releaseSessionLock,
readSessionLockData,
isSessionLockProcessAlive,
updateSessionLock,
} from "./session-lock.js";
import { selfHealRuntimeRecords } from "./auto-recovery.js";
import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
import { nativeIsRepo, nativeInit } from "./native-git-bridge.js";
import { createGitService } from "./git-service.js";
import {
nativeIsRepo,
nativeInit,
nativeAddAll,
nativeCommit,
} from "./native-git-bridge.js";
import { GitServiceImpl } from "./git-service.js";
import {
captureIntegrationBranch,
detectWorktreeName,
setActiveMilestoneId,
} from "./worktree.js";
import {
createAutoWorktree,
enterAutoWorktree,
getAutoWorktreePath,
isInAutoWorktree,
} from "./auto-worktree.js";
import { readResourceVersion } from "./resource-version.js";
import { initMetrics, getLedger } from "./metrics.js";
import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js";
import { readResourceVersion } from "./auto-worktree-sync.js";
import { initMetrics } from "./metrics.js";
import { initRoutingHistory } from "./routing-history.js";
import { restoreHookState, resetHookState, clearPersistedHookState } from "./post-unit-hooks.js";
import { restoreHookState, resetHookState } from "./post-unit-hooks.js";
import { resetProactiveHealing } from "./doctor-proactive.js";
import { snapshotSkills } from "./skill-discovery.js";
import { isDbAvailable } from "./gsd-db.js";
import { loadPersistedKeys } from "./auto-recovery.js";
import { hideFooter } from "./auto-dashboard.js";
import { debugLog, enableDebug, isDebugEnabled, getDebugLogPath } from "./debug-logger.js";
import {
debugLog,
enableDebug,
isDebugEnabled,
getDebugLogPath,
} from "./debug-logger.js";
import type { AutoSession } from "./auto/session.js";
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import {
existsSync,
mkdirSync,
readdirSync,
statSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { getErrorMessage } from "./error-utils.js";
import { parseUnitId } from "./unit-id.js";
import { sep as pathSep } from "node:path";
import type { WorktreeResolver } from "./worktree-resolver.js";
export interface BootstrapDeps {
shouldUseWorktreeIsolation: () => boolean;
registerSigtermHandler: (basePath: string) => void;
lockBase: () => string;
buildResolver: () => WorktreeResolver;
}
/**
@ -89,17 +101,16 @@ export async function bootstrapAutoSession(
requestedStepMode: boolean,
deps: BootstrapDeps,
): Promise<boolean> {
const { shouldUseWorktreeIsolation, registerSigtermHandler, lockBase } = deps;
const {
shouldUseWorktreeIsolation,
registerSigtermHandler,
lockBase,
buildResolver,
} = deps;
// ── Session lock: acquire FIRST, before any state mutation ──────────────
// This is the primary guard against concurrent sessions on the same project.
// Uses OS-level file locking (proper-lockfile) to prevent TOCTOU races.
const lockResult = acquireSessionLock(base);
if (!lockResult.acquired) {
ctx.ui.notify(
`${lockResult.reason}\nStop it with \`kill ${lockResult.existingPid ?? "the other process"}\` before starting a new session.`,
"error",
);
ctx.ui.notify(lockResult.reason, "error");
return false;
}
@ -112,379 +123,442 @@ export async function bootstrapAutoSession(
try {
// Ensure git repo exists
if (!nativeIsRepo(base)) {
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
const mainBranch =
loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
nativeInit(base, mainBranch);
}
// Ensure .gitignore has baseline patterns
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git;
const manageGitignore = gitPrefs?.manage_gitignore;
ensureGitignore(base, { manageGitignore });
if (manageGitignore !== false) untrackRuntimeFiles(base);
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git;
const commitDocs = gitPrefs?.commit_docs;
const manageGitignore = gitPrefs?.manage_gitignore;
ensureGitignore(base, { commitDocs, manageGitignore });
if (manageGitignore !== false) untrackRuntimeFiles(base);
// Migrate legacy in-project .gsd/ to external state directory
recoverFailedMigration(base);
const migration = migrateToExternalState(base);
if (migration.error) {
ctx.ui.notify(`External state migration warning: ${migration.error}`, "warning");
}
// Ensure symlink exists (handles fresh projects and post-migration)
ensureGsdSymlink(base);
// Bootstrap .gsd/ if it doesn't exist
const gsdDir = gsdRoot(base);
if (!existsSync(gsdDir)) {
mkdirSync(join(gsdDir, "milestones"), { recursive: true });
}
// Initialize GitServiceImpl
s.gitService = createGitService(s.basePath);
// Check for crash from previous session (use both old and new lock data).
// Skip if the lock PID matches this process — acquireSessionLock() writes
// to the same auto.lock file before this check, so we'd always false-positive.
const crashLock = readCrashLock(base);
if (crashLock && crashLock.pid !== process.pid) {
// We already hold the session lock, so no concurrent session is running.
// The crash lock is from a dead process — recover context from it.
const recoveredMid = parseUnitId(crashLock.unitId).milestone;
const milestoneAlreadyComplete = recoveredMid
? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
: false;
if (milestoneAlreadyComplete) {
ctx.ui.notify(
`Crash recovery: discarding stale context for ${crashLock.unitId} — milestone ${recoveredMid} is already complete.`,
"info",
);
} else {
const activityDir = join(gsdRoot(base), "activity");
const recovery = synthesizeCrashRecovery(
base, crashLock.unitType, crashLock.unitId,
crashLock.sessionFile, activityDir,
);
if (recovery && recovery.trace.toolCallCount > 0) {
s.pendingCrashRecovery = recovery.prompt;
ctx.ui.notify(
`${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
"warning",
);
} else {
ctx.ui.notify(
`${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
"warning",
);
}
}
clearLock(base);
}
// ── Debug mode ──
if (!isDebugEnabled() && process.env.GSD_DEBUG === "1") {
enableDebug(base);
}
if (isDebugEnabled()) {
const { isNativeParserAvailable } = await import("./native-parser-bridge.js");
debugLog("debug-start", {
platform: process.platform,
arch: process.arch,
node: process.version,
model: ctx.model?.id ?? "unknown",
provider: ctx.model?.provider ?? "unknown",
nativeParser: isNativeParserAvailable(),
cwd: base,
});
ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info");
}
// Invalidate caches before initial state derivation
invalidateAllCaches();
// Clean stale runtime unit files for completed milestones (#887)
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 (e) { debugLog("stale-unit-cleanup-failed", { file, error: getErrorMessage(e) }); }
// Bootstrap .gsd/ if it doesn't exist
const gsdDir = join(base, ".gsd");
if (!existsSync(gsdDir)) {
mkdirSync(join(gsdDir, "milestones"), { recursive: true });
if (commitDocs !== false) {
try {
nativeAddAll(base);
nativeCommit(base, "chore: init gsd");
} catch {
/* nothing to commit */
}
}
}
} catch (e) { debugLog("stale-unit-dir-cleanup-failed", { error: getErrorMessage(e) }); }
let state = await deriveState(base);
// Initialize GitServiceImpl
s.gitService = new GitServiceImpl(
s.basePath,
loadEffectiveGSDPreferences()?.preferences?.git ?? {},
);
// Milestone branch recovery (#601)
let hasSurvivorBranch = false;
if (
state.activeMilestone &&
(state.phase === "pre-planning" || state.phase === "needs-discussion") &&
shouldUseWorktreeIsolation() &&
!detectWorktreeName(base) &&
!isInsideWorktree(base)
) {
const milestoneBranch = `milestone/${state.activeMilestone.id}`;
const { nativeBranchExists } = await import("./native-git-bridge.js");
hasSurvivorBranch = nativeBranchExists(base, milestoneBranch);
if (hasSurvivorBranch) {
ctx.ui.notify(
`Found prior session branch ${milestoneBranch}. Resuming.`,
"info",
);
}
}
if (!hasSurvivorBranch) {
// No active work — start a new milestone via discuss flow
if (!state.activeMilestone || state.phase === "complete") {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches();
const postState = await deriveState(base);
if (postState.activeMilestone && postState.phase !== "complete" && postState.phase !== "pre-planning") {
state = postState;
} else if (postState.activeMilestone && postState.phase === "pre-planning") {
const contextFile = resolveMilestoneFile(base, postState.activeMilestone.id, "CONTEXT");
const hasContext = !!(contextFile && await loadFile(contextFile));
if (hasContext) {
state = postState;
} else {
ctx.ui.notify(
"Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.",
"warning",
);
return releaseLockAndReturn();
}
} else {
// Check for crash from previous session. Skip our own fresh bootstrap lock.
const crashLock = readCrashLock(base);
if (crashLock && crashLock.pid !== process.pid) {
if (isLockProcessAlive(crashLock)) {
ctx.ui.notify(
`Another auto-mode session (PID ${crashLock.pid}) appears to be running.\nStop it with \`kill ${crashLock.pid}\` before starting a new session.`,
"error",
);
return releaseLockAndReturn();
}
const recoveredMid = crashLock.unitId.split("/")[0];
const milestoneAlreadyComplete = recoveredMid
? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
: false;
if (milestoneAlreadyComplete) {
ctx.ui.notify(
`Crash recovery: discarding stale context for ${crashLock.unitId} — milestone ${recoveredMid} is already complete.`,
"info",
);
} else {
const activityDir = join(gsdRoot(base), "activity");
const recovery = synthesizeCrashRecovery(
base,
crashLock.unitType,
crashLock.unitId,
crashLock.sessionFile,
activityDir,
);
if (recovery && recovery.trace.toolCallCount > 0) {
s.pendingCrashRecovery = recovery.prompt;
ctx.ui.notify(
`${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
"warning",
);
} else {
ctx.ui.notify(
`${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
"warning",
);
}
}
clearLock(base);
}
// Active milestone exists but has no roadmap
if (state.phase === "pre-planning") {
const mid = state.activeMilestone!.id;
const contextFile = resolveMilestoneFile(base, mid, "CONTEXT");
const hasContext = !!(contextFile && await loadFile(contextFile));
if (!hasContext) {
// ── Debug mode ──
if (!isDebugEnabled() && process.env.GSD_DEBUG === "1") {
enableDebug(base);
}
if (isDebugEnabled()) {
const { isNativeParserAvailable } =
await import("./native-parser-bridge.js");
debugLog("debug-start", {
platform: process.platform,
arch: process.arch,
node: process.version,
model: ctx.model?.id ?? "unknown",
provider: ctx.model?.provider ?? "unknown",
nativeParser: isNativeParserAvailable(),
cwd: base,
});
ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info");
}
// Invalidate caches before initial state derivation
invalidateAllCaches();
// Clean stale runtime unit files for completed milestones (#887)
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 (e) {
debugLog("stale-unit-cleanup-failed", {
file,
error: e instanceof Error ? e.message : String(e),
});
}
}
}
}
} catch (e) {
debugLog("stale-unit-dir-cleanup-failed", {
error: e instanceof Error ? e.message : String(e),
});
}
let state = await deriveState(base);
// Stale worktree state recovery (#654)
if (
state.activeMilestone &&
shouldUseWorktreeIsolation() &&
!detectWorktreeName(base)
) {
const wtPath = getAutoWorktreePath(base, state.activeMilestone.id);
if (wtPath) {
state = await deriveState(wtPath);
}
}
// Milestone branch recovery (#601)
let hasSurvivorBranch = false;
if (
state.activeMilestone &&
(state.phase === "pre-planning" || state.phase === "needs-discussion") &&
shouldUseWorktreeIsolation() &&
!detectWorktreeName(base) &&
!base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`)
) {
const milestoneBranch = `milestone/${state.activeMilestone.id}`;
const { nativeBranchExists } = await import("./native-git-bridge.js");
hasSurvivorBranch = nativeBranchExists(base, milestoneBranch);
if (hasSurvivorBranch) {
ctx.ui.notify(
`Found prior session branch ${milestoneBranch}. Resuming.`,
"info",
);
}
}
if (!hasSurvivorBranch) {
// No active work — start a new milestone via discuss flow
if (!state.activeMilestone || state.phase === "complete") {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches();
const postState = await deriveState(base);
if (postState.activeMilestone && postState.phase !== "pre-planning") {
if (
postState.activeMilestone &&
postState.phase !== "complete" &&
postState.phase !== "pre-planning"
) {
state = postState;
} else {
ctx.ui.notify(
"Discussion completed but milestone context is still missing. Run /gsd to try again.",
"warning",
} else if (
postState.activeMilestone &&
postState.phase === "pre-planning"
) {
const contextFile = resolveMilestoneFile(
base,
postState.activeMilestone.id,
"CONTEXT",
);
const hasContext = !!(contextFile && (await loadFile(contextFile)));
if (hasContext) {
state = postState;
} else {
ctx.ui.notify(
"Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.",
"warning",
);
return releaseLockAndReturn();
}
} else {
return releaseLockAndReturn();
}
}
}
}
// Unreachable safety check
if (!state.activeMilestone) {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
return releaseLockAndReturn();
}
// Active milestone exists but has no roadmap
if (state.phase === "pre-planning") {
const mid = state.activeMilestone!.id;
const contextFile = resolveMilestoneFile(base, mid, "CONTEXT");
const hasContext = !!(contextFile && (await loadFile(contextFile)));
if (!hasContext) {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
// ── Initialize session state ──
s.active = true;
s.stepMode = requestedStepMode;
s.verbose = verboseMode;
s.cmdCtx = ctx;
s.basePath = base;
s.unitDispatchCount.clear();
s.unitRecoveryCount.clear();
s.unitConsecutiveSkips.clear();
s.lastBudgetAlertLevel = 0;
s.unitLifetimeDispatches.clear();
s.completedKeySet.clear();
loadPersistedKeys(base, s.completedKeySet);
resetHookState();
restoreHookState(base);
resetProactiveHealing();
s.autoStartTime = Date.now();
s.resourceVersionOnStart = readResourceVersion();
s.completedUnits = [];
s.pendingQuickTasks = [];
s.currentUnit = null;
s.currentMilestoneId = state.activeMilestone?.id ?? null;
s.originalModelId = ctx.model?.id ?? null;
s.originalModelProvider = ctx.model?.provider ?? null;
// Register SIGTERM handler
registerSigtermHandler(base);
// Capture integration branch
if (s.currentMilestoneId) {
if (getIsolationMode() !== "none") {
captureIntegrationBranch(base, s.currentMilestoneId);
}
setActiveMilestoneId(base, s.currentMilestoneId);
}
// ── Auto-worktree setup ──
s.originalBasePath = base;
if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isInsideWorktree(base)) {
try {
const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId);
if (existingWtPath) {
const wtPath = enterAutoWorktree(base, s.currentMilestoneId);
s.basePath = wtPath;
s.gitService = createGitService(s.basePath);
ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info");
} else {
const wtPath = createAutoWorktree(base, s.currentMilestoneId);
s.basePath = wtPath;
s.gitService = createGitService(s.basePath);
ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
invalidateAllCaches();
const postState = await deriveState(base);
if (postState.activeMilestone && postState.phase !== "pre-planning") {
state = postState;
} else {
ctx.ui.notify(
"Discussion completed but milestone context is still missing. Run /gsd to try again.",
"warning",
);
return releaseLockAndReturn();
}
}
}
registerSigtermHandler(s.originalBasePath);
} catch (err) {
ctx.ui.notify(
`Auto-worktree setup failed: ${getErrorMessage(err)}. Continuing in project root.`,
"warning",
);
}
}
// ── DB lifecycle ──
const gsdDbPath = join(gsdRoot(s.basePath), "gsd.db");
const gsdDirPath = gsdRoot(s.basePath);
if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) {
const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md"));
const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md"));
const hasMilestones = existsSync(join(gsdDirPath, "milestones"));
if (hasDecisions || hasRequirements || hasMilestones) {
// Unreachable safety check
if (!state.activeMilestone) {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
return releaseLockAndReturn();
}
// ── Initialize session state ──
s.active = true;
s.stepMode = requestedStepMode;
s.verbose = verboseMode;
s.cmdCtx = ctx;
s.basePath = base;
s.unitDispatchCount.clear();
s.unitRecoveryCount.clear();
s.lastBudgetAlertLevel = 0;
s.unitLifetimeDispatches.clear();
resetHookState();
restoreHookState(base);
resetProactiveHealing();
s.autoStartTime = Date.now();
s.resourceVersionOnStart = readResourceVersion();
s.completedUnits = [];
s.pendingQuickTasks = [];
s.currentUnit = null;
s.currentMilestoneId = state.activeMilestone?.id ?? null;
s.originalModelId = ctx.model?.id ?? null;
s.originalModelProvider = ctx.model?.provider ?? null;
// Register SIGTERM handler
registerSigtermHandler(base);
// Capture integration branch
if (s.currentMilestoneId) {
if (getIsolationMode() !== "none") {
captureIntegrationBranch(base, s.currentMilestoneId, { commitDocs });
}
setActiveMilestoneId(base, s.currentMilestoneId);
}
// ── Auto-worktree setup ──
s.originalBasePath = base;
const isUnderGsdWorktrees = (p: string): boolean => {
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
if (p.includes(marker)) return true;
const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
return p.endsWith(worktreesSuffix);
};
if (
s.currentMilestoneId &&
shouldUseWorktreeIsolation() &&
!detectWorktreeName(base) &&
!isUnderGsdWorktrees(base)
) {
buildResolver().enterMilestone(s.currentMilestoneId, {
notify: ctx.ui.notify.bind(ctx.ui),
});
if (s.basePath !== base) {
// Successfully entered worktree — re-register SIGTERM handler at original base
registerSigtermHandler(s.originalBasePath);
}
}
// ── DB lifecycle ──
const gsdDbPath = join(s.basePath, ".gsd", "gsd.db");
const gsdDirPath = join(s.basePath, ".gsd");
if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) {
const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md"));
const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md"));
const hasMilestones = existsSync(join(gsdDirPath, "milestones"));
if (hasDecisions || hasRequirements || hasMilestones) {
try {
const { openDatabase: openDb } = await import("./gsd-db.js");
const { migrateFromMarkdown } = await import("./md-importer.js");
openDb(gsdDbPath);
migrateFromMarkdown(s.basePath);
} catch (err) {
process.stderr.write(
`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`,
);
}
}
}
if (existsSync(gsdDbPath) && !isDbAvailable()) {
try {
const { openDatabase: openDb } = await import("./gsd-db.js");
const { migrateFromMarkdown } = await import("./md-importer.js");
openDb(gsdDbPath);
migrateFromMarkdown(s.basePath);
} catch (err) {
process.stderr.write(`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`);
process.stderr.write(
`gsd-db: failed to open existing database: ${(err as Error).message}\n`,
);
}
}
}
if (existsSync(gsdDbPath) && !isDbAvailable()) {
try {
const { openDatabase: openDb } = await import("./gsd-db.js");
openDb(gsdDbPath);
} catch (err) {
process.stderr.write(`gsd-db: failed to open existing database: ${(err as Error).message}\n`);
// Initialize metrics
initMetrics(s.basePath);
// Initialize routing history
initRoutingHistory(s.basePath);
// Capture session's model at auto-mode start (#650)
const currentModel = ctx.model;
if (currentModel) {
s.autoModeStartModel = {
provider: currentModel.provider,
id: currentModel.id,
};
}
}
// Initialize metrics
initMetrics(s.basePath);
// Initialize routing history
initRoutingHistory(s.basePath);
// Capture session's model at auto-mode start (#650)
const currentModel = ctx.model;
if (currentModel) {
s.autoModeStartModel = { provider: currentModel.provider, id: currentModel.id };
}
// Snapshot installed skills
if (resolveSkillDiscoveryMode() !== "off") {
snapshotSkills();
}
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
ctx.ui.setFooter(hideFooter);
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
const pendingCount = (state.registry ?? []).filter(m => m.status !== 'complete' && m.status !== 'parked').length;
const scopeMsg = pendingCount > 1
? `Will loop through ${pendingCount} milestones.`
: "Will loop until milestone complete.";
ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
// Update lock file with milestone info (OS lock already acquired at bootstrap start)
updateSessionLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0);
writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0);
// Secrets collection gate — pause instead of blocking (#1146)
const mid = state.activeMilestone!.id;
try {
const manifestStatus = await getManifestStatus(base, mid);
if (manifestStatus && manifestStatus.pending.length > 0) {
const pendingKeys = manifestStatus.pending;
const keyList = pendingKeys.map((k: string) => `${k}`).join("\n");
s.paused = true;
s.pausedForSecrets = true;
ctx.ui.notify(
`Auto-mode paused: ${pendingKeys.length} env variable${pendingKeys.length > 1 ? "s" : ""} needed for ${mid}.\n${keyList}\n\nCollect them with /gsd secrets, then resume with /gsd auto.`,
"warning",
);
ctx.ui.setStatus("gsd-auto", "paused");
sendDesktopNotification(
"GSD — Secrets Required",
`${pendingKeys.length} env variable(s) needed for ${mid}. Run /gsd secrets to provide them.`,
"warning",
"attention",
);
// Notify remote channel if configured (one-way — never collect secrets via remote)
sendRemoteNotification(
"GSD — Secrets Required",
`Auto-mode paused: ${pendingKeys.length} env variable(s) needed for ${mid}.\n${keyList}\n\nReturn to the terminal and run /gsd secrets to provide them securely.`,
).catch(() => {}); // fire-and-forget
return false;
// Snapshot installed skills
if (resolveSkillDiscoveryMode() !== "off") {
snapshotSkills();
}
} catch (err) {
ctx.ui.notify(
`Secrets check error: ${getErrorMessage(err)}. Continuing without secrets.`,
"warning",
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
ctx.ui.setFooter(hideFooter);
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
const pendingCount = (state.registry ?? []).filter(
(m) => m.status !== "complete" && m.status !== "parked",
).length;
const scopeMsg =
pendingCount > 1
? `Will loop through ${pendingCount} milestones.`
: "Will loop until milestone complete.";
ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
updateSessionLock(
lockBase(),
"starting",
s.currentMilestoneId ?? "unknown",
0,
);
}
writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0);
// Self-heal: clear stale runtime records
await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet);
// Self-heal: remove stale .git/index.lock
try {
const gitLockFile = join(base, ".git", "index.lock");
if (existsSync(gitLockFile)) {
const lockAge = Date.now() - statSync(gitLockFile).mtimeMs;
if (lockAge > 60_000) {
unlinkSync(gitLockFile);
ctx.ui.notify("Removed stale .git/index.lock from prior crash.", "info");
}
}
} catch (e) { debugLog("git-lock-cleanup-failed", { error: getErrorMessage(e) }); }
// Pre-flight: validate milestone queue
try {
const msDir = join(gsdRoot(base), "milestones");
if (existsSync(msDir)) {
const milestoneIds = readdirSync(msDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
.map(d => d.name.match(/^(M\d{3})/)?.[1] ?? d.name);
if (milestoneIds.length > 1) {
const issues: string[] = [];
for (const id of milestoneIds) {
const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT");
if (draft) issues.push(`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`);
}
if (issues.length > 0) {
ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map(i => `${i}`).join("\n")}`, "warning");
// Secrets collection gate
const mid = state.activeMilestone!.id;
try {
const manifestStatus = await getManifestStatus(base, mid);
if (manifestStatus && manifestStatus.pending.length > 0) {
const result = await collectSecretsFromManifest(base, mid, ctx);
if (
result &&
result.applied &&
result.skipped &&
result.existingSkipped
) {
ctx.ui.notify(
`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
"info",
);
} else {
ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, "info");
ctx.ui.notify("Secrets collection skipped.", "info");
}
}
} catch (err) {
ctx.ui.notify(
`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
"warning",
);
}
// Self-heal: remove stale .git/index.lock
try {
const gitLockFile = join(base, ".git", "index.lock");
if (existsSync(gitLockFile)) {
const lockAge = Date.now() - statSync(gitLockFile).mtimeMs;
if (lockAge > 60_000) {
unlinkSync(gitLockFile);
ctx.ui.notify(
"Removed stale .git/index.lock from prior crash.",
"info",
);
}
}
} catch (e) {
debugLog("git-lock-cleanup-failed", {
error: e instanceof Error ? e.message : String(e),
});
}
// Pre-flight: validate milestone queue
try {
const msDir = join(base, ".gsd", "milestones");
if (existsSync(msDir)) {
const milestoneIds = readdirSync(msDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name))
.map((d) => d.name.match(/^(M\d{3})/)?.[1] ?? d.name);
if (milestoneIds.length > 1) {
const issues: string[] = [];
for (const id of milestoneIds) {
const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT");
if (draft)
issues.push(
`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`,
);
}
if (issues.length > 0) {
ctx.ui.notify(
`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map((i) => `${i}`).join("\n")}`,
"warning",
);
} else {
ctx.ui.notify(
`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`,
"info",
);
}
}
}
} catch {
/* non-fatal */
}
} catch { /* non-fatal */ }
return true;
} catch (err) {

View file

@ -1,221 +0,0 @@
/**
* Stuck detection and loop recovery for auto-mode unit dispatch.
*
* Tracks dispatch counts per unit, enforces lifetime caps, and attempts
* stub/artifact recovery before stopping.
*
* Extracted from dispatchNextUnit() in auto.ts. Returns action values
* instead of calling stopAuto/dispatchNextUnit the caller handles
* control flow.
*/
import type { ExtensionContext } from "@gsd/pi-coding-agent";
import {
inspectExecuteTaskDurability,
} from "./unit-runtime.js";
import {
verifyExpectedArtifact,
diagnoseExpectedArtifact,
skipExecuteTask,
persistCompletedKey,
buildLoopRemediationSteps,
} from "./auto-recovery.js";
import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
import { saveActivityLog } from "./activity-log.js";
import { invalidateAllCaches } from "./cache.js";
import { sendDesktopNotification } from "./notifications.js";
import { debugLog } from "./debug-logger.js";
import {
resolveMilestonePath,
resolveSlicePath,
resolveTasksDir,
buildTaskFileName,
} from "./paths.js";
import {
MAX_UNIT_DISPATCHES,
STUB_RECOVERY_THRESHOLD,
MAX_LIFETIME_DISPATCHES,
} from "./auto/session.js";
import type { AutoSession } from "./auto/session.js";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { parseUnitId } from "./unit-id.js";
export interface StuckContext {
s: AutoSession;
ctx: ExtensionContext;
unitType: string;
unitId: string;
basePath: string;
buildSnapshotOpts: () => CloseoutOptions & Record<string, unknown>;
}
export type StuckResult =
| { action: "proceed" }
| { action: "recovered"; dispatchAgain: true }
| { action: "stop"; reason: string; notifyMessage?: string };
/**
* Check dispatch counts, enforce lifetime cap and MAX_UNIT_DISPATCHES,
* attempt stub/artifact recovery. Returns an action for the caller.
*/
export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckResult> {
const { s, ctx, unitType, unitId, basePath, buildSnapshotOpts } = sctx;
const dispatchKey = `${unitType}/${unitId}`;
const prevCount = s.unitDispatchCount.get(dispatchKey) ?? 0;
// Real dispatch reached — clear the consecutive-skip counter for this unit.
s.unitConsecutiveSkips.delete(dispatchKey);
debugLog("dispatch-unit", {
type: unitType,
id: unitId,
cycle: prevCount + 1,
lifetime: (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1,
});
// Hard lifetime cap — survives counter resets from loop-recovery/self-repair.
const lifetimeCount = (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1;
s.unitLifetimeDispatches.set(dispatchKey, lifetimeCount);
if (lifetimeCount > MAX_LIFETIME_DISPATCHES) {
if (s.currentUnit) {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts());
} else {
saveActivityLog(ctx, s.basePath, unitType, unitId);
}
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
return {
action: "stop",
reason: `Hard loop: ${unitType} ${unitId}`,
notifyMessage: `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
};
}
if (prevCount >= MAX_UNIT_DISPATCHES) {
if (s.currentUnit) {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts());
} else {
saveActivityLog(ctx, s.basePath, unitType, unitId);
}
// Final reconciliation pass for execute-task
if (unitType === "execute-task") {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
if (mid && sid && tid) {
const status = await inspectExecuteTaskDurability(basePath, unitId);
if (status) {
const reconciled = skipExecuteTask(basePath, mid, sid, tid, status, "loop-recovery", prevCount);
if (reconciled && verifyExpectedArtifact(unitType, unitId, basePath)) {
ctx.ui.notify(
`Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`,
"warning",
);
const reconciledKey = `${unitType}/${unitId}`;
persistCompletedKey(basePath, reconciledKey);
s.completedKeySet.add(reconciledKey);
s.unitDispatchCount.delete(dispatchKey);
invalidateAllCaches();
return { action: "recovered", dispatchAgain: true };
}
}
}
}
// General reconciliation: artifact appeared on last attempt
if (verifyExpectedArtifact(unitType, unitId, basePath)) {
ctx.ui.notify(
`Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`,
"info",
);
persistCompletedKey(basePath, dispatchKey);
s.completedKeySet.add(dispatchKey);
s.unitDispatchCount.delete(dispatchKey);
invalidateAllCaches();
return { action: "recovered", dispatchAgain: true };
}
// Last resort for complete-milestone: generate stub summary
if (unitType === "complete-milestone") {
try {
const mPath = resolveMilestonePath(basePath, unitId);
if (mPath) {
const stubPath = join(mPath, `${unitId}-SUMMARY.md`);
if (!existsSync(stubPath)) {
writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`);
ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning");
persistCompletedKey(basePath, dispatchKey);
s.completedKeySet.add(dispatchKey);
s.unitDispatchCount.delete(dispatchKey);
invalidateAllCaches();
return { action: "recovered", dispatchAgain: true };
}
}
} catch { /* non-fatal — fall through to normal stop */ }
}
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
return {
action: "stop",
reason: `Loop: ${unitType} ${unitId}`,
notifyMessage: `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`,
};
}
s.unitDispatchCount.set(dispatchKey, prevCount + 1);
if (prevCount > 0) {
// Adaptive self-repair: each retry attempts a different remediation step.
if (unitType === "execute-task") {
const status = await inspectExecuteTaskDurability(basePath, unitId);
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
if (status && mid && sid && tid) {
if (status.summaryExists && !status.taskChecked) {
const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
if (repaired && verifyExpectedArtifact(unitType, unitId, basePath)) {
ctx.ui.notify(
`Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`,
"warning",
);
const repairedKey = `${unitType}/${unitId}`;
persistCompletedKey(basePath, repairedKey);
s.completedKeySet.add(repairedKey);
s.unitDispatchCount.delete(dispatchKey);
invalidateAllCaches();
return { action: "recovered", dispatchAgain: true };
}
} else if (prevCount >= STUB_RECOVERY_THRESHOLD && !status.summaryExists) {
const tasksDir = resolveTasksDir(basePath, mid, sid);
const sDir = resolveSlicePath(basePath, mid, sid);
const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
if (targetDir) {
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY"));
if (!existsSync(summaryPath)) {
const stubContent = [
`# PARTIAL RECOVERY — attempt ${prevCount + 1} of ${MAX_UNIT_DISPATCHES}`,
``,
`Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) has not yet produced a real summary.`,
`This placeholder was written by auto-mode after ${prevCount} dispatch attempts.`,
``,
`The next agent session will retry this task. Replace this file with real work when done.`,
].join("\n");
writeFileSync(summaryPath, stubContent, "utf-8");
ctx.ui.notify(
`Stub recovery (attempt ${prevCount + 1}/${MAX_UNIT_DISPATCHES}): ${unitId} stub summary placeholder written. Retrying with recovery context.`,
"warning",
);
}
}
}
}
}
ctx.ui.notify(
`${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`,
"warning",
);
}
return { action: "proceed" };
}

View file

@ -1,17 +1,16 @@
/**
* Auto-mode Supervisor signal handling and working-tree activity detection.
* Auto-mode Supervisor SIGTERM handling and working-tree activity detection.
*
* Pure functions no module-level globals or AutoContext dependency.
*/
import { clearLock } from "./crash-recovery.js";
import { releaseSessionLock } from "./session-lock.js";
import { nativeHasChanges } from "./native-git-bridge.js";
// ─── Signal Handling ─────────────────────────────────────────────────────────
// ─── SIGTERM Handling ─────────────────────────────────────────────────────────
/**
* Register SIGTERM and SIGINT handlers that clear lock files and exit cleanly.
* Register a SIGTERM handler that clears the lock file and exits cleanly.
* Captures the active base path at registration time so the handler
* always references the correct path even if the module variable changes.
* Removes any previously registered handler before installing the new one.
@ -22,25 +21,19 @@ export function registerSigtermHandler(
currentBasePath: string,
previousHandler: (() => void) | null,
): () => void {
if (previousHandler) {
process.off("SIGTERM", previousHandler);
process.off("SIGINT", previousHandler);
}
if (previousHandler) process.off("SIGTERM", previousHandler);
const handler = () => {
releaseSessionLock(currentBasePath);
clearLock(currentBasePath);
process.exit(0);
};
process.on("SIGTERM", handler);
process.on("SIGINT", handler);
return handler;
}
/** Deregister signal handlers (called on stop/pause). */
/** Deregister the SIGTERM handler (called on stop/pause). */
export function deregisterSigtermHandler(handler: (() => void) | null): void {
if (handler) {
process.off("SIGTERM", handler);
process.off("SIGINT", handler);
}
}

View file

@ -18,14 +18,14 @@ import {
writeBlockerPlaceholder,
} from "./auto-recovery.js";
import { existsSync } from "node:fs";
import { parseUnitId } from "./unit-id.js";
import { resolveAgentEnd } from "./auto-loop.js";
export interface RecoveryContext {
basePath: string;
verbose: boolean;
currentUnitStartedAt: number;
unitRecoveryCount: Map<string, number>;
dispatchNextUnit: (ctx: ExtensionContext, pi: ExtensionAPI) => Promise<void>;
}
export async function recoverTimedOutUnit(
@ -36,7 +36,7 @@ export async function recoverTimedOutUnit(
reason: "idle" | "hard",
rctx: RecoveryContext,
): Promise<"recovered" | "paused"> {
const { basePath, verbose, currentUnitStartedAt, unitRecoveryCount, dispatchNextUnit } = rctx;
const { basePath, verbose, currentUnitStartedAt, unitRecoveryCount } = rctx;
const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
@ -75,7 +75,7 @@ export async function recoverTimedOutUnit(
"info",
);
unitRecoveryCount.delete(recoveryKey);
await dispatchNextUnit(ctx, pi);
resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any);
return "recovered";
}
@ -129,7 +129,7 @@ export async function recoverTimedOutUnit(
// Retries exhausted — write missing durable artifacts and advance.
const diagnostic = formatExecuteTaskRecoveryStatus(status);
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
const [mid, sid, tid] = unitId.split("/");
const skipped = mid && sid && tid
? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
: false;
@ -146,7 +146,7 @@ export async function recoverTimedOutUnit(
"warning",
);
unitRecoveryCount.delete(recoveryKey);
await dispatchNextUnit(ctx, pi);
resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any);
return "recovered";
}
@ -180,7 +180,7 @@ export async function recoverTimedOutUnit(
"info",
);
unitRecoveryCount.delete(recoveryKey);
await dispatchNextUnit(ctx, pi);
resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any);
return "recovered";
}
@ -249,7 +249,7 @@ export async function recoverTimedOutUnit(
"warning",
);
unitRecoveryCount.delete(recoveryKey);
await dispatchNextUnit(ctx, pi);
resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any);
return "recovered";
}

View file

@ -2,7 +2,7 @@
* Unit supervision timers soft timeout warning, idle watchdog,
* hard timeout, and context-pressure monitor.
*
* Extracted from dispatchNextUnit() in auto.ts. All timers are set up
* Originally extracted from dispatchNextUnit() in auto.ts (now deleted replaced by autoLoop).
* via startUnitSupervision() and torn down by the caller via clearUnitTimeout().
*/
@ -20,7 +20,6 @@ import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
import { saveActivityLog } from "./activity-log.js";
import { recoverTimedOutUnit, type RecoveryContext } from "./auto-timeout-recovery.js";
import type { AutoSession } from "./auto/session.js";
import { getErrorMessage } from "./error-utils.js";
export interface SupervisionContext {
s: AutoSession;
@ -128,7 +127,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
);
await pauseAuto(ctx, pi);
} catch (err) {
const message = getErrorMessage(err);
const message = err instanceof Error ? err.message : String(err);
console.error(`[idle-watchdog] Unhandled error: ${message}`);
try {
ctx.ui.notify(`Idle watchdog error: ${message}`, "warning");
@ -160,7 +159,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
);
await pauseAuto(ctx, pi);
} catch (err) {
const message = getErrorMessage(err);
const message = err instanceof Error ? err.message : String(err);
console.error(`[hard-timeout] Unhandled error: ${message}`);
try {
ctx.ui.notify(`Hard timeout error: ${message}`, "warning");

View file

@ -21,11 +21,8 @@ import {
runDependencyAudit,
} from "./verification-gate.js";
import { writeVerificationJSON } from "./verification-evidence.js";
import { removePersistedKey } from "./auto-recovery.js";
import type { AutoSession, PendingVerificationRetry } from "./auto/session.js";
import type { AutoSession } from "./auto/session.js";
import { join } from "node:path";
import { getErrorMessage } from "./error-utils.js";
import { parseUnitId } from "./unit-id.js";
export interface VerificationContext {
s: AutoSession;
@ -35,17 +32,21 @@ export interface VerificationContext {
export type VerificationResult = "continue" | "retry" | "pause";
function isInfraVerificationFailure(stderr: string): boolean {
return /\b(ENOENT|ENOTFOUND|ETIMEDOUT|ECONNRESET|EAI_AGAIN|spawn\s+\S+\s+ENOENT|command not found)\b/i.test(
stderr,
);
}
/**
* Run the verification gate for the current execute-task unit.
* Returns:
* - "continue" gate passed (or no checks configured), proceed normally
* - "retry" gate failed with retries remaining, dispatchNextUnit already called
* - "retry" gate failed with retries remaining, s.pendingVerificationRetry set for loop re-iteration
* - "pause" gate failed with retries exhausted, pauseAuto already called
*/
export async function runPostUnitVerification(
vctx: VerificationContext,
dispatchNextUnit: (ctx: ExtensionContext, pi: ExtensionAPI) => Promise<void>,
startDispatchGapWatchdog: (ctx: ExtensionContext, pi: ExtensionAPI) => void,
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>,
): Promise<VerificationResult> {
const { s, ctx, pi } = vctx;
@ -59,15 +60,16 @@ export async function runPostUnitVerification(
const prefs = effectivePrefs?.preferences;
// Read task plan verify field
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
const parts = s.currentUnit.id.split("/");
let taskPlanVerify: string | undefined;
if (mid && sid && tid) {
if (parts.length >= 3) {
const [mid, sid, tid] = parts;
const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
if (planFile) {
const planContent = await loadFile(planFile);
if (planContent) {
const slicePlan = parsePlan(planContent);
const taskEntry = slicePlan?.tasks?.find(t => t.id === tid);
const taskEntry = slicePlan?.tasks?.find((t) => t.id === tid);
taskPlanVerify = taskEntry?.verify;
}
}
@ -85,7 +87,7 @@ export async function runPostUnitVerification(
const runtimeErrors = await captureRuntimeErrors();
if (runtimeErrors.length > 0) {
result.runtimeErrors = runtimeErrors;
if (runtimeErrors.some(e => e.blocking)) {
if (runtimeErrors.some((e) => e.blocking)) {
result.passed = false;
}
}
@ -94,7 +96,9 @@ export async function runPostUnitVerification(
const auditWarnings = runDependencyAudit(s.basePath);
if (auditWarnings.length > 0) {
result.auditWarnings = auditWarnings;
process.stderr.write(`verification-gate: ${auditWarnings.length} audit warning(s)\n`);
process.stderr.write(
`verification-gate: ${auditWarnings.length} audit warning(s)\n`,
);
for (const w of auditWarnings) {
process.stderr.write(` [${w.severity}] ${w.name}: ${w.title}\n`);
}
@ -102,59 +106,49 @@ export async function runPostUnitVerification(
// Auto-fix retry preferences
const autoFixEnabled = prefs?.verification_auto_fix !== false;
const maxRetries = typeof prefs?.verification_max_retries === "number" ? prefs.verification_max_retries : 2;
const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
const maxRetries =
typeof prefs?.verification_max_retries === "number"
? prefs.verification_max_retries
: 2;
if (result.checks.length > 0) {
const blockingChecks = result.checks.filter(c => c.blocking);
const advisoryChecks = result.checks.filter(c => !c.blocking);
const blockingPassCount = blockingChecks.filter(c => c.exitCode === 0).length;
const advisoryFailCount = advisoryChecks.filter(c => c.exitCode !== 0).length;
const passCount = result.checks.filter((c) => c.exitCode === 0).length;
const total = result.checks.length;
if (result.passed) {
let msg = blockingChecks.length > 0
? `Verification gate: ${blockingPassCount}/${blockingChecks.length} blocking checks passed`
: `Verification gate: passed (no blocking checks)`;
if (advisoryFailCount > 0) {
msg += ` (${advisoryFailCount} advisory warning${advisoryFailCount > 1 ? "s" : ""})`;
}
ctx.ui.notify(msg);
// Log advisory warnings to stderr for visibility
if (advisoryFailCount > 0) {
const advisoryFailures = advisoryChecks.filter(c => c.exitCode !== 0);
process.stderr.write(`verification-gate: ${advisoryFailCount} advisory (non-blocking) failure(s)\n`);
for (const f of advisoryFailures) {
process.stderr.write(` [advisory] ${f.command} exited ${f.exitCode}\n`);
}
}
ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`);
} else {
const blockingFailures = blockingChecks.filter(c => c.exitCode !== 0);
const failNames = blockingFailures.map(f => f.command).join(", ");
const failures = result.checks.filter((c) => c.exitCode !== 0);
const failNames = failures.map((f) => f.command).join(", ");
ctx.ui.notify(`Verification gate: FAILED — ${failNames}`);
process.stderr.write(`verification-gate: ${blockingFailures.length}/${blockingChecks.length} blocking checks failed\n`);
for (const f of blockingFailures) {
process.stderr.write(
`verification-gate: ${total - passCount}/${total} checks failed\n`,
);
for (const f of failures) {
process.stderr.write(` ${f.command} exited ${f.exitCode}\n`);
if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`);
}
if (advisoryFailCount > 0) {
process.stderr.write(`verification-gate: ${advisoryFailCount} additional advisory (non-blocking) failure(s)\n`);
if (f.stderr)
process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`);
}
}
}
// Log blocking runtime errors
if (result.runtimeErrors?.some(e => e.blocking)) {
const blockingErrors = result.runtimeErrors.filter(e => e.blocking);
process.stderr.write(`verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`);
if (result.runtimeErrors?.some((e) => e.blocking)) {
const blockingErrors = result.runtimeErrors.filter((e) => e.blocking);
process.stderr.write(
`verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`,
);
for (const err of blockingErrors) {
process.stderr.write(` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`);
process.stderr.write(
` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`,
);
}
}
// Write verification evidence JSON
const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0;
if (mid && sid && tid) {
if (parts.length >= 3) {
try {
const [mid, sid, tid] = parts;
const sDir = resolveSlicePath(s.basePath, mid, sid);
if (sDir) {
const tasksDir = join(sDir, "tasks");
@ -162,52 +156,48 @@ export async function runPostUnitVerification(
writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id);
} else {
const nextAttempt = attempt + 1;
writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id, nextAttempt, maxRetries);
writeVerificationJSON(
result,
tasksDir,
tid,
s.currentUnit.id,
nextAttempt,
maxRetries,
);
}
}
} catch (evidenceErr) {
process.stderr.write(`verification-evidence: write error — ${(evidenceErr as Error).message}\n`);
process.stderr.write(
`verification-evidence: write error — ${(evidenceErr as Error).message}\n`,
);
}
}
const advisoryFailure =
!result.passed &&
(result.discoverySource === "package-json" ||
result.checks.some((check) =>
isInfraVerificationFailure(check.stderr),
));
if (advisoryFailure) {
s.verificationRetryCount.delete(s.currentUnit.id);
s.pendingVerificationRetry = null;
ctx.ui.notify(
result.discoverySource === "package-json"
? "Verification failed in auto-discovered package.json checks — treating as advisory."
: "Verification failed due to infrastructure/runtime environment issues — treating as advisory.",
"warning",
);
return "continue";
}
// ── Auto-fix retry logic ──
if (result.passed) {
s.verificationRetryCount.delete(s.currentUnit.id);
s.pendingVerificationRetry = null;
return "continue";
}
// Check if all failures are infra errors (ETIMEDOUT, ENOENT, etc.).
// Infra errors are transient OS-level problems the agent cannot fix —
// retrying the entire task is wasteful and creates phantom failures.
const failedChecks = result.checks.filter(c => c.exitCode !== 0);
const allInfraErrors = failedChecks.length > 0 && failedChecks.every(c => c.infraError === true);
if (allInfraErrors) {
const infraNames = failedChecks.map(f => f.command).join(", ");
ctx.ui.notify(`Verification gate: infra error (${infraNames}) — skipping retry, not a code issue`, "warning");
process.stderr.write(`verification-gate: all ${failedChecks.length} failure(s) are infra errors — treating as transient, no retry\n`);
s.verificationRetryCount.delete(s.currentUnit.id);
s.pendingVerificationRetry = null;
return "continue";
}
if (result.discoverySource === "package-json") {
// Auto-discovered checks from package.json may fail on pre-existing errors
// that the current task didn't introduce. Don't trigger the retry loop —
// log a warning and let the task proceed (#1186).
process.stderr.write(
`verification-gate: auto-discovered checks failed (source: package-json) — treating as advisory, not blocking\n`,
);
ctx.ui.notify(
`Verification: auto-discovered checks failed (pre-existing errors likely). Continuing without retry.`,
"warning",
);
s.verificationRetryCount.delete(s.currentUnit.id);
s.pendingVerificationRetry = null;
return "continue";
}
if (autoFixEnabled && attempt + 1 <= maxRetries) {
} else if (autoFixEnabled && attempt + 1 <= maxRetries) {
const nextAttempt = attempt + 1;
s.verificationRetryCount.set(s.currentUnit.id, nextAttempt);
s.pendingVerificationRetry = {
@ -215,17 +205,11 @@ export async function runPostUnitVerification(
failureContext: formatFailureContext(result),
attempt: nextAttempt,
};
ctx.ui.notify(`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning");
s.completedKeySet.delete(completionKey);
removePersistedKey(s.basePath, completionKey);
// Dispatch retry immediately
try {
await dispatchNextUnit(ctx, pi);
} catch (retryDispatchErr) {
const msg = getErrorMessage(retryDispatchErr);
ctx.ui.notify(`Verification retry dispatch error: ${msg}`, "error");
startDispatchGapWatchdog(ctx, pi);
}
ctx.ui.notify(
`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`,
"warning",
);
// Return "retry" — the autoLoop while loop will re-iterate with the retry context
return "retry";
} else {
// Gate failed, retries exhausted
@ -241,7 +225,9 @@ export async function runPostUnitVerification(
}
} catch (err) {
// Gate errors are non-fatal
process.stderr.write(`verification-gate: error — ${(err as Error).message}\n`);
process.stderr.write(
`verification-gate: error — ${(err as Error).message}\n`,
);
return "continue";
}
}

View file

@ -0,0 +1,204 @@
/**
* Worktree project root state synchronization for auto-mode.
*
* When auto-mode runs inside a worktree, dispatch-critical state files
* (.gsd/ metadata) diverge between the worktree (where work happens)
* and the project root (where startAutoMode reads initial state on restart).
* Without syncing, restarting auto-mode reads stale state from the project
* root and re-dispatches already-completed units.
*
* Also contains resource staleness detection and stale worktree escape.
*/
import {
existsSync,
mkdirSync,
readFileSync,
cpSync,
unlinkSync,
readdirSync,
} from "node:fs";
import { join, sep as pathSep } from "node:path";
import { homedir } from "node:os";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
// ─── Project Root → Worktree Sync ─────────────────────────────────────────
/**
* Sync milestone artifacts from project root INTO worktree before deriveState.
* Covers the case where the LLM wrote artifacts to the main repo filesystem
* (e.g. via absolute paths) but the worktree has stale data. Also deletes
* gsd.db in the worktree so it rebuilds from fresh disk state (#853).
* Non-fatal sync failure should never block dispatch.
*/
export function syncProjectRootToWorktree(
projectRoot: string,
worktreePath: string,
milestoneId: string | null,
): void {
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
if (!milestoneId) return;
const prGsd = join(projectRoot, ".gsd");
const wtGsd = join(worktreePath, ".gsd");
// Copy milestone directory from project root to worktree if the project root
// has newer artifacts (e.g. slices that don't exist in the worktree yet)
safeCopyRecursive(
join(prGsd, "milestones", milestoneId),
join(wtGsd, "milestones", milestoneId),
);
// Delete worktree gsd.db so it rebuilds from the freshly synced files.
// Stale DB rows are the root cause of the infinite skip loop (#853).
try {
const wtDb = join(wtGsd, "gsd.db");
if (existsSync(wtDb)) {
unlinkSync(wtDb);
}
} catch {
/* non-fatal */
}
}
// ─── Worktree → Project Root Sync ─────────────────────────────────────────
/**
* Sync dispatch-critical .gsd/ state files from worktree to project root.
* Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
* Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
* Non-fatal sync failure should never block dispatch.
*/
export function syncStateToProjectRoot(
worktreePath: string,
projectRoot: string,
milestoneId: string | null,
): void {
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
if (!milestoneId) return;
const wtGsd = join(worktreePath, ".gsd");
const prGsd = join(projectRoot, ".gsd");
// 1. STATE.md — the quick-glance status used by initial deriveState()
safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true });
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
// Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
safeCopyRecursive(
join(wtGsd, "milestones", milestoneId),
join(prGsd, "milestones", milestoneId),
{ force: true },
);
// 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
// Without this, a crash during a unit leaves the runtime record only in the
// worktree. If the next session resolves basePath before worktree re-entry,
// selfHeal can't find or clear the stale record (#769).
safeCopyRecursive(
join(wtGsd, "runtime", "units"),
join(prGsd, "runtime", "units"),
{ force: true },
);
}
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
* Read the resource version (semver) from the managed-resources manifest.
* Uses gsdVersion instead of syncedAt so that launching a second session
* doesn't falsely trigger staleness (#804).
*/
export function readResourceVersion(): string | null {
const agentDir =
process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
const manifestPath = join(agentDir, "managed-resources.json");
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
return typeof manifest?.gsdVersion === "string"
? manifest.gsdVersion
: null;
} catch {
return null;
}
}
/**
* Check if managed resources have been updated since session start.
* Returns a warning message if stale, null otherwise.
*/
export function checkResourcesStale(
versionOnStart: string | null,
): string | null {
if (versionOnStart === null) return null;
const current = readResourceVersion();
if (current === null) return null;
if (current !== versionOnStart) {
return "GSD resources were updated since this session started. Restart gsd to load the new code.";
}
return null;
}
// ─── Stale Worktree Escape ────────────────────────────────────────────────
/**
* Detect and escape a stale worktree cwd (#608).
*
* After milestone completion + merge, the worktree directory is removed but
* the process cwd may still point inside `.gsd/worktrees/<MID>/`.
* When a new session starts, `process.cwd()` is passed as `base` to startAuto
* and all subsequent writes land in the wrong directory. This function detects
* that scenario and chdir back to the project root.
*
* Returns the corrected base path.
*/
export function escapeStaleWorktree(base: string): string {
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
const idx = base.indexOf(marker);
if (idx === -1) return base;
// base is inside .gsd/worktrees/<something> — extract the project root
const projectRoot = base.slice(0, idx);
try {
process.chdir(projectRoot);
} catch {
// If chdir fails, return the original — caller will handle errors downstream
return base;
}
return projectRoot;
}
/**
* Clean stale runtime unit files for completed milestones.
*
* After restart, stale runtime/units/*.json from prior milestones can
* cause deriveState to resume the wrong milestone (#887). Removes files
* for milestones that have a SUMMARY (fully complete).
*/
export function cleanStaleRuntimeUnits(
gsdRootPath: string,
hasMilestoneSummary: (mid: string) => boolean,
): number {
const runtimeUnitsDir = join(gsdRootPath, "runtime", "units");
if (!existsSync(runtimeUnitsDir)) return 0;
let cleaned = 0;
try {
for (const file of readdirSync(runtimeUnitsDir)) {
if (!file.endsWith(".json")) continue;
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
if (!midMatch) continue;
if (hasMilestoneSummary(midMatch[1])) {
try {
unlinkSync(join(runtimeUnitsDir, file));
cleaned++;
} catch {
/* non-fatal */
}
}
}
} catch {
/* non-fatal */
}
return cleaned;
}

View file

@ -6,24 +6,40 @@
* manages create, enter, detect, and teardown for auto-mode worktrees.
*/
import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync, readdirSync, cpSync, mkdirSync, lstatSync as lstatSyncFn } from "node:fs";
import { isAbsolute, join, sep } from "node:path";
import {
existsSync,
cpSync,
readFileSync,
readdirSync,
mkdirSync,
realpathSync,
unlinkSync,
lstatSync as lstatSyncFn,
} from "node:fs";
import { isAbsolute, join } from "node:path";
import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
import {
copyWorktreeDb,
reconcileWorktreeDb,
isDbAvailable,
} from "./gsd-db.js";
import { atomicWriteSync } from "./atomic-write.js";
import { execSync, execFileSync } from "node:child_process";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
import { gsdRoot } from "./paths.js";
import {
createWorktree,
removeWorktree,
worktreePath,
} from "./worktree-manager.js";
import { detectWorktreeName, resolveGitHeadPath, nudgeGitBranchCache } from "./worktree.js";
import { ensureGsdSymlink } from "./repo-identity.js";
import {
MergeConflictError,
readIntegrationBranch,
} from "./git-service.js";
detectWorktreeName,
resolveGitHeadPath,
nudgeGitBranchCache,
} from "./worktree.js";
import { MergeConflictError, readIntegrationBranch } from "./git-service.js";
import { parseRoadmap } from "./files.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import { gsdRoot } from "./paths.js";
import {
nativeGetCurrentBranch,
nativeWorkingTreeStatus,
@ -38,13 +54,28 @@ import {
nativeBranchDelete,
nativeBranchExists,
} from "./native-git-bridge.js";
import { getErrorMessage } from "./error-utils.js";
// ─── Module State ──────────────────────────────────────────────────────────
/** Original project root before chdir into auto-worktree. */
let originalBase: string | null = null;
function clearProjectRootStateFiles(basePath: string, milestoneId: string): void {
const gsdDir = gsdRoot(basePath);
const transientFiles = [
join(gsdDir, "STATE.md"),
join(gsdDir, "auto.lock"),
join(gsdDir, "milestones", milestoneId, `${milestoneId}-META.json`),
];
for (const file of transientFiles) {
try {
unlinkSync(file);
} catch {
/* non-fatal — file may not exist */
}
}
}
// ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
/**
@ -61,7 +92,10 @@ let originalBase: string | null = null;
* Only adds missing content never overwrites existing files in the worktree
* (the worktree's execution state is authoritative for in-progress work).
*/
export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } {
export function syncGsdStateToWorktree(
mainBasePath: string,
worktreePath_: string,
): { synced: string[] } {
const mainGsd = gsdRoot(mainBasePath);
const wtGsd = gsdRoot(worktreePath_);
const synced: string[] = [];
@ -78,7 +112,13 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
// Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"];
const rootFiles = [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
];
for (const f of rootFiles) {
const src = join(mainGsd, f);
const dst = join(wtGsd, f);
@ -86,7 +126,9 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
try {
cpSync(src, dst);
synced.push(f);
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
}
}
@ -96,9 +138,11 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
if (existsSync(mainMilestonesDir)) {
try {
mkdirSync(wtMilestonesDir, { recursive: true });
const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
.map(d => d.name);
const mainMilestones = readdirSync(mainMilestonesDir, {
withFileTypes: true,
})
.filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name))
.map((d) => d.name);
for (const mid of mainMilestones) {
const srcDir = join(mainMilestonesDir, mid);
@ -109,12 +153,16 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
try {
cpSync(srcDir, dstDir, { recursive: true });
synced.push(`milestones/${mid}/`);
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
} else {
// Milestone directory exists but may be missing files (stale snapshot).
// Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
try {
const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json"));
const srcFiles = readdirSync(srcDir).filter(
(f) => f.endsWith(".md") || f.endsWith(".json"),
);
for (const f of srcFiles) {
const srcFile = join(srcDir, f);
const dstFile = join(dstDir, f);
@ -125,7 +173,9 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
cpSync(srcFile, dstFile);
synced.push(`milestones/${mid}/${f}`);
}
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
}
}
@ -136,12 +186,16 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
try {
cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
synced.push(`milestones/${mid}/slices/`);
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
} else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
// Both exist — sync missing slice directories
const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
const srcSlices = readdirSync(srcSlicesDir, {
withFileTypes: true,
})
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const sid of srcSlices) {
const srcSlice = join(srcSlicesDir, sid);
const dstSlice = join(dstSlicesDir, sid);
@ -149,14 +203,20 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
try {
cpSync(srcSlice, dstSlice, { recursive: true });
synced.push(`milestones/${mid}/slices/${sid}/`);
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
}
}
}
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
}
}
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
}
return { synced };
@ -170,7 +230,11 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
* Only syncs .gsd/milestones/ content root-level files (DECISIONS, REQUIREMENTS, etc.)
* are handled by the merge itself.
*/
export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string, milestoneId: string): { synced: string[] } {
export function syncWorktreeStateBack(
mainBasePath: string,
worktreePath: string,
milestoneId: string,
): { synced: string[] } {
const mainGsd = gsdRoot(mainBasePath);
const wtGsd = gsdRoot(worktreePath);
const synced: string[] = [];
@ -199,40 +263,53 @@ export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string
try {
cpSync(src, dst, { force: true });
synced.push(`milestones/${milestoneId}/${entry.name}`);
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
}
}
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
// Sync slice-level files (summaries, UATs)
const wtSlicesDir = join(wtMilestoneDir, "slices");
const mainSlicesDir = join(mainMilestoneDir, "slices");
if (existsSync(wtSlicesDir)) {
try {
for (const sliceEntry of readdirSync(wtSlicesDir, { withFileTypes: true })) {
for (const sliceEntry of readdirSync(wtSlicesDir, {
withFileTypes: true,
})) {
if (!sliceEntry.isDirectory()) continue;
const sid = sliceEntry.name;
const wtSliceDir = join(wtSlicesDir, sid);
const mainSliceDir = join(mainSlicesDir, sid);
mkdirSync(mainSliceDir, { recursive: true });
for (const fileEntry of readdirSync(wtSliceDir, { withFileTypes: true })) {
for (const fileEntry of readdirSync(wtSliceDir, {
withFileTypes: true,
})) {
if (fileEntry.isFile() && fileEntry.name.endsWith(".md")) {
const src = join(wtSliceDir, fileEntry.name);
const dst = join(mainSliceDir, fileEntry.name);
try {
cpSync(src, dst, { force: true });
synced.push(`milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`);
} catch { /* non-fatal */ }
synced.push(
`milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`,
);
} catch {
/* non-fatal */
}
}
}
}
} catch { /* non-fatal */ }
} catch {
/* non-fatal */
}
}
return { synced };
}
// ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
/**
@ -243,7 +320,11 @@ export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string
* Reads the hook path from git.worktree_post_create in preferences.
* Pass hookPath directly to bypass preference loading (useful for testing).
*/
export function runWorktreePostCreateHook(sourceDir: string, worktreeDir: string, hookPath?: string): string | null {
export function runWorktreePostCreateHook(
sourceDir: string,
worktreeDir: string,
hookPath?: string,
): string | null {
if (hookPath === undefined) {
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
hookPath = prefs?.worktree_post_create;
@ -270,7 +351,7 @@ export function runWorktreePostCreateHook(sourceDir: string, worktreeDir: string
});
return null;
} catch (err) {
const msg = getErrorMessage(err);
const msg = err instanceof Error ? err.message : String(err);
return `Worktree post-create hook failed: ${msg}`;
}
}
@ -291,7 +372,110 @@ export function autoWorktreeBranch(milestoneId: string): string {
* to prevent split-brain.
*/
export function createAutoWorktree(basePath: string, milestoneId: string): string {
/**
* Forward-merge plan checkbox state from the project root into a freshly
* re-attached worktree (#778).
*
* When auto-mode stops via crash (not graceful stop), the milestone branch
* HEAD may be behind the filesystem state at the project root because
* syncStateToProjectRoot() runs after every task completion but the final
* git commit may not have happened before the crash. On restart the worktree
* is re-attached to the branch HEAD, which has [ ] for the crashed task,
* causing verifyExpectedArtifact() to fail and triggering an infinite
* dispatch/skip loop.
*
* Fix: after re-attaching, read every *.md plan file in the milestone
* directory at the project root and apply any [x] checkbox states that are
* ahead of the worktree version (forward-only: never downgrade [x] [ ]).
*
* This is safe because syncStateToProjectRoot() is the authoritative source
* of post-task state at the project root it writes the same [x] the LLM
* produced, then the auto-commit follows. If the commit never happened, the
* filesystem copy is still valid and correct.
*/
function reconcilePlanCheckboxes(
projectRoot: string,
wtPath: string,
milestoneId: string,
): void {
const srcMilestone = join(projectRoot, ".gsd", "milestones", milestoneId);
const dstMilestone = join(wtPath, ".gsd", "milestones", milestoneId);
if (!existsSync(srcMilestone) || !existsSync(dstMilestone)) return;
// Walk all markdown files in the milestone directory (plans, summaries, etc.)
function walkMd(dir: string): string[] {
const results: string[] = [];
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkMd(full));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(full);
}
}
} catch {
/* non-fatal */
}
return results;
}
for (const srcFile of walkMd(srcMilestone)) {
const rel = srcFile.slice(srcMilestone.length);
const dstFile = dstMilestone + rel;
if (!existsSync(dstFile)) continue; // only reconcile existing files
let srcContent: string;
let dstContent: string;
try {
srcContent = readFileSync(srcFile, "utf-8");
dstContent = readFileSync(dstFile, "utf-8");
} catch {
continue;
}
if (srcContent === dstContent) continue;
// Extract all checked task IDs from the source (project root)
// Pattern: - [x] **T<id>: or - [x] **S<id>: (case-insensitive x)
const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm;
const srcChecked = new Set<string>();
for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]);
if (srcChecked.size === 0) continue;
// Forward-apply: replace [ ] → [x] for any IDs that are checked in src
let updated = dstContent;
let changed = false;
for (const id of srcChecked) {
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const uncheckedRe = new RegExp(
`^(- )\\[ \\]( \\*\\*${escapedId}:)`,
"gm",
);
if (uncheckedRe.test(updated)) {
updated = updated.replace(
new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"),
"$1[x]$2",
);
changed = true;
}
}
if (changed) {
try {
atomicWriteSync(dstFile, updated, "utf-8");
} catch {
/* non-fatal */
}
}
}
}
export function createAutoWorktree(
basePath: string,
milestoneId: string,
): string {
const branch = autoWorktreeBranch(milestoneId);
// Check if the milestone branch already exists — it survives auto-mode
@ -303,21 +487,46 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
let info: { name: string; path: string; branch: string; exists: boolean };
if (branchExists) {
// Re-attach worktree to the existing milestone branch (preserving commits)
info = createWorktree(basePath, milestoneId, { branch, reuseExistingBranch: true });
info = createWorktree(basePath, milestoneId, {
branch,
reuseExistingBranch: true,
});
} else {
// Fresh start — create branch from integration branch
const integrationBranch = readIntegrationBranch(basePath, milestoneId) ?? undefined;
info = createWorktree(basePath, milestoneId, { branch, startPoint: integrationBranch });
const integrationBranch =
readIntegrationBranch(basePath, milestoneId) ?? undefined;
info = createWorktree(basePath, milestoneId, {
branch,
startPoint: integrationBranch,
});
}
// Ensure worktree shares external state via symlink
ensureGsdSymlink(info.path);
// Sync .gsd/ state from main repo into the worktree (#1311).
// Even with the symlink, the worktree may have stale git-tracked files
// if .gsd/ is not gitignored. And on fresh create, the milestone files
// created on main since the branch point won't be in the worktree.
syncGsdStateToWorktree(basePath, info.path);
// Copy .gsd/ planning artifacts from the source repo into the new worktree.
// Worktrees are fresh git checkouts — untracked files don't carry over.
// Planning artifacts may be untracked if the project's .gitignore had a
// blanket .gsd/ rule (pre-v2.14.0). Without this copy, auto-mode loops
// on plan-slice because the plan file doesn't exist in the worktree.
//
// IMPORTANT: Skip when re-attaching to an existing branch (#759).
// The branch checkout already has committed artifacts with correct state
// (e.g. [x] for completed slices). Copying from the project root would
// overwrite them with stale data ([ ] checkboxes) because the root is
// not always fully synced.
if (!branchExists) {
copyPlanningArtifacts(basePath, info.path);
} else {
// Re-attaching to an existing branch: forward-merge any plan checkpoint
// state from the project root into the worktree (#778).
//
// If auto-mode stopped via crash, the milestone branch HEAD may lag behind
// the project root filesystem because syncStateToProjectRoot() ran after
// task completion but the auto-commit never fired. On restart the worktree
// is re-created from the branch HEAD (which has [ ] for the crashed task),
// causing verifyExpectedArtifact() to return false → stale-key eviction →
// infinite dispatch/skip loop. Reconciling here ensures the worktree sees
// the same [x] state that syncStateToProjectRoot() wrote to the root.
reconcilePlanCheckboxes(basePath, info.path, milestoneId);
}
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
const hookError = runWorktreePostCreateHook(basePath, info.path);
@ -336,7 +545,7 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
// Don't store originalBase -- caller can retry or clean up.
throw new GSDError(
GSD_IO_ERROR,
`Auto-worktree created at ${info.path} but chdir failed: ${getErrorMessage(err)}`,
`Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
@ -344,6 +553,49 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
return info.path;
}
/**
* Copy .gsd/ planning artifacts from source repo to a new worktree.
* Copies milestones/, DECISIONS.md, REQUIREMENTS.md, PROJECT.md, QUEUE.md,
* STATE.md, KNOWLEDGE.md, and OVERRIDES.md.
* Skips runtime files (auto.lock, metrics.json, etc.) and the worktrees/ dir.
* Best-effort failures are non-fatal since auto-mode can recreate artifacts.
*/
function copyPlanningArtifacts(srcBase: string, wtPath: string): void {
const srcGsd = join(srcBase, ".gsd");
const dstGsd = join(wtPath, ".gsd");
if (!existsSync(srcGsd)) return;
// Copy milestones/ directory (planning files, roadmaps, plans, research)
safeCopyRecursive(join(srcGsd, "milestones"), join(dstGsd, "milestones"), {
force: true,
filter: (src) => !src.endsWith("-META.json"),
});
// Copy top-level planning files
for (const file of [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"QUEUE.md",
"STATE.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
]) {
safeCopy(join(srcGsd, file), join(dstGsd, file), { force: true });
}
// Copy gsd.db if present in source
const srcDb = join(srcGsd, "gsd.db");
const destDb = join(dstGsd, "gsd.db");
if (existsSync(srcDb)) {
try {
copyWorktreeDb(srcDb, destDb);
} catch {
/* non-fatal */
}
}
}
/**
* Teardown an auto-worktree: chdir back to original base, then remove
* the worktree and its branch.
@ -363,12 +615,15 @@ export function teardownAutoWorktree(
} catch (err) {
throw new GSDError(
GSD_IO_ERROR,
`Failed to chdir back to ${originalBasePath} during teardown: ${getErrorMessage(err)}`,
`Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`,
);
}
nudgeGitBranchCache(previousCwd);
removeWorktree(originalBasePath, milestoneId, { branch, deleteBranch: !preserveBranch });
removeWorktree(originalBasePath, milestoneId, {
branch,
deleteBranch: !preserveBranch,
});
}
/**
@ -376,36 +631,13 @@ export function teardownAutoWorktree(
* Checks both module state and git branch prefix.
*/
export function isInAutoWorktree(basePath: string): boolean {
if (!originalBase) return false;
const cwd = process.cwd();
// Primary check: use originalBase if available (fast path)
if (originalBase) {
const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
const wtDir = join(gsdRoot(resolvedBase), "worktrees");
if (!cwd.startsWith(wtDir)) return false;
const branch = nativeGetCurrentBranch(cwd);
return branch.startsWith("milestone/");
}
// Fallback: infer worktree status structurally when originalBase is null
// (happens after session restart where module-level state is lost, #1120).
// Check if cwd is inside a .gsd/worktrees/ directory and has a .git file
// (worktree marker) pointing to the main repo.
const worktreeMarker = join(cwd, ".git");
if (!existsSync(worktreeMarker)) return false;
try {
const stat = statSync(worktreeMarker);
if (stat.isDirectory()) return false; // Main repo has .git dir, not file
// Worktrees have a .git file with "gitdir: ..." pointing to the main repo
const gitContent = readFileSync(worktreeMarker, "utf-8").trim();
if (!gitContent.startsWith("gitdir:")) return false;
// Verify we're inside a GSD-managed worktree
if (!detectWorktreeName(cwd)) return false;
const branch = nativeGetCurrentBranch(cwd);
return branch.startsWith("milestone/");
} catch {
return false;
}
const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
const wtDir = join(resolvedBase, ".gsd", "worktrees");
if (!cwd.startsWith(wtDir)) return false;
const branch = nativeGetCurrentBranch(cwd);
return branch.startsWith("milestone/");
}
/**
@ -416,7 +648,10 @@ export function isInAutoWorktree(basePath: string): boolean {
* gitdir: pointer) rather than just a stray directory. This prevents
* mis-detection of leftover directories as active worktrees (#695).
*/
export function getAutoWorktreePath(basePath: string, milestoneId: string): string | null {
export function getAutoWorktreePath(
basePath: string,
milestoneId: string,
): string | null {
const p = worktreePath(basePath, milestoneId);
if (!existsSync(p)) return null;
@ -440,39 +675,42 @@ export function getAutoWorktreePath(basePath: string, milestoneId: string): stri
*
* Atomic: chdir + originalBase update in same try block.
*/
export function enterAutoWorktree(basePath: string, milestoneId: string): string {
export function enterAutoWorktree(
basePath: string,
milestoneId: string,
): string {
const p = worktreePath(basePath, milestoneId);
if (!existsSync(p)) {
throw new GSDError(GSD_IO_ERROR, `Auto-worktree for ${milestoneId} does not exist at ${p}`);
throw new GSDError(
GSD_IO_ERROR,
`Auto-worktree for ${milestoneId} does not exist at ${p}`,
);
}
// Validate this is a real git worktree, not a stray directory (#695)
const gitPath = join(p, ".git");
if (!existsSync(gitPath)) {
throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} exists but is not a git worktree (no .git)`);
throw new GSDError(
GSD_GIT_ERROR,
`Auto-worktree path ${p} exists but is not a git worktree (no .git)`,
);
}
try {
const content = readFileSync(gitPath, "utf8").trim();
if (!content.startsWith("gitdir: ")) {
throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`);
throw new GSDError(
GSD_GIT_ERROR,
`Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`,
);
}
} catch (err) {
if (err instanceof Error && err.message.includes("worktree")) throw err;
throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
throw new GSDError(
GSD_IO_ERROR,
`Auto-worktree path ${p} exists but .git is unreadable`,
);
}
// Ensure worktree shares external state via symlink (#1311).
// On resume (enterAutoWorktree), the symlink may be missing if it was
// created before ensureGsdSymlink existed, or the .gsd/ directory may be
// a stale git-tracked copy instead of a symlink. Refreshing here ensures
// the worktree sees the same milestone state as the main repo.
ensureGsdSymlink(p);
// Sync .gsd/ state from main repo into worktree (#1311).
// Covers the case where .gsd/ is a real directory (not symlinked) and
// milestones were created on main after the worktree was last used.
syncGsdStateToWorktree(basePath, p);
const previousCwd = process.cwd();
try {
@ -481,7 +719,7 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
} catch (err) {
throw new GSDError(
GSD_IO_ERROR,
`Failed to enter auto-worktree at ${p}: ${getErrorMessage(err)}`,
`Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`,
);
}
@ -504,8 +742,10 @@ export function getActiveAutoWorktreeContext(): {
} | null {
if (!originalBase) return null;
const cwd = process.cwd();
const resolvedBase = existsSync(originalBase) ? realpathSync(originalBase) : originalBase;
const wtDir = join(gsdRoot(resolvedBase), "worktrees");
const resolvedBase = existsSync(originalBase)
? realpathSync(originalBase)
: originalBase;
const wtDir = join(resolvedBase, ".gsd", "worktrees");
if (!cwd.startsWith(wtDir)) return null;
const worktreeName = detectWorktreeName(cwd);
if (!worktreeName) return null;
@ -529,7 +769,10 @@ function autoCommitDirtyState(cwd: string): boolean {
const status = nativeWorkingTreeStatus(cwd);
if (!status) return false;
nativeAddAll(cwd);
const result = nativeCommit(cwd, "chore: auto-commit before milestone merge");
const result = nativeCommit(
cwd,
"chore: auto-commit before milestone merge",
);
return result !== null;
} catch {
return false;
@ -565,59 +808,53 @@ export function mergeMilestoneToMain(
// 1. Auto-commit dirty state in worktree before leaving
autoCommitDirtyState(worktreeCwd);
// Reconcile worktree DB into main DB before leaving worktree context
if (isDbAvailable()) {
try {
const worktreeDbPath = join(worktreeCwd, ".gsd", "gsd.db");
const mainDbPath = join(originalBasePath_, ".gsd", "gsd.db");
reconcileWorktreeDb(mainDbPath, worktreeDbPath);
} catch {
/* non-fatal */
}
}
// 2. Parse roadmap for slice listing
const roadmap = parseRoadmap(roadmapContent);
const completedSlices = roadmap.slices.filter(s => s.done);
const completedSlices = roadmap.slices.filter((s) => s.done);
// 3. chdir to original base
const previousCwd = process.cwd();
process.chdir(originalBasePath_);
// 3a. Auto-commit any dirty state in the project root. Without this, the
// squash merge can fail with "Your local changes would be overwritten" (#1127).
autoCommitDirtyState(originalBasePath_);
// 3b. Remove untracked .gsd/ runtime files that syncStateToProjectRoot copied.
// Only clean specific runtime files — NEVER touch milestones/, decisions, or
// other planning artifacts that represent user work (#1250).
const runtimeFilesToClean = ["STATE.md", "completed-units.json", "auto.lock", "gsd.db"];
for (const f of runtimeFilesToClean) {
const p = join(originalBasePath_, ".gsd", f);
try { if (existsSync(p)) unlinkSync(p); } catch { /* non-fatal */ }
}
try {
const runtimeDir = join(originalBasePath_, ".gsd", "runtime");
if (existsSync(runtimeDir)) rmSync(runtimeDir, { recursive: true, force: true });
} catch { /* non-fatal */ }
// 4. Resolve integration branch — prefer milestone metadata, fall back to preferences / "main"
const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
const integrationBranch = readIntegrationBranch(originalBasePath_, milestoneId);
const integrationBranch = readIntegrationBranch(
originalBasePath_,
milestoneId,
);
const mainBranch = integrationBranch ?? prefs.main_branch ?? "main";
// Remove transient project-root state files before any branch or merge
// operation. Untracked milestone metadata can otherwise block squash merges.
clearProjectRootStateFiles(originalBasePath_, milestoneId);
// 5. Checkout integration branch (skip if already current — avoids git error
// when main is already checked out in the project-root worktree, #757)
const currentBranchAtBase = nativeGetCurrentBranch(originalBasePath_);
if (currentBranchAtBase !== mainBranch) {
// Remove untracked .gsd/ state files that may conflict with the branch
// being checked out. These are regenerated by doctor/rebuildState and
// are not meaningful in the main working tree — the worktree had the
// real state. Without this, `git checkout main` fails with
// "Your local changes would be overwritten" (#827).
const gsdStateFiles = ["STATE.md", "completed-units.json", "auto.lock"];
for (const f of gsdStateFiles) {
const p = join(gsdRoot(originalBasePath_), f);
try { unlinkSync(p); } catch { /* non-fatal — file may not exist */ }
}
nativeCheckoutBranch(originalBasePath_, mainBranch);
}
// 6. Build rich commit message
const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
const milestoneTitle =
roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
const subject = `feat(${milestoneId}): ${milestoneTitle}`;
let body = "";
if (completedSlices.length > 0) {
const sliceLines = completedSlices.map(s => `- ${s.id}: ${s.title}`).join("\n");
const sliceLines = completedSlices
.map((s) => `- ${s.id}: ${s.title}`)
.join("\n");
body = `\n\nCompleted slices:\n${sliceLines}\n\nBranch: ${milestoneBranch}`;
}
const commitMessage = subject + body;
@ -627,17 +864,20 @@ export function mergeMilestoneToMain(
if (!mergeResult.success) {
// Check for conflicts — use merge result first, fall back to nativeConflictFiles
const conflictedFiles = mergeResult.conflicts.length > 0
? mergeResult.conflicts
: nativeConflictFiles(originalBasePath_);
const conflictedFiles =
mergeResult.conflicts.length > 0
? mergeResult.conflicts
: nativeConflictFiles(originalBasePath_);
if (conflictedFiles.length > 0) {
// Separate .gsd/ state file conflicts from real code conflicts.
// GSD state files (STATE.md, completed-units.json, auto.lock, etc.)
// GSD state files (STATE.md, auto.lock, etc.)
// diverge between branches during normal operation — always prefer the
// milestone branch version since it has the latest execution state.
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/"));
const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/"));
const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/"));
const codeConflicts = conflictedFiles.filter(
(f) => !f.startsWith(".gsd/"),
);
// Auto-resolve .gsd/ conflicts by accepting the milestone branch version
if (gsdConflicts.length > 0) {
@ -655,7 +895,12 @@ export function mergeMilestoneToMain(
// If there are still non-.gsd conflicts, escalate
if (codeConflicts.length > 0) {
throw new MergeConflictError(codeConflicts, "squash", milestoneBranch, mainBranch);
throw new MergeConflictError(
codeConflicts,
"squash",
milestoneBranch,
mainBranch,
);
}
}
// No conflicts detected — possibly "already up to date", fall through to commit
@ -710,7 +955,10 @@ export function mergeMilestoneToMain(
// 10. Remove worktree directory first (must happen before branch deletion)
try {
removeWorktree(originalBasePath_, milestoneId, { branch: null as unknown as string, deleteBranch: false });
removeWorktree(originalBasePath_, milestoneId, {
branch: null as unknown as string,
deleteBranch: false,
});
} catch {
// Best-effort -- worktree dir may already be gone
}

File diff suppressed because it is too large Load diff

View file

@ -52,16 +52,28 @@ export interface PendingVerificationRetry {
attempt: number;
}
/**
* A typed item enqueued by postUnitPostVerification for the main loop to
* drain via the standard runUnit path. Replaces inline dispatch
* (pi.sendMessage / s.cmdCtx.newSession()) for hooks, triage, and quick-tasks.
*/
export interface SidecarItem {
kind: "hook" | "triage" | "quick-task";
unitType: string;
unitId: string;
prompt: string;
/** Model override for hook units (e.g. "anthropic/claude-3-5-sonnet"). */
model?: string;
/** Capture ID for quick-task items (already marked executed at enqueue time). */
captureId?: string;
}
// ─── Constants ───────────────────────────────────────────────────────────────
export const MAX_UNIT_DISPATCHES = 3;
export const STUB_RECOVERY_THRESHOLD = 2;
export const MAX_LIFETIME_DISPATCHES = 6;
export const MAX_CONSECUTIVE_SKIPS = 3;
export const DISPATCH_GAP_TIMEOUT_MS = 5_000;
export const MAX_SKIP_DEPTH = 20;
export const NEW_SESSION_TIMEOUT_MS = 30_000;
export const DISPATCH_HANG_TIMEOUT_MS = 60_000;
// ─── AutoSession ─────────────────────────────────────────────────────────────
@ -69,7 +81,6 @@ export class AutoSession {
// ── Lifecycle ────────────────────────────────────────────────────────────
active = false;
paused = false;
pausedForSecrets = false;
stepMode = false;
verbose = false;
cmdCtx: ExtensionCommandContext | null = null;
@ -83,15 +94,12 @@ export class AutoSession {
readonly unitDispatchCount = new Map<string, number>();
readonly unitLifetimeDispatches = new Map<string, number>();
readonly unitRecoveryCount = new Map<string, number>();
readonly unitConsecutiveSkips = new Map<string, number>();
readonly completedKeySet = new Set<string>();
// ── Timers ───────────────────────────────────────────────────────────────
unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
continueHereHandle: ReturnType<typeof setInterval> | null = null;
dispatchGapHandle: ReturnType<typeof setTimeout> | null = null;
// ── Current unit ─────────────────────────────────────────────────────────
currentUnit: CurrentUnit | null = null;
@ -113,12 +121,8 @@ export class AutoSession {
resourceVersionOnStart: string | null = null;
lastStateRebuildAt = 0;
// ── Guards ───────────────────────────────────────────────────────────────
handlingAgentEnd = false;
pendingAgentEndRetry = false;
dispatching = false;
skipDepth = 0;
readonly recentlyEvictedKeys = new Set<string>();
// ── Sidecar queue ─────────────────────────────────────────────────────
sidecarQueue: SidecarItem[] = [];
// ── Metrics ──────────────────────────────────────────────────────────────
autoStartTime = 0;
@ -129,6 +133,29 @@ export class AutoSession {
// ── Signal handler ───────────────────────────────────────────────────────
sigtermHandler: (() => void) | null = null;
// ── Loop promise state ──────────────────────────────────────────────────
/**
* True only while runUnit is rotating into a fresh session. agent_end events
* emitted from the previous session's abort during this window must be
* ignored; they do not belong to the new unit.
*/
sessionSwitchInFlight = false;
/**
* One-shot resolver for the current unit's agent_end promise.
* Non-null only while a unit is in-flight (between sendMessage and agent_end).
* Scoped to the session to prevent concurrent session corruption.
*/
pendingResolve: ((result: { status: "completed" | "cancelled" | "error"; event?: { messages: unknown[] } }) => void) | null = null;
/**
* Queue for agent_end events that arrive when no pendingResolve exists.
* This happens when error-recovery sendMessage retries produce agent_end
* events between loop iterations. The next runUnit drains this queue
* instead of waiting for a new event.
*/
pendingAgentEndQueue: Array<{ messages: unknown[] }> = [];
// ── Methods ──────────────────────────────────────────────────────────────
clearTimers(): void {
@ -136,13 +163,11 @@ export class AutoSession {
if (this.wrapupWarningHandle) { clearTimeout(this.wrapupWarningHandle); this.wrapupWarningHandle = null; }
if (this.idleWatchdogHandle) { clearInterval(this.idleWatchdogHandle); this.idleWatchdogHandle = null; }
if (this.continueHereHandle) { clearInterval(this.continueHereHandle); this.continueHereHandle = null; }
if (this.dispatchGapHandle) { clearTimeout(this.dispatchGapHandle); this.dispatchGapHandle = null; }
}
resetDispatchCounters(): void {
this.unitDispatchCount.clear();
this.unitLifetimeDispatches.clear();
this.unitConsecutiveSkips.clear();
}
get lockBasePath(): string {
@ -163,7 +188,6 @@ export class AutoSession {
// Lifecycle
this.active = false;
this.paused = false;
this.pausedForSecrets = false;
this.stepMode = false;
this.verbose = false;
this.cmdCtx = null;
@ -177,9 +201,6 @@ export class AutoSession {
this.unitDispatchCount.clear();
this.unitLifetimeDispatches.clear();
this.unitRecoveryCount.clear();
this.unitConsecutiveSkips.clear();
// Note: completedKeySet is intentionally NOT cleared — it persists
// across restarts to prevent re-dispatching completed units.
// Unit
this.currentUnit = null;
@ -201,21 +222,20 @@ export class AutoSession {
this.resourceVersionOnStart = null;
this.lastStateRebuildAt = 0;
// Guards
this.handlingAgentEnd = false;
this.pendingAgentEndRetry = false;
this.dispatching = false;
this.skipDepth = 0;
this.recentlyEvictedKeys.clear();
// Metrics
this.autoStartTime = 0;
this.lastPromptCharCount = undefined;
this.lastBaselineCharCount = undefined;
this.pendingQuickTasks = [];
this.sidecarQueue = [];
// Signal handler
this.sigtermHandler = null;
// Loop promise state
this.sessionSwitchInFlight = false;
this.pendingResolve = null;
this.pendingAgentEndQueue = [];
}
toJSON(): Record<string, unknown> {
@ -227,10 +247,7 @@ export class AutoSession {
currentMilestoneId: this.currentMilestoneId,
currentUnit: this.currentUnit,
completedUnits: this.completedUnits.length,
completedKeySet: this.completedKeySet.size,
unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
dispatching: this.dispatching,
skipDepth: this.skipDepth,
};
}
}

View file

@ -9,10 +9,9 @@
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { join, resolve, sep } from "node:path";
import { randomUUID } from "node:crypto";
import { gsdRoot } from "./paths.js";
import { resolveProjectRoot } from "./worktree.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -59,8 +58,15 @@ const VALID_CLASSIFICATIONS: readonly string[] = [
* directory that contains `.gsd/worktrees/` that's the project root.
*/
export function resolveCapturesPath(basePath: string): string {
const projectRoot = resolveProjectRoot(resolve(basePath));
return join(gsdRoot(projectRoot), CAPTURES_FILENAME);
const resolved = resolve(basePath);
const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`;
const idx = resolved.indexOf(worktreeMarker);
if (idx !== -1) {
// basePath is inside a worktree — resolve to project root
const projectRoot = resolved.slice(0, idx);
return join(projectRoot, ".gsd", CAPTURES_FILENAME);
}
return join(gsdRoot(basePath), CAPTURES_FILENAME);
}
// ─── File I/O ─────────────────────────────────────────────────────────────────

View file

@ -5,7 +5,6 @@ import { readdirSync } from "node:fs";
import { resolveMilestoneFile, milestonesDir } from "./paths.js";
import { parseRoadmapSlices } from "./roadmap-slices.js";
import { findMilestoneIds } from "./guided-flow.js";
import { parseUnitId } from "./unit-id.js";
const SLICE_DISPATCH_TYPES = new Set([
"research-slice",
@ -37,10 +36,15 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null {
}
}
export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null {
export function getPriorSliceCompletionBlocker(
base: string,
_mainBranch: string,
unitType: string,
unitId: string,
): string | null {
if (!SLICE_DISPATCH_TYPES.has(unitType)) return null;
const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
const [targetMid, targetSid] = unitId.split("/");
if (!targetMid || !targetSid) return null;
// Use findMilestoneIds to respect custom queue order.
@ -51,9 +55,7 @@ export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string
const milestoneIds = allIds.slice(0, targetIdx + 1);
for (const mid of milestoneIds) {
// Skip parked milestones — they don't block dispatch of later milestones
const parkedFile = resolveMilestoneFile(base, mid, "PARKED");
if (parkedFile) continue;
if (resolveMilestoneFile(base, mid, "PARKED")) continue;
// Read from disk (working tree) — always has the latest state
const roadmapContent = readRoadmapFromDisk(base, mid);
@ -61,17 +63,19 @@ export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string
const slices = parseRoadmapSlices(roadmapContent);
if (mid !== targetMid) {
const incomplete = slices.find(slice => !slice.done);
const incomplete = slices.find((slice) => !slice.done);
if (incomplete) {
return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${mid}/${incomplete.id} is not complete.`;
}
continue;
}
const targetIndex = slices.findIndex(slice => slice.id === targetSid);
const targetIndex = slices.findIndex((slice) => slice.id === targetSid);
if (targetIndex === -1) return null;
const incomplete = slices.slice(0, targetIndex).find(slice => !slice.done);
const incomplete = slices
.slice(0, targetIndex)
.find((slice) => !slice.done);
if (incomplete) {
return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`;
}

View file

@ -21,7 +21,12 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
**Empty arrays (`[]`) are equivalent to omitting the field entirely.** During validation, GSD deletes empty arrays from the preferences object (see `validatePreferences()` in `preferences.ts`):
```typescript
for (const key of ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const) {
for (const key of [
"always_use_skills",
"prefer_skills",
"avoid_skills",
"custom_instructions",
] as const) {
if (validated[key] && validated[key]!.length === 0) {
delete validated[key];
}
@ -50,6 +55,7 @@ Preferences are loaded from two locations and merged:
2. **Project:** `.gsd/preferences.md` — applies to the current project only
**Merge behavior** (see `mergePreferences()` in `preferences.ts`):
- **Scalar fields** (`skill_discovery`, `budget_ceiling`, etc.): Project wins if defined, otherwise global. Uses nullish coalescing (`??`).
- **Array fields** (`always_use_skills`, `prefer_skills`, etc.): Concatenated via `mergeStringLists()` (global first, then project).
- **Object fields** (`models`, `git`, `auto_supervisor`): Shallow merge via spread operator `{ ...base, ...override }`.
@ -60,10 +66,10 @@ For `models`, project settings override global at the phase level. If global has
These are **separate concerns**:
| Field | What it controls | Code reference |
|-------|-----------------|----------------|
| `skill_discovery` | **Whether** GSD looks for relevant skills during research | `resolveSkillDiscoveryMode()` in `preferences.ts` |
| `always_use_skills`, `prefer_skills`, `avoid_skills` | **Which** skills to use when they're found relevant | `renderPreferencesForSystemPrompt()` in `preferences.ts` |
| Field | What it controls | Code reference |
| ---------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------- |
| `skill_discovery` | **Whether** GSD looks for relevant skills during research | `resolveSkillDiscoveryMode()` in `preferences.ts` |
| `always_use_skills`, `prefer_skills`, `avoid_skills` | **Which** skills to use when they're found relevant | `renderPreferencesForSystemPrompt()` in `preferences.ts` |
Setting `prefer_skills: []` does **not** disable skill discovery — it just means you have no preference overrides. Use `skill_discovery: off` to disable discovery entirely.
@ -75,14 +81,14 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
- `mode`: workflow mode — `"solo"` or `"team"`. Sets sensible defaults for git and project settings based on your workflow. Mode defaults are the lowest priority layer — any explicit preference overrides them. Omit to configure everything manually.
| Setting | `solo` | `team` |
|---|---|---|
| `git.auto_push` | `true` | `false` |
| `git.push_branches` | `false` | `true` |
| `git.pre_merge_check` | `false` | `true` |
| `git.merge_strategy` | `"squash"` | `"squash"` |
| `git.isolation` | `"worktree"` | `"worktree"` |
| `unique_milestone_ids` | `false` | `true` |
| Setting | `solo` | `team` |
| ---------------------- | ------------ | ------------ |
| `git.auto_push` | `true` | `false` |
| `git.push_branches` | `false` | `true` |
| `git.pre_merge_check` | `false` | `true` |
| `git.merge_strategy` | `"squash"` | `"squash"` |
| `git.isolation` | `"worktree"` | `"worktree"` |
| `unique_milestone_ids` | `false` | `true` |
Quick setup: `/gsd mode` (global) or `/gsd mode project` (project-level).
@ -141,11 +147,12 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
- `context_pause_threshold`: number (0-100) — context window usage percentage at which auto-mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled).
- `token_profile`: `"budget"`, `"balanced"`, or `"quality"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) runs all phases; `quality` prefers higher-quality models. See token-optimization docs.
- `token_profile`: `"budget"`, `"balanced"`, or `"quality"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models. See token-optimization docs.
- `phases`: fine-grained control over which phases run. Usually set by `token_profile`, but can be overridden. Keys:
- `skip_research`: boolean — skip milestone-level research. Default: `false`.
- `skip_reassess`: boolean — skip roadmap reassessment after each slice. Default: `false`.
- `reassess_after_slice`: boolean — run roadmap reassessment after each completed slice. Default: `false`.
- `skip_reassess`: boolean — force-disable roadmap reassessment even if `reassess_after_slice` is enabled. Default: `false`.
- `skip_slice_research`: boolean — skip per-slice research. Default: `false`.
- `remote_questions`: route interactive questions to Slack/Discord for headless auto-mode. Keys:
@ -359,11 +366,11 @@ If you use a bare model ID (no provider prefix) and it exists in multiple provid
---
version: 1
models:
research: openrouter/deepseek/deepseek-r1 # $0.28/$0.42 per 1M tokens
research: openrouter/deepseek/deepseek-r1 # $0.28/$0.42 per 1M tokens
planning:
model: claude-opus-4-6 # $5/$25 — best for architecture
model: claude-opus-4-6 # $5/$25 — best for architecture
fallbacks:
- openrouter/z-ai/glm-5 # $1/$3.20 — strong alternative
- openrouter/z-ai/glm-5 # $1/$3.20 — strong alternative
execution: openrouter/minimax/minimax-m2.5 # $0.30/$1.20 — cheapest quality
completion: openrouter/minimax/minimax-m2.5
---

View file

@ -314,10 +314,9 @@ export async function checkRuntimeHealth(
});
if (shouldFix("orphaned_completed_units")) {
const { removePersistedKey } = await import("./auto-recovery.js");
for (const key of orphaned) {
removePersistedKey(basePath, key);
}
const orphanedSet = new Set(orphaned);
const remaining = keys.filter((key) => !orphanedSet.has(key));
await saveFile(completedKeysFile, JSON.stringify(remaining));
fixesApplied.push(`removed ${orphaned.length} orphaned completed-unit key(s)`);
}
}

View file

@ -42,6 +42,8 @@ export interface GitPreferences {
push_branches?: boolean;
remote?: string;
snapshots?: boolean;
/** Deprecated. .gsd/ is managed externally; retained for compatibility. */
commit_docs?: boolean;
pre_merge_check?: boolean | string;
commit_type?: string;
main_branch?: string;
@ -226,7 +228,12 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st
/** Regex matching GSD quick-task branches: gsd/quick/<num>-<slug> */
export const QUICK_BRANCH_RE = /^gsd\/quick\//;
export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void {
export function writeIntegrationBranch(
basePath: string,
milestoneId: string,
branch: string,
_options?: { commitDocs?: boolean },
): void {
// Don't record slice branches as the integration target
if (SLICE_BRANCH_RE.test(branch)) return;
// Don't record quick-task branches — they are ephemeral and merge back

View file

@ -86,7 +86,10 @@ const BASELINE_PATTERNS = [
* `.gsd/` state is managed externally (symlinked to `~/.gsd/projects/<hash>/`),
* so the entire directory is always gitignored.
*/
export function ensureGitignore(basePath: string, options?: { manageGitignore?: boolean }): boolean {
export function ensureGitignore(
basePath: string,
options?: { manageGitignore?: boolean; commitDocs?: boolean },
): boolean {
// If manage_gitignore is explicitly false, do not touch .gitignore at all
if (options?.manageGitignore === false) return false;
@ -212,4 +215,3 @@ custom_instructions:
return true;
}

View file

@ -5,10 +5,11 @@
// Exposes a unified sync API for decisions and requirements storage.
// Schema is initialized on first open with WAL mode for file-backed DBs.
import { createRequire } from 'node:module';
import { existsSync } from 'node:fs';
import type { Decision, Requirement } from './types.js';
import { GSDError, GSD_STALE_STATE } from './errors.js';
import { createRequire } from "node:module";
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { Decision, Requirement } from "./types.js";
import { GSDError, GSD_STALE_STATE } from "./errors.js";
// Create a require function for loading native modules in ESM context
const _require = createRequire(import.meta.url);
@ -20,7 +21,7 @@ const _require = createRequire(import.meta.url);
* Both expose prepare().run/get/all the adapter normalizes row objects.
*/
interface DbStatement {
run(...params: unknown[]): void;
run(...params: unknown[]): unknown;
get(...params: unknown[]): Record<string, unknown> | undefined;
all(...params: unknown[]): Record<string, unknown>[];
}
@ -31,7 +32,7 @@ interface DbAdapter {
close(): void;
}
type ProviderName = 'node:sqlite' | 'better-sqlite3';
type ProviderName = "node:sqlite" | "better-sqlite3";
let providerName: ProviderName | null = null;
let providerModule: unknown = null;
@ -46,18 +47,20 @@ function suppressSqliteWarning(): void {
// @ts-expect-error — overriding process.emit with filtered version
process.emit = function (event: string, ...args: unknown[]): boolean {
if (
event === 'warning' &&
event === "warning" &&
args[0] &&
typeof args[0] === 'object' &&
'name' in args[0] &&
(args[0] as { name: string }).name === 'ExperimentalWarning' &&
'message' in args[0] &&
typeof (args[0] as { message: string }).message === 'string' &&
(args[0] as { message: string }).message.includes('SQLite')
typeof args[0] === "object" &&
"name" in args[0] &&
(args[0] as { name: string }).name === "ExperimentalWarning" &&
"message" in args[0] &&
typeof (args[0] as { message: string }).message === "string" &&
(args[0] as { message: string }).message.includes("SQLite")
) {
return false;
}
return origEmit.apply(process, [event, ...args] as Parameters<typeof process.emit>) as unknown as boolean;
return origEmit.apply(process, [event, ...args] as Parameters<
typeof process.emit
>) as unknown as boolean;
};
}
@ -68,10 +71,10 @@ function loadProvider(): void {
// Try node:sqlite first
try {
suppressSqliteWarning();
const mod = _require('node:sqlite');
const mod = _require("node:sqlite");
if (mod.DatabaseSync) {
providerModule = mod;
providerName = 'node:sqlite';
providerName = "node:sqlite";
return;
}
} catch {
@ -80,17 +83,19 @@ function loadProvider(): void {
// Try better-sqlite3
try {
const mod = _require('better-sqlite3');
if (typeof mod === 'function' || (mod && mod.default)) {
const mod = _require("better-sqlite3");
if (typeof mod === "function" || (mod && mod.default)) {
providerModule = mod.default || mod;
providerName = 'better-sqlite3';
providerName = "better-sqlite3";
return;
}
} catch {
// better-sqlite3 not available
}
process.stderr.write('gsd-db: No SQLite provider available (tried node:sqlite, better-sqlite3)\n');
process.stderr.write(
"gsd-db: No SQLite provider available (tried node:sqlite, better-sqlite3)\n",
);
}
// ─── Database Adapter ──────────────────────────────────────────────────────
@ -101,13 +106,13 @@ function loadProvider(): void {
function normalizeRow(row: unknown): Record<string, unknown> | undefined {
if (row == null) return undefined;
if (Object.getPrototypeOf(row) === null) {
return { ...row as Record<string, unknown> };
return { ...(row as Record<string, unknown>) };
}
return row as Record<string, unknown>;
}
function normalizeRows(rows: unknown[]): Record<string, unknown>[] {
return rows.map(r => normalizeRow(r)!);
return rows.map((r) => normalizeRow(r)!);
}
function createAdapter(rawDb: unknown): DbAdapter {
@ -128,8 +133,8 @@ function createAdapter(rawDb: unknown): DbAdapter {
prepare(sql: string): DbStatement {
const stmt = db.prepare(sql);
return {
run(...params: unknown[]): void {
stmt.run(...params);
run(...params: unknown[]): unknown {
return stmt.run(...params);
},
get(...params: unknown[]): Record<string, unknown> | undefined {
return normalizeRow(stmt.get(...params));
@ -149,8 +154,10 @@ function openRawDb(path: string): unknown {
loadProvider();
if (!providerModule || !providerName) return null;
if (providerName === 'node:sqlite') {
const { DatabaseSync } = providerModule as { DatabaseSync: new (path: string) => unknown };
if (providerName === "node:sqlite") {
const { DatabaseSync } = providerModule as {
DatabaseSync: new (path: string) => unknown;
};
return new DatabaseSync(path);
}
@ -166,10 +173,10 @@ const SCHEMA_VERSION = 3;
function initSchema(db: DbAdapter, fileBacked: boolean): void {
// WAL mode for file-backed databases (must be outside transaction)
if (fileBacked) {
db.exec('PRAGMA journal_mode=WAL');
db.exec("PRAGMA journal_mode=WAL");
}
db.exec('BEGIN');
db.exec("BEGIN");
try {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_version (
@ -245,24 +252,37 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)');
db.exec(
"CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)",
);
// Views — DROP + CREATE since CREATE VIEW IF NOT EXISTS doesn't update definitions
db.exec(`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`);
db.exec(`CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`);
db.exec(`CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`);
db.exec(
`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`,
);
db.exec(
`CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`,
);
db.exec(
`CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`,
);
// Insert schema version if not already present
const existing = db.prepare('SELECT count(*) as cnt FROM schema_version').get();
if (existing && (existing['cnt'] as number) === 0) {
db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)').run(
{ ':version': SCHEMA_VERSION, ':applied_at': new Date().toISOString() },
);
const existing = db
.prepare("SELECT count(*) as cnt FROM schema_version")
.get();
if (existing && (existing["cnt"] as number) === 0) {
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
).run({
":version": SCHEMA_VERSION,
":applied_at": new Date().toISOString(),
});
}
db.exec('COMMIT');
db.exec("COMMIT");
} catch (err) {
db.exec('ROLLBACK');
db.exec("ROLLBACK");
throw err;
}
@ -275,12 +295,12 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
* and applies DDL for each version step up to SCHEMA_VERSION.
*/
function migrateSchema(db: DbAdapter): void {
const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get();
const currentVersion = row ? (row['v'] as number) : 0;
const row = db.prepare("SELECT MAX(version) as v FROM schema_version").get();
const currentVersion = row ? (row["v"] as number) : 0;
if (currentVersion >= SCHEMA_VERSION) return;
db.exec('BEGIN');
db.exec("BEGIN");
try {
// v1 → v2: add artifacts table
if (currentVersion < 2) {
@ -296,9 +316,9 @@ function migrateSchema(db: DbAdapter): void {
)
`);
db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)').run(
{ ':version': 2, ':applied_at': new Date().toISOString() },
);
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
).run({ ":version": 2, ":applied_at": new Date().toISOString() });
}
// v2 → v3: add memories + memory_processed_units tables
@ -327,18 +347,22 @@ function migrateSchema(db: DbAdapter): void {
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)');
db.exec('DROP VIEW IF EXISTS active_memories');
db.exec('CREATE VIEW active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL');
db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)').run(
{ ':version': 3, ':applied_at': new Date().toISOString() },
db.exec(
"CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)",
);
db.exec("DROP VIEW IF EXISTS active_memories");
db.exec(
"CREATE VIEW active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL",
);
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
).run({ ":version": 3, ":applied_at": new Date().toISOString() });
}
db.exec('COMMIT');
db.exec("COMMIT");
} catch (err) {
db.exec('ROLLBACK');
db.exec("ROLLBACK");
throw err;
}
}
@ -385,12 +409,16 @@ export function openDatabase(path: string): boolean {
if (!rawDb) return false;
const adapter = createAdapter(rawDb);
const fileBacked = path !== ':memory:';
const fileBacked = path !== ":memory:";
try {
initSchema(adapter, fileBacked);
} catch (err) {
try { adapter.close(); } catch { /* swallow */ }
try {
adapter.close();
} catch {
/* swallow */
}
throw err;
}
@ -420,14 +448,15 @@ export function closeDatabase(): void {
* Runs a function inside a transaction. Rolls back on error.
*/
export function transaction<T>(fn: () => T): T {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.exec('BEGIN');
if (!currentDb)
throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb.exec("BEGIN");
try {
const result = fn();
currentDb.exec('COMMIT');
currentDb.exec("COMMIT");
return result;
} catch (err) {
currentDb.exec('ROLLBACK');
currentDb.exec("ROLLBACK");
throw err;
}
}
@ -437,21 +466,24 @@ export function transaction<T>(fn: () => T): T {
/**
* Insert a decision. The `seq` field is auto-generated.
*/
export function insertDecision(d: Omit<Decision, 'seq'>): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by)
export function insertDecision(d: Omit<Decision, "seq">): void {
if (!currentDb)
throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb
.prepare(
`INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by)
VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`,
).run({
':id': d.id,
':when_context': d.when_context,
':scope': d.scope,
':decision': d.decision,
':choice': d.choice,
':rationale': d.rationale,
':revisable': d.revisable,
':superseded_by': d.superseded_by,
});
)
.run({
":id": d.id,
":when_context": d.when_context,
":scope": d.scope,
":decision": d.decision,
":choice": d.choice,
":rationale": d.rationale,
":revisable": d.revisable,
":superseded_by": d.superseded_by,
});
}
/**
@ -459,18 +491,18 @@ export function insertDecision(d: Omit<Decision, 'seq'>): void {
*/
export function getDecisionById(id: string): Decision | null {
if (!currentDb) return null;
const row = currentDb.prepare('SELECT * FROM decisions WHERE id = ?').get(id);
const row = currentDb.prepare("SELECT * FROM decisions WHERE id = ?").get(id);
if (!row) return null;
return {
seq: row['seq'] as number,
id: row['id'] as string,
when_context: row['when_context'] as string,
scope: row['scope'] as string,
decision: row['decision'] as string,
choice: row['choice'] as string,
rationale: row['rationale'] as string,
revisable: row['revisable'] as string,
superseded_by: (row['superseded_by'] as string) ?? null,
seq: row["seq"] as number,
id: row["id"] as string,
when_context: row["when_context"] as string,
scope: row["scope"] as string,
decision: row["decision"] as string,
choice: row["choice"] as string,
rationale: row["rationale"] as string,
revisable: row["revisable"] as string,
superseded_by: (row["superseded_by"] as string) ?? null,
};
}
@ -479,16 +511,16 @@ export function getDecisionById(id: string): Decision | null {
*/
export function getActiveDecisions(): Decision[] {
if (!currentDb) return [];
const rows = currentDb.prepare('SELECT * FROM active_decisions').all();
return rows.map(row => ({
seq: row['seq'] as number,
id: row['id'] as string,
when_context: row['when_context'] as string,
scope: row['scope'] as string,
decision: row['decision'] as string,
choice: row['choice'] as string,
rationale: row['rationale'] as string,
revisable: row['revisable'] as string,
const rows = currentDb.prepare("SELECT * FROM active_decisions").all();
return rows.map((row) => ({
seq: row["seq"] as number,
id: row["id"] as string,
when_context: row["when_context"] as string,
scope: row["scope"] as string,
decision: row["decision"] as string,
choice: row["choice"] as string,
rationale: row["rationale"] as string,
revisable: row["revisable"] as string,
superseded_by: null,
}));
}
@ -499,24 +531,27 @@ export function getActiveDecisions(): Decision[] {
* Insert a requirement.
*/
export function insertRequirement(r: Requirement): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by)
if (!currentDb)
throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb
.prepare(
`INSERT INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by)
VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`,
).run({
':id': r.id,
':class': r.class,
':status': r.status,
':description': r.description,
':why': r.why,
':source': r.source,
':primary_owner': r.primary_owner,
':supporting_slices': r.supporting_slices,
':validation': r.validation,
':notes': r.notes,
':full_content': r.full_content,
':superseded_by': r.superseded_by,
});
)
.run({
":id": r.id,
":class": r.class,
":status": r.status,
":description": r.description,
":why": r.why,
":source": r.source,
":primary_owner": r.primary_owner,
":supporting_slices": r.supporting_slices,
":validation": r.validation,
":notes": r.notes,
":full_content": r.full_content,
":superseded_by": r.superseded_by,
});
}
/**
@ -524,21 +559,23 @@ export function insertRequirement(r: Requirement): void {
*/
export function getRequirementById(id: string): Requirement | null {
if (!currentDb) return null;
const row = currentDb.prepare('SELECT * FROM requirements WHERE id = ?').get(id);
const row = currentDb
.prepare("SELECT * FROM requirements WHERE id = ?")
.get(id);
if (!row) return null;
return {
id: row['id'] as string,
class: row['class'] as string,
status: row['status'] as string,
description: row['description'] as string,
why: row['why'] as string,
source: row['source'] as string,
primary_owner: row['primary_owner'] as string,
supporting_slices: row['supporting_slices'] as string,
validation: row['validation'] as string,
notes: row['notes'] as string,
full_content: row['full_content'] as string,
superseded_by: (row['superseded_by'] as string) ?? null,
id: row["id"] as string,
class: row["class"] as string,
status: row["status"] as string,
description: row["description"] as string,
why: row["why"] as string,
source: row["source"] as string,
primary_owner: row["primary_owner"] as string,
supporting_slices: row["supporting_slices"] as string,
validation: row["validation"] as string,
notes: row["notes"] as string,
full_content: row["full_content"] as string,
superseded_by: (row["superseded_by"] as string) ?? null,
};
}
@ -547,19 +584,19 @@ export function getRequirementById(id: string): Requirement | null {
*/
export function getActiveRequirements(): Requirement[] {
if (!currentDb) return [];
const rows = currentDb.prepare('SELECT * FROM active_requirements').all();
return rows.map(row => ({
id: row['id'] as string,
class: row['class'] as string,
status: row['status'] as string,
description: row['description'] as string,
why: row['why'] as string,
source: row['source'] as string,
primary_owner: row['primary_owner'] as string,
supporting_slices: row['supporting_slices'] as string,
validation: row['validation'] as string,
notes: row['notes'] as string,
full_content: row['full_content'] as string,
const rows = currentDb.prepare("SELECT * FROM active_requirements").all();
return rows.map((row) => ({
id: row["id"] as string,
class: row["class"] as string,
status: row["status"] as string,
description: row["description"] as string,
why: row["why"] as string,
source: row["source"] as string,
primary_owner: row["primary_owner"] as string,
supporting_slices: row["supporting_slices"] as string,
validation: row["validation"] as string,
notes: row["notes"] as string,
full_content: row["full_content"] as string,
superseded_by: null,
}));
}
@ -602,45 +639,51 @@ export function _resetProvider(): void {
/**
* Insert or replace a decision. Uses the `id` UNIQUE constraint for idempotency.
*/
export function upsertDecision(d: Omit<Decision, 'seq'>): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by)
export function upsertDecision(d: Omit<Decision, "seq">): void {
if (!currentDb)
throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb
.prepare(
`INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by)
VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`,
).run({
':id': d.id,
':when_context': d.when_context,
':scope': d.scope,
':decision': d.decision,
':choice': d.choice,
':rationale': d.rationale,
':revisable': d.revisable,
':superseded_by': d.superseded_by ?? null,
});
)
.run({
":id": d.id,
":when_context": d.when_context,
":scope": d.scope,
":decision": d.decision,
":choice": d.choice,
":rationale": d.rationale,
":revisable": d.revisable,
":superseded_by": d.superseded_by ?? null,
});
}
/**
* Insert or replace a requirement. Uses the `id` PK for idempotency.
*/
export function upsertRequirement(r: Requirement): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT OR REPLACE INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by)
if (!currentDb)
throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb
.prepare(
`INSERT OR REPLACE INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by)
VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`,
).run({
':id': r.id,
':class': r.class,
':status': r.status,
':description': r.description,
':why': r.why,
':source': r.source,
':primary_owner': r.primary_owner,
':supporting_slices': r.supporting_slices,
':validation': r.validation,
':notes': r.notes,
':full_content': r.full_content,
':superseded_by': r.superseded_by ?? null,
});
)
.run({
":id": r.id,
":class": r.class,
":status": r.status,
":description": r.description,
":why": r.why,
":source": r.source,
":primary_owner": r.primary_owner,
":supporting_slices": r.supporting_slices,
":validation": r.validation,
":notes": r.notes,
":full_content": r.full_content,
":superseded_by": r.superseded_by ?? null,
});
}
/**
@ -655,7 +698,7 @@ export function upsertRequirement(r: Requirement): void {
export function clearArtifacts(): void {
if (!currentDb) return;
try {
currentDb.exec('DELETE FROM artifacts');
currentDb.exec("DELETE FROM artifacts");
} catch {
// Clearing a cache should never be fatal
}
@ -669,17 +712,169 @@ export function insertArtifact(a: {
task_id: string | null;
full_content: string;
}): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at)
if (!currentDb)
throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb
.prepare(
`INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at)
VALUES (:path, :artifact_type, :milestone_id, :slice_id, :task_id, :full_content, :imported_at)`,
).run({
':path': a.path,
':artifact_type': a.artifact_type,
':milestone_id': a.milestone_id,
':slice_id': a.slice_id,
':task_id': a.task_id,
':full_content': a.full_content,
':imported_at': new Date().toISOString(),
});
)
.run({
":path": a.path,
":artifact_type": a.artifact_type,
":milestone_id": a.milestone_id,
":slice_id": a.slice_id,
":task_id": a.task_id,
":full_content": a.full_content,
":imported_at": new Date().toISOString(),
});
}
// ─── Worktree DB Helpers ──────────────────────────────────────────────────
export function copyWorktreeDb(srcDbPath: string, destDbPath: string): boolean {
try {
if (!existsSync(srcDbPath)) return false;
const destDir = dirname(destDbPath);
mkdirSync(destDir, { recursive: true });
copyFileSync(srcDbPath, destDbPath);
return true;
} catch (err) {
process.stderr.write(
`gsd-db: failed to copy DB to worktree: ${(err as Error).message}\n`,
);
return false;
}
}
export function reconcileWorktreeDb(
mainDbPath: string,
worktreeDbPath: string,
): {
decisions: number;
requirements: number;
artifacts: number;
conflicts: string[];
} {
const zero = {
decisions: 0,
requirements: 0,
artifacts: 0,
conflicts: [] as string[],
};
if (!existsSync(worktreeDbPath)) return zero;
if (worktreeDbPath.includes("'")) {
process.stderr.write(
`gsd-db: worktree DB reconciliation failed: path contains unsafe characters\n`,
);
return zero;
}
if (!currentDb) {
const opened = openDatabase(mainDbPath);
if (!opened) {
process.stderr.write(
`gsd-db: worktree DB reconciliation failed: cannot open main DB\n`,
);
return zero;
}
}
const adapter = currentDb!;
const conflicts: string[] = [];
try {
adapter.exec(`ATTACH DATABASE '${worktreeDbPath}' AS wt`);
try {
const decConf = adapter
.prepare(
`SELECT m.id FROM decisions m INNER JOIN wt.decisions w ON m.id = w.id WHERE m.decision != w.decision OR m.choice != w.choice OR m.rationale != w.rationale OR m.superseded_by IS NOT w.superseded_by`,
)
.all();
for (const row of decConf)
conflicts.push(
`decision ${(row as Record<string, unknown>)["id"]}: modified in both`,
);
const reqConf = adapter
.prepare(
`SELECT m.id FROM requirements m INNER JOIN wt.requirements w ON m.id = w.id WHERE m.description != w.description OR m.status != w.status OR m.notes != w.notes OR m.superseded_by IS NOT w.superseded_by`,
)
.all();
for (const row of reqConf)
conflicts.push(
`requirement ${(row as Record<string, unknown>)["id"]}: modified in both`,
);
const merged = { decisions: 0, requirements: 0, artifacts: 0 };
adapter.exec("BEGIN");
try {
const dR = adapter
.prepare(
`
INSERT OR REPLACE INTO decisions (
id, when_context, scope, decision, choice, rationale, revisable, superseded_by
)
SELECT
id, when_context, scope, decision, choice, rationale, revisable, superseded_by
FROM wt.decisions
`,
)
.run();
merged.decisions =
typeof dR === "object" && dR !== null
? ((dR as { changes?: number }).changes ?? 0)
: 0;
const rR = adapter
.prepare(
`
INSERT OR REPLACE INTO requirements (
id, class, status, description, why, source, primary_owner,
supporting_slices, validation, notes, full_content, superseded_by
)
SELECT
id, class, status, description, why, source, primary_owner,
supporting_slices, validation, notes, full_content, superseded_by
FROM wt.requirements
`,
)
.run();
merged.requirements =
typeof rR === "object" && rR !== null
? ((rR as { changes?: number }).changes ?? 0)
: 0;
const aR = adapter
.prepare(
`
INSERT OR REPLACE INTO artifacts (
path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at
)
SELECT
path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at
FROM wt.artifacts
`,
)
.run();
merged.artifacts =
typeof aR === "object" && aR !== null
? ((aR as { changes?: number }).changes ?? 0)
: 0;
adapter.exec("COMMIT");
} catch (txErr) {
try {
adapter.exec("ROLLBACK");
} catch {
/* best-effort */
}
throw txErr;
}
return { ...merged, conflicts };
} finally {
try {
adapter.exec("DETACH DATABASE wt");
} catch {
/* best-effort */
}
}
} catch (err) {
process.stderr.write(
`gsd-db: worktree DB reconciliation failed: ${(err as Error).message}\n`,
);
return { ...zero, conflicts };
}
}

View file

@ -23,24 +23,31 @@ import type {
ExtensionCommandContext,
ExtensionContext,
} from "@gsd/pi-coding-agent";
import {
createBashTool,
createEditTool,
createReadTool,
createWriteTool,
importExtensionModule,
isToolCallEventType,
} from "@gsd/pi-coding-agent";
import { createBashTool, createWriteTool, createReadTool, createEditTool, isToolCallEventType } from "@gsd/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { debugLog, debugTime } from "./debug-logger.js";
import { registerLazyGSDCommand } from "./commands-bootstrap.js";
import { registerGSDCommand } from "./commands.js";
import { loadToolApiKeys } from "./commands-config.js";
import { registerExitCommand } from "./exit-command.js";
import { registerLazyWorktreeCommands } from "./worktree-command-bootstrap.js";
import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js";
import { getActiveAutoWorktreeContext } from "./auto-worktree.js";
import { saveFile, formatContinue, loadFile, parseContinue, parseSummary, loadActiveOverrides, formatOverridesSection } from "./files.js";
import { loadPrompt } from "./prompt-loader.js";
import { deriveState } from "./state.js";
import { isAutoActive, isAutoPaused, pauseAuto, getAutoDashboardData, getAutoModeStartModel, markToolStart, markToolEnd } from "./auto.js";
import { isSessionSwitchInFlight, resolveAgentEnd } from "./auto-loop.js";
import { saveActivityLog } from "./activity-log.js";
import { checkAutoStartAfterDiscuss, getDiscussionMilestoneId, findMilestoneIds, nextMilestoneId } from "./guided-flow.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
import {
loadEffectiveGSDPreferences,
renderPreferencesForSystemPrompt,
resolveAllSkillReferences,
resolveModelWithFallbacksForUnit,
getNextFallbackModel,
isTransientNetworkError,
} from "./preferences.js";
import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "./skill-discovery.js";
import {
resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTaskFiles, resolveTasksDir,
@ -54,48 +61,10 @@ import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { shortcutDesc } from "../shared/mod.js";
import { Text } from "@gsd/pi-tui";
import { pauseAutoForProviderError, classifyProviderError } from "./provider-error-pause.js";
import { toPosixPath } from "../shared/mod.js";
import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js";
import { DEFAULT_BASH_TIMEOUT_SECS } from "./constants.js";
import { getErrorMessage } from "./error-utils.js";
function memoizeImport<T>(loader: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | null = null;
return () => {
if (!promise) {
promise = loader();
}
return promise;
};
}
const loadAutoModule = memoizeImport(() => importExtensionModule<typeof import("./auto.js")>(import.meta.url, "./auto.js"));
const loadStateModule = memoizeImport(() => importExtensionModule<typeof import("./state.js")>(import.meta.url, "./state.js"));
const loadGuidedFlowModule = memoizeImport(() => importExtensionModule<typeof import("./guided-flow.js")>(import.meta.url, "./guided-flow.js"));
const loadPreferencesModule = memoizeImport(() => importExtensionModule<typeof import("./preferences.js")>(import.meta.url, "./preferences.js"));
const loadDashboardOverlayModule = memoizeImport(() => importExtensionModule<typeof import("./dashboard-overlay.js")>(import.meta.url, "./dashboard-overlay.js"));
const loadWorktreeCommandModule = memoizeImport(() => importExtensionModule<typeof import("./worktree-command.js")>(import.meta.url, "./worktree-command.js"));
const loadAutoWorktreeModule = memoizeImport(() => importExtensionModule<typeof import("./auto-worktree.js")>(import.meta.url, "./auto-worktree.js"));
const loadProviderErrorPauseModule = memoizeImport(() => importExtensionModule<typeof import("./provider-error-pause.js")>(import.meta.url, "./provider-error-pause.js"));
const loadParallelOrchestratorModule = memoizeImport(() => importExtensionModule<typeof import("./parallel-orchestrator.js")>(import.meta.url, "./parallel-orchestrator.js"));
/**
* Ensure the GSD database is available, auto-initializing if needed.
* Returns true if the DB is ready, false if initialization failed.
*/
async function ensureDbAvailable(): Promise<boolean> {
try {
const db = await importExtensionModule<typeof import("./gsd-db.js")>(import.meta.url, "./gsd-db.js");
if (db.isDbAvailable()) return true;
// Auto-initialize: open (and create if needed) the DB at the standard path
const gsdDir = gsdRoot(process.cwd());
if (!existsSync(gsdDir)) return false; // No GSD project — can't create DB
const dbPath = join(gsdDir, "gsd.db");
return db.openDatabase(dbPath);
} catch {
return false;
}
}
// ── Agent Instructions ────────────────────────────────────────────────────
// Lightweight "always follow" files injected into every GSD agent session.
@ -126,9 +95,7 @@ function loadAgentInstructions(): string | null {
}
// ── Depth verification state ──────────────────────────────────────────────
// Tracks which milestones have passed depth verification.
// Single-milestone flows set '*' (wildcard). Multi-milestone flows set per-ID.
const depthVerifiedMilestones = new Set<string>();
let depthVerificationDone = false;
// ── Queue phase tracking ──────────────────────────────────────────────────
// When true, the LLM is in a queue flow writing CONTEXT.md files.
@ -139,28 +106,11 @@ let activeQueuePhase = false;
// Tracks per-model retry attempts for transient network errors.
// Cleared when a model switch occurs or retries are exhausted.
const networkRetryCounters = new Map<string, number>();
// ── Transient error escalation ───────────────────────────────────────────
// Tracks consecutive transient auto-resume attempts. Each attempt doubles
// the delay. After MAX_TRANSIENT_AUTO_RESUMES consecutive failures, auto-mode
// pauses indefinitely to avoid infinite rapid-fire retries (#1166).
const MAX_TRANSIENT_AUTO_RESUMES = 5;
const MAX_TRANSIENT_AUTO_RESUMES = 3;
let consecutiveTransientErrors = 0;
export function isDepthVerified(): boolean {
return depthVerifiedMilestones.has("*") || depthVerifiedMilestones.size > 0;
}
/** Check whether a specific milestone has passed depth verification. */
export function isDepthVerifiedFor(milestoneId: string): boolean {
// Wildcard means "all milestones verified" (single-milestone flow)
if (depthVerifiedMilestones.has("*")) return true;
return depthVerifiedMilestones.has(milestoneId);
}
/** Mark a specific milestone as depth-verified. */
export function markDepthVerified(milestoneId: string): void {
depthVerifiedMilestones.add(milestoneId);
return depthVerificationDone;
}
/** Check whether a queue phase is active. */
@ -191,25 +141,11 @@ export function shouldBlockContextWrite(
if (!inDiscussion && !inQueue) return { block: false };
if (!MILESTONE_CONTEXT_RE.test(inputPath)) return { block: false };
// For discussion flows: check global depth verification (backward compat)
if (inDiscussion && depthVerified) return { block: false };
// For queue flows: extract milestone ID from the path and check per-milestone verification
if (inQueue) {
const pathMatch = inputPath.match(/\/(M\d+(?:-[a-z0-9]{6})?)-CONTEXT\.md$/);
const targetMid = pathMatch?.[1];
if (targetMid && depthVerifiedMilestones.has(targetMid)) return { block: false };
// Wildcard passes all
if (depthVerifiedMilestones.has("*")) return { block: false };
}
if (depthVerified) return { block: false };
return {
block: true,
reason: `Blocked: Cannot write milestone CONTEXT.md without depth verification. ` +
`Use ask_user_questions with a question id containing "depth_verification" first. ` +
`For multi-milestone flows, include the milestone ID in the question id (e.g., "depth_verification_M001"). ` +
`This ensures each milestone's context has been critically examined before being written.`,
reason: `Blocked: Cannot write to milestone CONTEXT.md during discussion phase without depth verification. Call ask_user_questions with question id "depth_verification" first to confirm discussion depth before writing context.`,
};
}
@ -224,8 +160,8 @@ const GSD_LOGO_LINES = [
];
export default function (pi: ExtensionAPI) {
registerLazyGSDCommand(pi);
registerLazyWorktreeCommands(pi);
registerGSDCommand(pi);
registerWorktreeCommand(pi);
registerExitCommand(pi);
// ── EPIPE guard — prevent crash when stdout/stderr pipe closes unexpectedly ──
@ -235,22 +171,11 @@ export default function (pi: ExtensionAPI) {
// chance to persist state and pause instead of crashing (see issue #739).
if (!process.listeners("uncaughtException").some(l => l.name === "_gsdEpipeGuard")) {
const _gsdEpipeGuard = (err: Error): void => {
const code = (err as NodeJS.ErrnoException).code;
if (code === "EPIPE") {
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
// Pipe closed — nothing we can write; just exit cleanly
process.exit(0);
}
// ECOMPROMISED: proper-lockfile's update timer detected mtime drift (system
// sleep, heavy event loop stall, or filesystem precision mismatch on Node.js
// v25+). The onCompromised callback already set _lockCompromised = true, but
// due to a subtle interaction between the synchronous fs adapter and the
// setTimeout boundary, the error can still propagate here as an uncaught
// exception. Exit cleanly so the process.once("exit") handler removes the
// lock directory — allowing the next session to acquire cleanly (#1322).
if (code === "ECOMPROMISED") {
process.exit(1);
}
// Re-throw anything that isn't EPIPE or ECOMPROMISED so real crashes still surface
// Re-throw anything that isn't EPIPE so real crashes still surface
throw err;
};
process.on("uncaughtException", _gsdEpipeGuard);
@ -371,8 +296,14 @@ export default function (pi: ExtensionAPI) {
when_context: Type.Optional(Type.String({ description: "When/context for the decision (e.g. milestone ID)" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
// Ensure DB is available (auto-initialize if needed)
if (!await ensureDbAvailable()) {
// Check DB availability
let dbAvailable = false;
try {
const db = await import("./gsd-db.js");
dbAvailable = db.isDbAvailable();
} catch { /* dynamic import failed */ }
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save decision." }],
isError: true,
@ -381,7 +312,7 @@ export default function (pi: ExtensionAPI) {
}
try {
const { saveDecisionToDb } = await importExtensionModule<typeof import("./db-writer.js")>(import.meta.url, "./db-writer.js");
const { saveDecisionToDb } = await import("./db-writer.js");
const { id } = await saveDecisionToDb(
{
scope: params.scope,
@ -398,7 +329,7 @@ export default function (pi: ExtensionAPI) {
details: { operation: "save_decision", id },
};
} catch (err) {
const msg = getErrorMessage(err);
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_save_decision tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error saving decision: ${msg}` }],
@ -432,8 +363,13 @@ export default function (pi: ExtensionAPI) {
supporting_slices: Type.Optional(Type.String({ description: "Supporting slices" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
// Ensure DB is available (auto-initialize if needed)
if (!await ensureDbAvailable()) {
let dbAvailable = false;
try {
const db = await import("./gsd-db.js");
dbAvailable = db.isDbAvailable();
} catch { /* dynamic import failed */ }
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot update requirement." }],
isError: true,
@ -443,7 +379,7 @@ export default function (pi: ExtensionAPI) {
try {
// Verify requirement exists
const db = await importExtensionModule<typeof import("./gsd-db.js")>(import.meta.url, "./gsd-db.js");
const db = await import("./gsd-db.js");
const existing = db.getRequirementById(params.id);
if (!existing) {
return {
@ -453,7 +389,7 @@ export default function (pi: ExtensionAPI) {
};
}
const { updateRequirementInDb } = await importExtensionModule<typeof import("./db-writer.js")>(import.meta.url, "./db-writer.js");
const { updateRequirementInDb } = await import("./db-writer.js");
const updates: Record<string, string | undefined> = {};
if (params.status !== undefined) updates.status = params.status;
if (params.validation !== undefined) updates.validation = params.validation;
@ -469,7 +405,7 @@ export default function (pi: ExtensionAPI) {
details: { operation: "update_requirement", id: params.id },
};
} catch (err) {
const msg = getErrorMessage(err);
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_update_requirement tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error updating requirement: ${msg}` }],
@ -501,8 +437,13 @@ export default function (pi: ExtensionAPI) {
content: Type.String({ description: "The full markdown content of the artifact" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
// Ensure DB is available (auto-initialize if needed)
if (!await ensureDbAvailable()) {
let dbAvailable = false;
try {
const db = await import("./gsd-db.js");
dbAvailable = db.isDbAvailable();
} catch { /* dynamic import failed */ }
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save artifact." }],
isError: true,
@ -531,7 +472,7 @@ export default function (pi: ExtensionAPI) {
relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`;
}
const { saveArtifactToDb } = await importExtensionModule<typeof import("./db-writer.js")>(import.meta.url, "./db-writer.js");
const { saveArtifactToDb } = await import("./db-writer.js");
await saveArtifactToDb(
{
path: relativePath,
@ -549,7 +490,7 @@ export default function (pi: ExtensionAPI) {
details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type },
};
} catch (err) {
const msg = getErrorMessage(err);
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_save_summary tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error saving artifact: ${msg}` }],
@ -586,10 +527,6 @@ export default function (pi: ExtensionAPI) {
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
try {
const [{ findMilestoneIds, nextMilestoneId }, { loadEffectiveGSDPreferences }] = await Promise.all([
loadGuidedFlowModule(),
loadPreferencesModule(),
]);
const basePath = process.cwd();
const existingIds = findMilestoneIds(basePath);
const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
@ -602,7 +539,7 @@ export default function (pi: ExtensionAPI) {
details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, reservedCount: reservedMilestoneIds.size, uniqueEnabled },
};
} catch (err) {
const msg = getErrorMessage(err);
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text" as const, text: `Error generating milestone ID: ${msg}` }],
isError: true,
@ -614,9 +551,8 @@ export default function (pi: ExtensionAPI) {
// ── session_start: render branded GSD header + load tool keys + remote status ──
pi.on("session_start", async (_event, ctx) => {
// Clear depth verification and queue phase state from any prior session
depthVerifiedMilestones.clear();
activeQueuePhase = false;
// Clear per-session state that must not leak across sessions (e.g. RPC mode)
depthVerificationDone = false;
// Theme access throws in RPC mode (no TUI) — header is decorative, skip it
try {
@ -635,17 +571,11 @@ export default function (pi: ExtensionAPI) {
// Load tool API keys from auth.json into environment
loadToolApiKeys();
// Always-on health widget — ambient system health signal below the editor
try {
const { initHealthWidget } = await importExtensionModule<typeof import("./health-widget.js")>(import.meta.url, "./health-widget.js");
initHealthWidget(ctx);
} catch { /* non-fatal — widget is best-effort */ }
// Notify remote questions status if configured
try {
const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([
importExtensionModule<typeof import("../remote-questions/config.js")>(import.meta.url, "../remote-questions/config.js"),
importExtensionModule<typeof import("../remote-questions/status.js")>(import.meta.url, "../remote-questions/status.js"),
import("../remote-questions/config.js"),
import("../remote-questions/status.js"),
]);
const status = getRemoteConfigStatus();
const latest = getLatestPromptSummary();
@ -663,13 +593,12 @@ export default function (pi: ExtensionAPI) {
description: shortcutDesc("Open GSD dashboard", "/gsd status"),
handler: async (ctx) => {
// Only show if .gsd/ exists
if (!existsSync(gsdRoot(process.cwd()))) {
if (!existsSync(join(process.cwd(), ".gsd"))) {
ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info");
return;
}
const { GSDDashboardOverlay } = await loadDashboardOverlayModule();
const result = await ctx.ui.custom<void>(
await ctx.ui.custom<void>(
(tui, theme, _kb, done) => {
return new GSDDashboardOverlay(tui, theme, () => done());
},
@ -683,23 +612,15 @@ export default function (pi: ExtensionAPI) {
},
},
);
// Fallback for RPC mode where ctx.ui.custom() returns undefined.
if (result === undefined) {
const { fireStatusViaCommand } = await importExtensionModule<typeof import("./commands.js")>(import.meta.url, "./commands.js");
await fireStatusViaCommand(ctx);
}
},
});
// ── before_agent_start: inject GSD contract into true system prompt ─────
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
if (!existsSync(gsdRoot(process.cwd()))) return;
if (!existsSync(join(process.cwd(), ".gsd"))) return;
const stopContextTimer = debugTime("context-inject");
const systemContent = loadPrompt("system");
const { loadEffectiveGSDPreferences, resolveAllSkillReferences, renderPreferencesForSystemPrompt } =
await loadPreferencesModule();
const loadedPreferences = loadEffectiveGSDPreferences();
let preferenceBlock = "";
if (loadedPreferences) {
@ -733,7 +654,7 @@ export default function (pi: ExtensionAPI) {
// Inject auto-learned project memories
let memoryBlock = "";
try {
const { getActiveMemoriesRanked, formatMemoriesForPrompt } = await importExtensionModule<typeof import("./memory-store.js")>(import.meta.url, "./memory-store.js");
const { getActiveMemoriesRanked, formatMemoriesForPrompt } = await import("./memory-store.js");
const memories = getActiveMemoriesRanked(30);
if (memories.length > 0) {
const formatted = formatMemoriesForPrompt(memories, 2000);
@ -763,10 +684,6 @@ export default function (pi: ExtensionAPI) {
// Worktree context — override the static CWD in the system prompt
let worktreeBlock = "";
const [{ getActiveWorktreeName, getWorktreeOriginalCwd }, { getActiveAutoWorktreeContext }] = await Promise.all([
loadWorktreeCommandModule(),
loadAutoWorktreeModule(),
]);
const worktreeName = getActiveWorktreeName();
const worktreeMainCwd = getWorktreeOriginalCwd();
const autoWorktree = getActiveAutoWorktreeContext();
@ -830,37 +747,9 @@ export default function (pi: ExtensionAPI) {
// ── agent_end: auto-mode advancement or auto-start after discuss ───────────
pi.on("agent_end", async (event, ctx: ExtensionContext) => {
const [
{
isAutoActive,
pauseAuto,
getAutoDashboardData,
getAutoModeStartModel,
handleAgentEnd,
},
{ checkAutoStartAfterDiscuss },
{
isTransientNetworkError,
resolveModelWithFallbacksForUnit,
getNextFallbackModel,
},
{ classifyProviderError, pauseAutoForProviderError },
] = await Promise.all([
loadAutoModule(),
loadGuidedFlowModule(),
loadPreferencesModule(),
loadProviderErrorPauseModule(),
]);
// Clean up quick-task branch if one just completed (#1269)
try {
const { cleanupQuickBranch } = await importExtensionModule<typeof import("./quick.js")>(import.meta.url, "./quick.js");
cleanupQuickBranch();
} catch { /* non-fatal */ }
// If discuss phase just finished, start auto-mode
if (checkAutoStartAfterDiscuss()) {
depthVerifiedMilestones.clear();
depthVerificationDone = false;
activeQueuePhase = false;
return;
}
@ -868,6 +757,13 @@ export default function (pi: ExtensionAPI) {
// If auto-mode is already running, advance to next unit
if (!isAutoActive()) return;
// Fresh-session auto-mode intentionally aborts the previous session during
// cmdCtx.newSession(). Ignore that agent_end so we neither pause nor
// resolve the new unit with an event from the old session.
if (isSessionSwitchInFlight()) {
return;
}
// If the agent was aborted (user pressed Escape) or hit a provider
// error (fetch failure, rate limit, etc.), pause auto-mode instead of
// advancing. This preserves the conversation so the user can inspect
@ -1007,50 +903,46 @@ export default function (pi: ExtensionAPI) {
const explicitRetryAfterMs = ("retryAfterMs" in lastMsg && typeof lastMsg.retryAfterMs === "number")
? lastMsg.retryAfterMs
: undefined;
let retryAfterMs = explicitRetryAfterMs ?? classification.suggestedDelayMs;
// ── Escalating backoff for repeated transient errors ──────────────
// Each consecutive transient auto-resume doubles the delay. After
// MAX_TRANSIENT_AUTO_RESUMES consecutive failures, treat as permanent
// to avoid infinite rapid-fire retries (#1166).
let effectiveTransient = classification.isTransient;
if (classification.isTransient) {
consecutiveTransientErrors++;
if (consecutiveTransientErrors > MAX_TRANSIENT_AUTO_RESUMES) {
effectiveTransient = false;
ctx.ui.notify(
`${consecutiveTransientErrors} consecutive transient errors. Pausing indefinitely — resume manually with /gsd auto.`,
"error",
);
consecutiveTransientErrors = 0;
} else {
// Escalate: base delay × 2^(consecutive-1) → 30s, 60s, 120s, 240s, 480s
retryAfterMs = retryAfterMs * 2 ** (consecutiveTransientErrors - 1);
}
consecutiveTransientErrors += 1;
} else {
consecutiveTransientErrors = 0;
}
const baseRetryAfterMs = explicitRetryAfterMs ?? classification.suggestedDelayMs;
const retryAfterMs = classification.isTransient ? baseRetryAfterMs * 2 ** Math.max(0, consecutiveTransientErrors - 1) : baseRetryAfterMs;
const allowAutoResume = classification.isTransient
&& consecutiveTransientErrors <= MAX_TRANSIENT_AUTO_RESUMES;
if (classification.isTransient && !allowAutoResume) {
ctx.ui.notify(
`Transient provider errors persisted after ${MAX_TRANSIENT_AUTO_RESUMES} auto-resume attempts. Pausing for manual review.`,
"warning",
);
}
await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi), {
isRateLimit: classification.isRateLimit,
isTransient: effectiveTransient,
isTransient: allowAutoResume,
retryAfterMs,
resume: () => {
pi.sendMessage(
{ customType: "gsd-auto-timeout-recovery", content: "Continue execution \u2014 provider error recovery delay elapsed.", display: false },
{ triggerTurn: true },
);
},
resume: allowAutoResume
? () => {
pi.sendMessage(
{ customType: "gsd-auto-timeout-recovery", content: "Continue execution \u2014 provider error recovery delay elapsed.", display: false },
{ triggerTurn: true },
);
}
: undefined,
});
return;
}
try {
consecutiveTransientErrors = 0;
networkRetryCounters.clear(); // Clear network retry state on successful unit completion
consecutiveTransientErrors = 0; // Reset escalating backoff on success
await handleAgentEnd(ctx, pi);
resolveAgentEnd(event);
} catch (err) {
// Safety net: if handleAgentEnd throws despite its internal try-catch,
// ensure auto-mode stops gracefully instead of silently stalling (#381).
const message = getErrorMessage(err);
// Safety net: if resolveAgentEnd throws, ensure auto-mode stops gracefully (#381).
const message = err instanceof Error ? err.message : String(err);
ctx.ui.notify(
`Auto-mode error in agent_end handler: ${message}. Stopping auto-mode.`,
"error",
@ -1065,11 +957,6 @@ export default function (pi: ExtensionAPI) {
// ── session_before_compact ────────────────────────────────────────────────
pi.on("session_before_compact", async (_event, _ctx: ExtensionContext) => {
const [{ isAutoActive, isAutoPaused }, { deriveState }] = await Promise.all([
loadAutoModule(),
loadStateModule(),
]);
// Block compaction during auto-mode — each unit is a fresh session
// Also block during paused state — context is valuable for the user
if (isAutoActive() || isAutoPaused()) {
@ -1116,31 +1003,12 @@ export default function (pi: ExtensionAPI) {
// ── session_shutdown: save activity log on Ctrl+C / SIGTERM ─────────────
pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => {
const [{ isParallelActive, shutdownParallel }, { isAutoActive, isAutoPaused, getAutoDashboardData }] =
await Promise.all([
loadParallelOrchestratorModule(),
loadAutoModule(),
]);
if (isParallelActive()) {
try {
await shutdownParallel(process.cwd());
} catch { /* best-effort */ }
}
// Auto-commit dirty work in CLI-spawned worktrees so nothing is lost.
// The CLI sets GSD_CLI_WORKTREE when launched with -w.
const cliWorktree = process.env.GSD_CLI_WORKTREE;
if (cliWorktree) {
try {
const { autoCommitCurrentBranch } = await importExtensionModule<typeof import("./worktree.js")>(import.meta.url, "./worktree.js");
const msg = autoCommitCurrentBranch(process.cwd(), "session-end", cliWorktree);
if (msg) {
ctx.ui.notify(`Auto-committed worktree ${cliWorktree} before exit.`, "info");
}
} catch { /* best-effort */ }
}
if (!isAutoActive() && !isAutoPaused()) return;
// Save the current session — the lock file stays on disk
@ -1151,14 +1019,9 @@ export default function (pi: ExtensionAPI) {
}
});
// ── tool_call: block CONTEXT.md writes without depth verification ──
// Active during both discussion flows (pendingAutoStart set) and
// queue flows (activeQueuePhase set). For multi-milestone queue flows,
// each milestone must pass its own depth verification before its
// CONTEXT.md can be written.
// ── tool_call: block CONTEXT.md writes during discussion without depth verification ──
pi.on("tool_call", async (event) => {
if (!isToolCallEventType("write", event)) return;
const { getDiscussionMilestoneId } = await loadGuidedFlowModule();
const result = shouldBlockContextWrite(
event.toolName,
event.input.path,
@ -1170,43 +1033,24 @@ export default function (pi: ExtensionAPI) {
});
// ── tool_result: persist discussion exchanges & detect depth gate ──────
// Handles both discussion flows and queue flows. For queue flows,
// depth verification question IDs may include milestone IDs
// (e.g., "depth_verification_M001") for per-milestone gating.
pi.on("tool_result", async (event) => {
if (event.toolName !== "ask_user_questions") return;
const { getDiscussionMilestoneId } = await loadGuidedFlowModule();
const milestoneId = getDiscussionMilestoneId();
// Queue flows don't set pendingAutoStart, so milestoneId may be null.
// Depth gate detection still applies — it sets per-milestone flags.
const inQueue = activeQueuePhase;
if (!milestoneId) return;
const details = event.details as any;
if (details?.cancelled || !details?.response) return;
// ── Depth gate detection ──────────────────────────────────────────
// Supports two patterns:
// 1. "depth_verification" — wildcard, marks all milestones verified
// 2. "depth_verification_M001" — per-milestone verification
const questions: any[] = (event.input as any)?.questions ?? [];
for (const q of questions) {
if (typeof q.id === "string" && q.id.includes("depth_verification")) {
// Extract milestone ID from question ID if present
const midMatch = q.id.match(/depth_verification[_-](M\d+(?:-[a-z0-9]{6})?)/i);
if (midMatch) {
depthVerifiedMilestones.add(midMatch[1]);
} else {
// Wildcard — all milestones verified (backward compat for single-milestone)
depthVerifiedMilestones.add("*");
}
depthVerificationDone = true;
break;
}
}
// Discussion persistence only applies when in a discussion flow with a known milestone
if (!milestoneId) return;
// ── Persist exchange to DISCUSSION.md ──────────────────────────────
const basePath = process.cwd();
const milestoneDir = resolveMilestonePath(basePath, milestoneId);
@ -1252,13 +1096,11 @@ export default function (pi: ExtensionAPI) {
// ── tool_execution_start/end: track in-flight tools for idle detection ──
pi.on("tool_execution_start", async (event) => {
const { isAutoActive, markToolStart } = await loadAutoModule();
if (!isAutoActive()) return;
markToolStart(event.toolCallId);
});
pi.on("tool_execution_end", async (event) => {
const { markToolEnd } = await loadAutoModule();
markToolEnd(event.toolCallId);
});
}
@ -1273,7 +1115,6 @@ async function buildGuidedExecuteContextInjection(prompt: string, basePath: stri
const resumeMatch = prompt.match(/Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i);
if (resumeMatch) {
const [, sliceId, milestoneId] = resumeMatch;
const { deriveState } = await loadStateModule();
const state = await deriveState(basePath);
if (
state.activeMilestone?.id === milestoneId &&

View file

@ -1,430 +0,0 @@
/**
* Mechanical Completion deterministic post-verification artifact generation.
*
* Pure functions that aggregate task-level outputs into slice/milestone summaries,
* UAT stubs, roadmap checkbox updates, and validation reports. Zero orchestration
* dependencies operates on filesystem paths and parsed structures only.
*
* ADR-003: replaces LLM-driven complete-slice and validate-milestone units with
* mechanical aggregation when the data is sufficient.
*/
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { loadFile, parseSummary } from "./files.js";
import { extractMarkdownSection } from "./auto-prompts.js";
import {
resolveTaskFiles,
resolveTaskJsonFiles,
resolveTasksDir,
resolveSliceFile,
resolveSlicePath,
resolveMilestoneFile,
resolveMilestonePath,
resolveGsdRootFile,
} from "./paths.js";
import type { Summary, SummaryFrontmatter } from "./types.js";
import type { EvidenceJSON } from "./verification-evidence.js";
// ─── Slice Completion ────────────────────────────────────────────────────────
/**
* Mechanically complete a slice by aggregating task summaries into:
* - S##-SUMMARY.md (aggregated frontmatter + task one-liners)
* - S##-UAT.md (extracted from plan Verification section)
* - Roadmap checkbox [x] update
*
* Returns true if completion succeeded, false if data is insufficient
* (serves as quality gate caller falls back to LLM completion).
*/
export async function mechanicalSliceCompletion(
base: string, mid: string, sid: string,
): Promise<boolean> {
const tDir = resolveTasksDir(base, mid, sid);
if (!tDir) return false;
// Read all task summaries
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
if (summaryFiles.length === 0) return false;
const taskSummaries: Array<{ taskId: string; summary: Summary }> = [];
for (const file of summaryFiles) {
const content = readFileSync(join(tDir, file), "utf-8");
if (!content.trim()) continue;
const summary = parseSummary(content);
const taskId = file.match(/^(T\d+)/)?.[1] ?? file;
taskSummaries.push({ taskId, summary });
}
if (taskSummaries.length === 0) return false;
// Quality gate: multi-task slices need substantive summaries
if (taskSummaries.length > 1) {
const totalContent = taskSummaries
.map(ts => ts.summary.whatHappened || ts.summary.oneLiner || "")
.join("");
if (totalContent.length < 200) return false;
}
// Aggregate frontmatter
const aggregated = aggregateFrontmatter(taskSummaries.map(ts => ts.summary.frontmatter));
// Build SUMMARY.md
const summaryLines: string[] = [
"---",
`id: ${sid}`,
`parent: ${mid}`,
`milestone: ${mid}`,
];
if (aggregated.provides.length > 0)
summaryLines.push(`provides:\n${aggregated.provides.map(p => ` - ${p}`).join("\n")}`);
if (aggregated.key_files.length > 0)
summaryLines.push(`key_files:\n${aggregated.key_files.map(f => ` - ${f}`).join("\n")}`);
if (aggregated.key_decisions.length > 0)
summaryLines.push(`key_decisions:\n${aggregated.key_decisions.map(d => ` - ${d}`).join("\n")}`);
if (aggregated.patterns_established.length > 0)
summaryLines.push(`patterns_established:\n${aggregated.patterns_established.map(p => ` - ${p}`).join("\n")}`);
if (aggregated.affects.length > 0)
summaryLines.push(`affects:\n${aggregated.affects.map(a => ` - ${a}`).join("\n")}`);
if (aggregated.observability_surfaces.length > 0)
summaryLines.push(`observability_surfaces:\n${aggregated.observability_surfaces.map(o => ` - ${o}`).join("\n")}`);
const allPassed = taskSummaries.every(ts => ts.summary.frontmatter.verification_result === "passed");
summaryLines.push(`verification_result: ${allPassed ? "passed" : "mixed"}`);
summaryLines.push(`completed_at: ${new Date().toISOString()}`);
summaryLines.push("---");
summaryLines.push("");
summaryLines.push(`# ${sid}: Slice Summary`);
summaryLines.push("");
// Task one-liners
for (const { taskId, summary } of taskSummaries) {
const line = summary.oneLiner || summary.title || taskId;
summaryLines.push(`- **${taskId}**: ${line}`);
}
summaryLines.push("");
const sDir = resolveSlicePath(base, mid, sid);
if (!sDir) return false;
const summaryPath = join(sDir, `${sid}-SUMMARY.md`);
atomicWriteSync(summaryPath, summaryLines.join("\n"));
process.stderr.write(`gsd-mechanical: wrote ${summaryPath}\n`);
// Build UAT.md from plan's Verification section
const planPath = resolveSliceFile(base, mid, sid, "PLAN");
if (planPath) {
const planContent = readFileSync(planPath, "utf-8");
const verification = extractMarkdownSection(planContent, "Verification");
if (verification) {
const uatContent = [
"---",
`id: ${sid}`,
`parent: ${mid}`,
"type: artifact-driven",
"---",
"",
`# ${sid}: UAT`,
"",
verification,
"",
].join("\n");
const uatPath = join(sDir, `${sid}-UAT.md`);
atomicWriteSync(uatPath, uatContent);
process.stderr.write(`gsd-mechanical: wrote ${uatPath}\n`);
}
}
// Mark slice [x] in ROADMAP
await markSliceInRoadmap(base, mid, sid);
// Append new decisions if any
await appendNewDecisions(base, taskSummaries.map(ts => ts.summary));
// Update requirements if all passed
if (allPassed) {
await mechanicalRequirementsUpdate(base, mid, sid, taskSummaries.map(ts => ts.summary));
}
return true;
}
// ─── Requirements Update ─────────────────────────────────────────────────────
/**
* Conservative requirements update: mark requirements Validated only if
* all tasks' verification passed.
*/
export async function mechanicalRequirementsUpdate(
_base: string, _mid: string, _sid: string, _taskSummaries: Summary[],
): Promise<void> {
// Conservative: requirements validation requires human or LLM judgment
// about whether the requirement is truly met. Mechanical completion only
// marks the slice done — requirement status updates are left to the
// existing validation pipeline.
}
// ─── Decision Aggregation ────────────────────────────────────────────────────
/**
* Collect key_decisions from task summaries, deduplicate against existing
* DECISIONS.md, and append new ones.
*/
export async function appendNewDecisions(
base: string, taskSummaries: Summary[],
): Promise<void> {
const allDecisions = taskSummaries.flatMap(s => s.frontmatter.key_decisions);
if (allDecisions.length === 0) return;
const decisionsPath = resolveGsdRootFile(base, "DECISIONS");
const existing = existsSync(decisionsPath)
? readFileSync(decisionsPath, "utf-8")
: "";
// Deduplicate — skip decisions whose text already appears in the file
const newDecisions = allDecisions.filter(d =>
d.trim() && !existing.includes(d.trim()),
);
if (newDecisions.length === 0) return;
const entries = newDecisions
.map(d => `- ${d} _(auto-aggregated from task summaries)_`)
.join("\n");
const updated = existing.trimEnd() + "\n\n### Auto-aggregated Decisions\n\n" + entries + "\n";
atomicWriteSync(decisionsPath, updated);
process.stderr.write(`gsd-mechanical: appended ${newDecisions.length} decision(s) to DECISIONS.md\n`);
}
// ─── Milestone Verification ──────────────────────────────────────────────────
export interface MilestoneVerificationResult {
verdict: "passed" | "failed" | "mixed";
checks: EvidenceJSON[];
uatResults: string[];
markdown: string;
}
/**
* Aggregate T##-VERIFY.json files and S##-UAT-RESULT.md files across all
* slices in a milestone to produce VALIDATION.md.
*/
export async function aggregateMilestoneVerification(
base: string, mid: string,
): Promise<MilestoneVerificationResult> {
const mDir = resolveMilestonePath(base, mid);
if (!mDir) return { verdict: "failed", checks: [], uatResults: [], markdown: "" };
const allChecks: EvidenceJSON[] = [];
const allUatResults: string[] = [];
// Scan all slices
const slicesDir = join(mDir, "slices");
if (!existsSync(slicesDir)) return { verdict: "failed", checks: [], uatResults: [], markdown: "" };
const sliceDirs = readdirSyncSafe(slicesDir).filter(name => /^S\d+/i.test(name)).sort();
for (const sliceName of sliceDirs) {
const sid = sliceName.match(/^(S\d+)/i)?.[1] ?? sliceName;
const tDir = resolveTasksDir(base, mid, sid);
if (tDir) {
const verifyFiles = resolveTaskJsonFiles(tDir, "VERIFY");
for (const vf of verifyFiles) {
try {
const content = readFileSync(join(tDir, vf), "utf-8");
const evidence = JSON.parse(content) as EvidenceJSON;
allChecks.push(evidence);
} catch {
// Skip malformed JSON
}
}
}
// Check for UAT result
const uatResultPath = resolveSliceFile(base, mid, sid, "UAT-RESULT");
if (uatResultPath) {
try {
const uatContent = readFileSync(uatResultPath, "utf-8");
allUatResults.push(`### ${sid}\n\n${uatContent}`);
} catch {
// Non-fatal
}
}
}
// Determine verdict
const allPassed = allChecks.length > 0 && allChecks.every(c => c.passed);
const anyFailed = allChecks.some(c => !c.passed);
const verdict: "passed" | "failed" | "mixed" = allPassed
? "passed"
: anyFailed
? (allChecks.some(c => c.passed) ? "mixed" : "failed")
: "passed"; // No checks = vacuously passed
// Build VALIDATION.md
const mdLines: string[] = [
"---",
`milestone: ${mid}`,
`verdict: ${verdict}`,
"remediation_round: 0",
`validated_at: ${new Date().toISOString()}`,
"---",
"",
`# ${mid}: Milestone Validation`,
"",
`**Verdict:** ${verdict}`,
"",
"## Verification Results",
"",
];
if (allChecks.length === 0) {
mdLines.push("_No verification evidence found._");
} else {
mdLines.push("| Task | Passed | Checks | Failed |");
mdLines.push("|------|--------|--------|--------|");
for (const check of allChecks) {
const failedCount = check.checks.filter(c => c.verdict === "fail").length;
mdLines.push(
`| ${check.taskId} | ${check.passed ? "yes" : "no"} | ${check.checks.length} | ${failedCount} |`,
);
}
}
if (allUatResults.length > 0) {
mdLines.push("");
mdLines.push("## UAT Results");
mdLines.push("");
mdLines.push(...allUatResults);
}
mdLines.push("");
const markdown = mdLines.join("\n");
// Write VALIDATION.md
const validationPath = join(mDir, `${mid}-VALIDATION.md`);
atomicWriteSync(validationPath, markdown);
process.stderr.write(`gsd-mechanical: wrote ${validationPath}\n`);
return { verdict, checks: allChecks, uatResults: allUatResults, markdown };
}
// ─── Milestone Summary ──────────────────────────────────────────────────────
/**
* Read all S##-SUMMARY.md files and produce M##-SUMMARY.md.
*/
export async function generateMilestoneSummary(
base: string, mid: string,
): Promise<string> {
const mDir = resolveMilestonePath(base, mid);
if (!mDir) return "";
const slicesDir = join(mDir, "slices");
if (!existsSync(slicesDir)) return "";
const sliceDirs = readdirSyncSafe(slicesDir).filter(name => /^S\d+/i.test(name)).sort();
const aggregatedProvides: string[] = [];
const aggregatedKeyFiles: string[] = [];
const aggregatedKeyDecisions: string[] = [];
const aggregatedPatterns: string[] = [];
const sliceOneLinerList: string[] = [];
for (const sliceName of sliceDirs) {
const sid = sliceName.match(/^(S\d+)/i)?.[1] ?? sliceName;
const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY");
if (!summaryPath) continue;
try {
const content = readFileSync(summaryPath, "utf-8");
const summary = parseSummary(content);
aggregatedProvides.push(...summary.frontmatter.provides);
aggregatedKeyFiles.push(...summary.frontmatter.key_files);
aggregatedKeyDecisions.push(...summary.frontmatter.key_decisions);
aggregatedPatterns.push(...summary.frontmatter.patterns_established);
sliceOneLinerList.push(`- **${sid}**: ${summary.oneLiner || summary.title || sid}`);
} catch {
sliceOneLinerList.push(`- **${sid}**: _(summary unavailable)_`);
}
}
const mdLines: string[] = [
"---",
`id: ${mid}`,
];
if (dedup(aggregatedProvides).length > 0)
mdLines.push(`provides:\n${dedup(aggregatedProvides).map(p => ` - ${p}`).join("\n")}`);
if (dedup(aggregatedKeyFiles).length > 0)
mdLines.push(`key_files:\n${dedup(aggregatedKeyFiles).map(f => ` - ${f}`).join("\n")}`);
if (dedup(aggregatedKeyDecisions).length > 0)
mdLines.push(`key_decisions:\n${dedup(aggregatedKeyDecisions).map(d => ` - ${d}`).join("\n")}`);
if (dedup(aggregatedPatterns).length > 0)
mdLines.push(`patterns_established:\n${dedup(aggregatedPatterns).map(p => ` - ${p}`).join("\n")}`);
mdLines.push(`completed_at: ${new Date().toISOString()}`);
mdLines.push("---");
mdLines.push("");
mdLines.push(`# ${mid}: Milestone Summary`);
mdLines.push("");
mdLines.push("## Slices");
mdLines.push("");
mdLines.push(...sliceOneLinerList);
mdLines.push("");
const content = mdLines.join("\n");
// Write M##-SUMMARY.md
const summaryPath = join(mDir, `${mid}-SUMMARY.md`);
atomicWriteSync(summaryPath, content);
process.stderr.write(`gsd-mechanical: wrote ${summaryPath}\n`);
return content;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function aggregateFrontmatter(fms: SummaryFrontmatter[]): {
provides: string[];
key_files: string[];
key_decisions: string[];
patterns_established: string[];
affects: string[];
observability_surfaces: string[];
} {
return {
provides: dedup(fms.flatMap(f => f.provides)),
key_files: dedup(fms.flatMap(f => f.key_files)),
key_decisions: dedup(fms.flatMap(f => f.key_decisions)),
patterns_established: dedup(fms.flatMap(f => f.patterns_established)),
affects: dedup(fms.flatMap(f => f.affects)),
observability_surfaces: dedup(fms.flatMap(f => f.observability_surfaces)),
};
}
function dedup(arr: string[]): string[] {
return [...new Set(arr.filter(s => s.trim()))];
}
async function markSliceInRoadmap(base: string, mid: string, sid: string): Promise<void> {
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (!roadmapPath) return;
const content = await loadFile(roadmapPath);
if (!content) return;
const updated = content.replace(
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sid}:`, "m"),
`$1[x] **${sid}:`,
);
if (updated !== content) {
atomicWriteSync(roadmapPath, updated);
process.stderr.write(`gsd-mechanical: marked ${sid} done in ROADMAP\n`);
}
}
function readdirSyncSafe(dir: string): string[] {
try {
return readdirSync(dir);
} catch {
return [];
}
}

View file

@ -14,8 +14,6 @@ import type {
import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
import { parseUnitId } from "./unit-id.js";
// ─── Hook Queue State ──────────────────────────────────────────────────────
@ -150,7 +148,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
};
// Build the prompt with variable substitution
const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId);
const [mid, sid, tid] = triggerUnitId.split("/");
const prompt = config.prompt
.replace(/\{milestoneId\}/g, mid ?? "")
.replace(/\{sliceId\}/g, sid ?? "")
@ -209,14 +207,16 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null {
* - Milestone-level (M001): .gsd/M001/{artifact}
*/
export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
if (mid && sid && tid) {
return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
const parts = unitId.split("/");
if (parts.length === 3) {
const [mid, sid, tid] = parts;
return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
}
if (mid && sid) {
return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
if (parts.length === 2) {
const [mid, sid] = parts;
return join(basePath, ".gsd", mid, "slices", sid, artifactName);
}
return join(gsdRoot(basePath), mid, artifactName);
return join(basePath, ".gsd", parts[0], artifactName);
}
// ═══════════════════════════════════════════════════════════════════════════
@ -252,7 +252,7 @@ export function runPreDispatchHooks(
return { action: "proceed", prompt, firedHooks: [] };
}
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
const [mid, sid, tid] = unitId.split("/");
const substitute = (text: string): string =>
text
.replace(/\{milestoneId\}/g, mid ?? "")
@ -310,7 +310,7 @@ export function runPreDispatchHooks(
const HOOK_STATE_FILE = "hook-state.json";
function hookStatePath(basePath: string): string {
return join(gsdRoot(basePath), HOOK_STATE_FILE);
return join(basePath, ".gsd", HOOK_STATE_FILE);
}
/**
@ -323,7 +323,7 @@ export function persistHookState(basePath: string): void {
savedAt: new Date().toISOString(),
};
try {
const dir = gsdRoot(basePath);
const dir = join(basePath, ".gsd");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8");
} catch {
@ -465,7 +465,7 @@ export function triggerHookManually(
activeHook.cycle = currentCycle;
// Build the prompt with variable substitution
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
const [mid, sid, tid] = unitId.split("/");
const prompt = hook.prompt
.replace(/\{milestoneId\}/g, mid ?? "")
.replace(/\{sliceId\}/g, sid ?? "")

View file

@ -28,246 +28,111 @@ export interface ProgressScore {
}
export interface ProgressSignal {
name: string;
level: ProgressLevel;
detail: string;
kind: "positive" | "negative" | "neutral";
label: string;
}
// ── Signal Evaluators ──────────────────────────────────────────────────────
function evaluateHealthTrend(): ProgressSignal {
const trend = getHealthTrend();
switch (trend) {
case "improving":
return { name: "health_trend", level: "green", detail: "Health improving" };
case "stable":
return { name: "health_trend", level: "green", detail: "Health stable" };
case "degrading":
return { name: "health_trend", level: "red", detail: "Health degrading" };
case "unknown":
return { name: "health_trend", level: "green", detail: "Insufficient data" };
}
function escalateLevel(level: ProgressLevel, next: ProgressLevel): ProgressLevel {
const ranks: Record<ProgressLevel, number> = {
green: 0,
yellow: 1,
red: 2,
};
return ranks[next] > ranks[level] ? next : level;
}
function evaluateErrorStreak(): ProgressSignal {
const streak = getConsecutiveErrorUnits();
if (streak === 0) {
return { name: "error_streak", level: "green", detail: "No consecutive errors" };
}
if (streak <= 2) {
return { name: "error_streak", level: "yellow", detail: `${streak} consecutive error unit(s)` };
}
return { name: "error_streak", level: "red", detail: `${streak} consecutive error units` };
}
function evaluateRecentErrors(): ProgressSignal {
const history = getHealthHistory();
if (history.length === 0) {
return { name: "recent_errors", level: "green", detail: "No health data yet" };
}
const latest = history[history.length - 1]!;
if (latest.errors === 0 && latest.warnings <= 1) {
return { name: "recent_errors", level: "green", detail: `${latest.errors}E/${latest.warnings}W` };
}
if (latest.errors === 0) {
return { name: "recent_errors", level: "yellow", detail: `${latest.warnings} warning(s)` };
}
if (latest.errors <= 2) {
return { name: "recent_errors", level: "yellow", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
}
return { name: "recent_errors", level: "red", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
}
function evaluateArtifactProduction(): ProgressSignal {
const history = getHealthHistory();
if (history.length < 2) {
return { name: "artifact_production", level: "green", detail: "Insufficient data" };
}
const totalFixes = history.reduce((sum, s) => sum + s.fixesApplied, 0);
const recent = history.slice(-3);
const recentFixes = recent.reduce((sum, s) => sum + s.fixesApplied, 0);
// If recent units are all producing fixes but errors aren't decreasing,
// doctor is fighting fires but not making headway
if (recentFixes > 3 && recent.every(s => s.errors > 0)) {
return { name: "artifact_production", level: "yellow", detail: "Doctor applying fixes but errors persist" };
}
return { name: "artifact_production", level: "green", detail: `${totalFixes} total fixes applied` };
}
function evaluateDispatchVelocity(): ProgressSignal {
const history = getHealthHistory();
if (history.length < 3) {
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
}
// Check time between recent snapshots — are units completing at a reasonable rate?
const recent = history.slice(-5);
if (recent.length < 2) {
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
}
const timeDiffs: number[] = [];
for (let i = 1; i < recent.length; i++) {
timeDiffs.push(recent[i]!.timestamp - recent[i - 1]!.timestamp);
}
const avgTimeMs = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length;
const avgTimeMins = Math.round(avgTimeMs / 60_000);
// If average unit time is > 15 minutes, something might be wrong
if (avgTimeMins > 15) {
return { name: "dispatch_velocity", level: "yellow", detail: `Units averaging ${avgTimeMins}min each` };
}
return { name: "dispatch_velocity", level: "green", detail: `Units averaging ${avgTimeMins || "<1"}min each` };
}
// ── Main API ───────────────────────────────────────────────────────────────
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Compute the current progress score by evaluating all available signals.
* Returns a composite score with individual signal details.
* Compute the current progress score from health signals.
*/
export function computeProgressScore(): ProgressScore {
const signals: ProgressSignal[] = [
evaluateHealthTrend(),
evaluateErrorStreak(),
evaluateRecentErrors(),
evaluateArtifactProduction(),
evaluateDispatchVelocity(),
];
const signals: ProgressSignal[] = [];
let level: ProgressLevel = "green";
// Overall level: worst of all signals
const level = signals.some(s => s.level === "red")
? "red"
: signals.some(s => s.level === "yellow")
? "yellow"
: "green";
// Check consecutive errors
const consecutiveErrors = getConsecutiveErrorUnits();
if (consecutiveErrors >= 3) {
signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error units` });
level = escalateLevel(level, "red");
} else if (consecutiveErrors >= 1) {
signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error unit(s)` });
level = escalateLevel(level, "yellow");
}
// Build summary from the most important signals
const summary = buildSummary(level, signals);
// Check health trend
const trend = getHealthTrend();
if (trend === "degrading") {
signals.push({ kind: "negative", label: "Health trend declining" });
level = escalateLevel(level, "yellow");
} else if (trend === "improving") {
signals.push({ kind: "positive", label: "Health trend improving" });
} else if (trend === "stable") {
signals.push({ kind: "neutral", label: "Health trend stable" });
}
// Check recent history
const history = getHealthHistory();
if (history.length === 0) {
signals.push({ kind: "neutral", label: "No health data yet" });
}
const summary = level === "green"
? "Progressing well"
: level === "yellow"
? "Some issues detected"
: "Stuck or erroring";
return { level, summary, signals };
}
/**
* Compute progress score with additional context from the current unit.
* Compute progress score with additional context for dashboard display.
*/
export function computeProgressScoreWithContext(context: {
currentUnitType?: string;
currentUnitId?: string;
completedUnits?: number;
totalUnits?: number;
retryCount?: number;
maxRetries?: number;
sameUnitCount?: number;
recoveryCount?: number;
completedCount?: number;
}): ProgressScore {
const base = computeProgressScore();
// Add retry signal if available
if (context.retryCount !== undefined && context.maxRetries !== undefined) {
const retrySignal: ProgressSignal = context.retryCount === 0
? { name: "retry_count", level: "green", detail: "No retries" }
: context.retryCount <= 2
? { name: "retry_count", level: "yellow", detail: `Retry ${context.retryCount}/${context.maxRetries}` }
: { name: "retry_count", level: "red", detail: `Retry ${context.retryCount}/${context.maxRetries} — looping` };
base.signals.push(retrySignal);
// Re-evaluate level
if (retrySignal.level === "red") base.level = "red";
else if (retrySignal.level === "yellow" && base.level === "green") base.level = "yellow";
if (context.sameUnitCount && context.sameUnitCount >= 3) {
base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}× consecutively` });
base.level = escalateLevel(base.level, "red");
base.summary = "Stuck on same unit";
} else if (context.sameUnitCount && context.sameUnitCount >= 2) {
base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}×` });
base.level = escalateLevel(base.level, "yellow");
}
// Build richer summary with context
base.summary = buildSummaryWithContext(base.level, base.signals, context);
if (context.recoveryCount && context.recoveryCount > 0) {
base.signals.push({ kind: "negative", label: `${context.recoveryCount} recovery attempts` });
base.level = escalateLevel(base.level, "yellow");
}
if (context.completedCount && context.completedCount > 0) {
base.signals.push({ kind: "positive", label: `${context.completedCount} units completed` });
}
return base;
}
// ── Formatting ─────────────────────────────────────────────────────────────
function buildSummary(level: ProgressLevel, signals: ProgressSignal[]): string {
switch (level) {
case "green":
return "Progressing well";
case "yellow": {
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
return `Struggling — ${issues[0] ?? "minor issues detected"}`;
}
case "red": {
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
return `Stuck — ${issues[0] ?? "critical issues detected"}`;
}
}
}
function buildSummaryWithContext(
level: ProgressLevel,
signals: ProgressSignal[],
context: {
currentUnitType?: string;
currentUnitId?: string;
completedUnits?: number;
totalUnits?: number;
retryCount?: number;
maxRetries?: number;
},
): string {
const unitLabel = context.currentUnitId
? ` ${context.currentUnitId}`
: "";
const progressLabel = context.completedUnits !== undefined && context.totalUnits !== undefined
? ` (${context.completedUnits} of ${context.totalUnits} done)`
: "";
switch (level) {
case "green":
return `Progressing well —${unitLabel}${progressLabel}`;
case "yellow": {
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
const retryInfo = context.retryCount ? `, attempt ${context.retryCount}/${context.maxRetries}` : "";
return `Struggling —${unitLabel}${retryInfo}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "issues detected"}`;
}
case "red": {
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
return `Stuck —${unitLabel}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "critical issues"}`;
}
}
}
/**
* Format progress score as a single-line traffic light for TUI display.
* Format a one-line progress indicator for dashboard/status display.
*/
export function formatProgressLine(score: ProgressScore): string {
const icon = score.level === "green" ? "\uD83D\uDFE2"
: score.level === "yellow" ? "\uD83D\uDFE1"
: "\uD83D\uDD34";
const icon = score.level === "green" ? "●" : score.level === "yellow" ? "◐" : "○";
return `${icon} ${score.summary}`;
}
/**
* Format a detailed progress report showing all signals.
* Format a multi-line progress report.
*/
export function formatProgressReport(score: ProgressScore): string {
const lines: string[] = [];
lines.push(formatProgressLine(score));
lines.push("");
lines.push("Signals:");
const lines = [formatProgressLine(score)];
for (const signal of score.signals) {
const icon = signal.level === "green" ? "\u2705"
: signal.level === "yellow" ? "\u26A0\uFE0F"
: "\uD83D\uDED1";
lines.push(` ${icon} ${signal.name}: ${signal.detail}`);
const prefix = signal.kind === "positive" ? " ✓" : signal.kind === "negative" ? " ✗" : " ·";
lines.push(`${prefix} ${signal.label}`);
}
return lines.join("\n");
}

View file

@ -10,12 +10,24 @@
*/
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadPrompt } from "./prompt-loader.js";
import { gsdRoot } from "./paths.js";
import { createGitService, runGit } from "./git-service.js";
import { getErrorMessage } from "./error-utils.js";
import { GitServiceImpl, runGit } from "./git-service.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import { nativeHasStagedChanges } from "./native-git-bridge.js";
interface QuickReturnState {
basePath: string;
originalBranch: string;
quickBranch: string;
taskNum: number;
slug: string;
description: string;
}
let pendingQuickReturn: QuickReturnState | null = null;
// ─── Quick Task Helpers ───────────────────────────────────────────────────────
@ -65,6 +77,84 @@ function ensureQuickDir(basePath: string, taskNum: number, slug: string): string
return taskDir;
}
function quickReturnStatePath(basePath: string): string {
return join(gsdRoot(basePath), "runtime", "quick-return.json");
}
function persistPendingReturn(state: QuickReturnState): void {
pendingQuickReturn = state;
mkdirSync(join(gsdRoot(state.basePath), "runtime"), { recursive: true });
writeFileSync(quickReturnStatePath(state.basePath), JSON.stringify(state) + "\n", "utf-8");
}
function readPendingReturn(basePath: string): QuickReturnState | null {
if (pendingQuickReturn && pendingQuickReturn.basePath === basePath) {
return pendingQuickReturn;
}
try {
const raw = readFileSync(quickReturnStatePath(basePath), "utf-8");
const parsed = JSON.parse(raw) as Partial<QuickReturnState>;
if (
typeof parsed.basePath === "string"
&& typeof parsed.originalBranch === "string"
&& typeof parsed.quickBranch === "string"
&& typeof parsed.taskNum === "number"
&& typeof parsed.slug === "string"
&& typeof parsed.description === "string"
) {
pendingQuickReturn = parsed as QuickReturnState;
return pendingQuickReturn;
}
} catch {
// No persisted quick-return state
}
return null;
}
function clearPendingReturn(basePath: string): void {
if (pendingQuickReturn?.basePath === basePath) {
pendingQuickReturn = null;
}
rmSync(quickReturnStatePath(basePath), { force: true });
}
function hasStagedChanges(basePath: string): boolean {
return nativeHasStagedChanges(basePath);
}
export function cleanupQuickBranch(basePath = process.cwd()): boolean {
const state = readPendingReturn(basePath);
if (!state) return false;
const repoPath = state.basePath;
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
const git = new GitServiceImpl(repoPath, gitPrefs);
if (git.getCurrentBranch() === state.quickBranch) {
try {
git.autoCommit("quick-task", `Q${state.taskNum}`, []);
} catch {
// Best-effort: quick work may already be committed.
}
}
if (git.getCurrentBranch() !== state.originalBranch) {
runGit(repoPath, ["checkout", state.originalBranch]);
}
runGit(repoPath, ["merge", "--squash", state.quickBranch]);
if (hasStagedChanges(repoPath)) {
runGit(repoPath, ["commit", "-m", `quick(Q${state.taskNum}): ${state.slug}`]);
}
runGit(repoPath, ["branch", "-D", state.quickBranch], { allowFailure: true });
clearPendingReturn(repoPath);
return true;
}
// ─── Main Handler ─────────────────────────────────────────────────────────────
export async function handleQuick(
@ -102,33 +192,41 @@ export async function handleQuick(
const taskDirRel = `.gsd/quick/${taskNum}-${slug}`;
const date = new Date().toISOString().split("T")[0];
// Create git branch for the quick task (unless isolation: none)
const git = createGitService(basePath);
// Create git branch for the quick task
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
const git = new GitServiceImpl(basePath, gitPrefs);
const branchName = `gsd/quick/${taskNum}-${slug}`;
const skipBranch = git.prefs.isolation === "none";
let originalBranch = git.getCurrentBranch();
let branchCreated = false;
let originalBranch: string | undefined;
if (!skipBranch) {
try {
originalBranch = git.getCurrentBranch();
if (originalBranch !== branchName) {
// Auto-commit any dirty state before switching
try {
git.autoCommit("quick-task", `Q${taskNum}`, []);
} catch { /* nothing to commit — fine */ }
try {
const current = originalBranch;
if (current !== branchName) {
// Auto-commit any dirty state before switching
try {
git.autoCommit("quick-task", `Q${taskNum}`, []);
} catch { /* nothing to commit — fine */ }
runGit(basePath, ["checkout", "-b", branchName]);
branchCreated = true;
}
} catch (err) {
// Branch creation failed — continue on current branch
const message = getErrorMessage(err);
ctx.ui.notify(`Could not create branch ${branchName}: ${message}. Working on current branch.`, "warning");
runGit(basePath, ["checkout", "-b", branchName]);
branchCreated = true;
}
} catch (err) {
// Branch creation failed — continue on current branch
const message = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`Could not create branch ${branchName}: ${message}. Working on current branch.`, "warning");
}
const actualBranch = branchCreated ? branchName : git.getCurrentBranch();
if (actualBranch === branchName && originalBranch !== branchName) {
persistPendingReturn({
basePath,
originalBranch,
quickBranch: branchName,
taskNum,
slug,
description,
});
}
// Notify user
ctx.ui.notify(
@ -156,106 +254,4 @@ export async function handleQuick(
},
{ triggerTurn: true },
);
// Schedule branch merge-back after the quick task agent session ends.
// Without this, auto-mode resumes on the quick-task branch (#1269).
if (branchCreated && originalBranch) {
_pendingQuickBranchReturn = {
basePath,
originalBranch,
quickBranch: branchName,
taskNum,
slug,
description,
};
// Persist to disk so recovery works across session crashes (#1293).
persistPendingReturn(_pendingQuickBranchReturn, basePath);
}
}
/** Pending quick-task branch return — consumed by cleanupQuickBranch(). */
let _pendingQuickBranchReturn: {
basePath: string;
originalBranch: string;
quickBranch: string;
taskNum: number;
slug: string;
description: string;
} | null = null;
// ─── Disk Persistence ─────────────────────────────────────────────────────
/** Path to the pending quick-task return file. */
function pendingReturnPath(basePath: string): string {
return join(gsdRoot(basePath), "runtime", "quick-return.json");
}
/** Write pending return state to disk. */
function persistPendingReturn(state: NonNullable<typeof _pendingQuickBranchReturn>, basePath: string): void {
const filePath = pendingReturnPath(basePath);
mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true });
writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
}
/** Remove pending return file from disk. */
function clearPendingReturn(basePath: string): void {
try { unlinkSync(pendingReturnPath(basePath)); } catch { /* already gone */ }
}
/** Load pending return from disk (cross-session recovery). */
function loadPendingReturn(basePath: string): NonNullable<typeof _pendingQuickBranchReturn> | null {
const filePath = pendingReturnPath(basePath);
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf-8"));
} catch {
return null;
}
}
/**
* Merge the quick-task branch back to the original branch and switch.
* Called from the agent_end handler after a quick task completes.
*
* Checks both in-memory state (same session) and disk state (cross-session
* recovery for crashed/interrupted sessions).
*
* Returns true if a branch return was performed.
*/
export function cleanupQuickBranch(): boolean {
// Prefer in-memory state; fall back to disk for cross-session recovery
let state = _pendingQuickBranchReturn;
if (!state) {
// Try loading from disk — handles the case where the session that
// started the quick task crashed before agent_end could run (#1293).
const basePath = process.cwd();
state = loadPendingReturn(basePath);
}
if (!state) return false;
_pendingQuickBranchReturn = null;
const { basePath, originalBranch, quickBranch, taskNum, slug, description } = state;
try {
// Auto-commit any remaining work
try { runGit(basePath, ["add", "-A"]); } catch {}
try { runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${slug}`]); } catch {}
// Switch back and merge
runGit(basePath, ["checkout", originalBranch]);
try {
runGit(basePath, ["merge", "--squash", quickBranch]);
runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${description.slice(0, 72)}`]);
} catch { /* merge conflict or nothing — non-fatal */ }
// Clean up quick branch
try { runGit(basePath, ["branch", "-D", quickBranch]); } catch {}
// Clean up disk state
clearPendingReturn(basePath);
return true;
} catch {
// Cleanup failed — leave disk state for next attempt
return false;
}
}

View file

@ -30,6 +30,7 @@ token_profile:
phases:
skip_research:
skip_reassess:
reassess_after_slice:
skip_slice_research:
dynamic_routing:
enabled:

View file

@ -1,14 +1,9 @@
/**
* agent-end-retry.test.ts Verifies the deferred agent_end retry mechanism (#1072).
* agent-end-retry.test.ts Regression checks for the post-#1419 agent_end model.
*
* When handleAgentEnd is already running and a second agent_end event fires
* (e.g. a hook/triage/quick-task unit dispatched inside handleAgentEnd completes
* before it returns), the reentrancy guard must not silently drop the event.
* Instead, it should queue a retry via pendingAgentEndRetry so the completed
* unit's agent_end is processed after the current handler finishes.
*
* Without this, auto-mode can stall permanently in the "summarizing" phase
* with no unit running and no watchdog set.
* The old recursive handleAgentEnd retry path is gone. The loop now keeps
* pendingResolve + pendingAgentEndQueue on AutoSession, and handleAgentEnd is
* only a thin compatibility wrapper around resolveAgentEnd().
*/
import test from "node:test";
@ -29,79 +24,57 @@ function getSessionTsSource(): string {
return readFileSync(SESSION_TS_PATH, "utf-8");
}
// ── AutoSession must declare pendingAgentEndRetry ────────────────────────────
test("AutoSession declares pendingAgentEndRetry field", () => {
test("AutoSession declares pending agent_end queue state", () => {
const source = getSessionTsSource();
assert.ok(
source.includes("pendingAgentEndRetry"),
"AutoSession (auto/session.ts) must declare pendingAgentEndRetry field for deferred retry",
source.includes("pendingResolve"),
"AutoSession must declare pendingResolve for the in-flight unit promise",
);
assert.ok(
source.includes("pendingAgentEndQueue"),
"AutoSession must declare pendingAgentEndQueue for between-iteration agent_end events",
);
});
test("AutoSession resets pendingAgentEndRetry in reset()", () => {
test("AutoSession reset clears pending agent_end queue state", () => {
const source = getSessionTsSource();
// Find the reset() method — it's declared as "reset(): void {"
const resetIdx = source.indexOf("reset(): void");
assert.ok(resetIdx > -1, "AutoSession must have a reset() method");
const resetBlock = source.slice(resetIdx, resetIdx + 3000);
const resetBlock = source.slice(resetIdx, resetIdx + 4000);
assert.ok(
resetBlock.includes("pendingAgentEndRetry"),
"reset() must clear pendingAgentEndRetry",
resetBlock.includes("this.pendingResolve = null"),
"reset() must clear pendingResolve",
);
assert.ok(
resetBlock.includes("this.pendingAgentEndQueue = []"),
"reset() must clear pendingAgentEndQueue",
);
});
// ── handleAgentEnd reentrancy guard must queue retry ─────────────────────────
test("legacy pendingAgentEndRetry state is gone", () => {
const source = getSessionTsSource();
assert.ok(
!source.includes("pendingAgentEndRetry"),
"AutoSession should no longer use legacy pendingAgentEndRetry state",
);
});
test("handleAgentEnd sets pendingAgentEndRetry when reentrant", () => {
test("handleAgentEnd is a thin compatibility wrapper", () => {
const source = getAutoTsSource();
// Find the handleAgentEnd function
const fnIdx = source.indexOf("export async function handleAgentEnd");
assert.ok(fnIdx > -1, "handleAgentEnd must exist in auto.ts");
// The reentrancy guard section (within ~500 chars of the function start)
const guardBlock = source.slice(fnIdx, fnIdx + 800);
assert.ok(
guardBlock.includes("s.handlingAgentEnd"),
"handleAgentEnd must check s.handlingAgentEnd",
);
assert.ok(
guardBlock.includes("pendingAgentEndRetry = true"),
"reentrancy guard must set pendingAgentEndRetry = true instead of silently dropping (#1072)",
);
});
// ── finally block must process pendingAgentEndRetry ──────────────────────────
test("handleAgentEnd finally block retries if pendingAgentEndRetry is set", () => {
const source = getAutoTsSource();
const fnIdx = source.indexOf("export async function handleAgentEnd");
assert.ok(fnIdx > -1, "handleAgentEnd must exist");
// Find the finally block within handleAgentEnd (search for the closing pattern)
const fnBlock = source.slice(fnIdx, source.indexOf("\n// ─── ", fnIdx + 100));
assert.ok(
fnBlock.includes("pendingAgentEndRetry"),
"handleAgentEnd finally block must check pendingAgentEndRetry",
fnBlock.includes("resolveAgentEnd("),
"handleAgentEnd must delegate to resolveAgentEnd",
);
assert.ok(
fnBlock.includes("setImmediate"),
"deferred retry must use setImmediate to avoid stack overflow (#1072)",
!fnBlock.includes("pendingAgentEndRetry"),
"handleAgentEnd must not use legacy retry state",
);
assert.ok(
fnBlock.includes("handleAgentEnd(ctx, pi)"),
"deferred retry must call handleAgentEnd recursively (#1072)",
);
});
// ── Regression: reentrancy guard must NOT silently return ─────────────────────
test("reentrancy guard references issue #1072", () => {
const source = getAutoTsSource();
const fnIdx = source.indexOf("export async function handleAgentEnd");
const guardBlock = source.slice(fnIdx, fnIdx + 800);
assert.ok(
guardBlock.includes("1072"),
"reentrancy guard comment must reference #1072 for traceability",
!fnBlock.includes("dispatchNextUnit"),
"handleAgentEnd must not dispatch recursively",
);
});

View file

@ -12,7 +12,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, realpathSync, readFileSync } from "node:fs";
import {
mkdtempSync,
mkdirSync,
rmSync,
writeFileSync,
existsSync,
realpathSync,
readFileSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
@ -28,11 +36,17 @@ import {
const __dirname = dirname(fileURLToPath(import.meta.url));
function run(command: string, cwd: string): string {
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
return execSync(command, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
}
function createTempRepo(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-all-complete-test-")));
const dir = realpathSync(
mkdtempSync(join(tmpdir(), "gsd-all-complete-test-")),
);
run("git init", dir);
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
@ -63,41 +77,54 @@ function createMilestoneArtifacts(dir: string, mid: string): void {
// ─── Source-level: verify the merge code exists in the "all complete" path ────
test("auto.ts 'all milestones complete' path merges before stopping (#962)", () => {
const autoSrc = readFileSync(join(__dirname, "..", "auto.ts"), "utf-8");
test("auto-loop 'all milestones complete' path merges before stopping (#962)", () => {
const loopSrc = readFileSync(join(__dirname, "..", "auto-loop.ts"), "utf-8");
const resolverSrc = readFileSync(
join(__dirname, "..", "worktree-resolver.ts"),
"utf-8",
);
// Find the "incomplete.length === 0" block
const incompleteIdx = autoSrc.indexOf("incomplete.length === 0");
assert.ok(incompleteIdx > -1, "auto.ts should have 'incomplete.length === 0' check");
const incompleteIdx = loopSrc.indexOf("incomplete.length === 0");
assert.ok(
incompleteIdx > -1,
"auto-loop.ts should have 'incomplete.length === 0' check",
);
// The merge call must appear BETWEEN the incomplete check and the stopAuto call.
// After the #1308 refactor, the merge is delegated to tryMergeMilestone.
const blockAfterIncomplete = autoSrc.slice(incompleteIdx, incompleteIdx + 3000);
const blockAfterIncomplete = loopSrc.slice(
incompleteIdx,
incompleteIdx + 3000,
);
assert.ok(
blockAfterIncomplete.includes("tryMergeMilestone"),
"auto.ts should call tryMergeMilestone in the 'all milestones complete' path",
blockAfterIncomplete.includes("deps.resolver.mergeAndExit"),
"auto-loop.ts should call resolver.mergeAndExit in the 'all milestones complete' path",
);
// The merge should come before stopAuto in this block
const mergePos = blockAfterIncomplete.indexOf("tryMergeMilestone");
const mergePos = blockAfterIncomplete.indexOf("deps.resolver.mergeAndExit");
const stopPos = blockAfterIncomplete.indexOf("stopAuto");
assert.ok(
mergePos < stopPos,
"tryMergeMilestone should be called before stopAuto in the 'all complete' path",
"resolver.mergeAndExit should be called before stopAuto in the 'all complete' path",
);
// Verify tryMergeMilestone handles both worktree and branch isolation
const helperIdx = autoSrc.indexOf("function tryMergeMilestone");
assert.ok(helperIdx > -1, "tryMergeMilestone helper should exist");
const helperBlock = autoSrc.slice(helperIdx, helperIdx + 2000);
const helperIdx = resolverSrc.indexOf("mergeAndExit(milestoneId");
assert.ok(
helperBlock.includes("isInAutoWorktree"),
"tryMergeMilestone should check isInAutoWorktree for worktree mode",
helperIdx > -1,
"WorktreeResolver.mergeAndExit helper should exist",
);
const helperBlock = resolverSrc.slice(helperIdx, helperIdx + 2600);
assert.ok(
helperBlock.includes('mode === "worktree"') ||
helperBlock.includes('mode: "worktree"'),
"WorktreeResolver.mergeAndExit should handle worktree mode",
);
assert.ok(
helperBlock.includes("getIsolationMode") || helperBlock.includes("isolationMode"),
"tryMergeMilestone should check isolation mode for branch mode",
helperBlock.includes('mode === "branch"') ||
helperBlock.includes('mode: "branch"'),
"WorktreeResolver.mergeAndExit should handle branch mode",
);
});
@ -124,23 +151,38 @@ test("single milestone worktree is merged to main when all complete (#962)", ()
run('git commit -m "feat(M001): add feature"', wt);
// Simulate the fix: merge before stopping (what the "all complete" path now does)
const roadmapPath = join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md");
const roadmapPath = join(
tempDir,
".gsd",
"milestones",
"M001",
"M001-ROADMAP.md",
);
const roadmapContent = readFileSync(roadmapPath, "utf-8");
const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent);
// Verify work is on main
assert.ok(existsSync(join(tempDir, "feature.ts")), "feature.ts should be on main after merge");
assert.ok(
existsSync(join(tempDir, "feature.ts")),
"feature.ts should be on main after merge",
);
assert.equal(process.cwd(), tempDir, "cwd restored to project root");
assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree");
assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared");
// Verify milestone branch was cleaned up
const branches = run("git branch", tempDir);
assert.ok(!branches.includes("milestone/M001"), "milestone branch should be deleted");
assert.ok(
!branches.includes("milestone/M001"),
"milestone branch should be deleted",
);
// Verify squash commit on main
const log = run("git log --oneline -3", tempDir);
assert.ok(log.includes("M001"), "squash commit on main should reference M001");
assert.ok(
log.includes("M001"),
"squash commit on main should reference M001",
);
assert.ok(mergeResult.commitMessage.length > 0, "commit message returned");
} finally {
@ -171,7 +213,10 @@ test("last milestone worktree is merged when it's the final one (#962)", () => {
writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n");
run("git add .", wt1);
run('git commit -m "feat(M001): m001 work"', wt1);
const roadmap1 = readFileSync(join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf-8");
const roadmap1 = readFileSync(
join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
"utf-8",
);
mergeMilestoneToMain(tempDir, "M001", roadmap1);
// Now complete M002 (the LAST milestone — this is the #962 scenario)
@ -179,7 +224,10 @@ test("last milestone worktree is merged when it's the final one (#962)", () => {
writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n");
run("git add .", wt2);
run('git commit -m "feat(M002): m002 work"', wt2);
const roadmap2 = readFileSync(join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "utf-8");
const roadmap2 = readFileSync(
join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"),
"utf-8",
);
mergeMilestoneToMain(tempDir, "M002", roadmap2);
// Both features should now be on main

View file

@ -5,7 +5,7 @@ import {
getBudgetAlertLevel,
getBudgetEnforcementAction,
getNewBudgetAlertLevel,
} from "../auto-budget.js";
} from "../auto.js";
test("getBudgetAlertLevel returns the expected threshold bucket", () => {
assert.equal(getBudgetAlertLevel(0.10), 0);

View file

@ -1,691 +0,0 @@
/**
* auto-dispatch-loop.test.ts End-to-end regression tests for the
* auto-mode dispatch loop: deriveState() resolveDispatch()
*
* Exercises the full state-machine chain WITHOUT an LLM. Each test
* creates a .gsd/ filesystem fixture, derives state, runs the dispatch
* table, and verifies the correct unit type/id is produced.
*
* Regression coverage for:
* #1270 Replaying completed run-uat units
* #1277 Non-artifact UATs dispatched, blocking progression
* #1241 Slice progression gated on file existence, not verdict content
* #909 Missing task plan files infinite plan-slice loop
* #807 Prose slice headers not parsed "No slice eligible" block
* #1248 Prose header regex only matched H2 with colon separator
* #1289 Crash recovery false-positive on own PID
* #1217 (orphaned processes tested via post-unit, not dispatch)
*
* Pattern: create fixture deriveState resolveDispatch assert
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { deriveState, invalidateStateCache } from '../state.ts';
import { resolveDispatch, type DispatchContext } from '../auto-dispatch.ts';
import { parseRoadmapSlices } from '../roadmap-slices.ts';
import { checkNeedsRunUat } from '../auto-prompts.ts';
import { checkIdempotency, type IdempotencyContext } from '../auto-idempotency.ts';
import { invalidateAllCaches } from '../cache.ts';
import { AutoSession } from '../auto/session.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Fixture Helpers
// ═══════════════════════════════════════════════════════════════════════════
function createBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-dispatch-loop-'));
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
return base;
}
function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
}
function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}-${suffix}.md`), content);
}
function writeTaskFile(base: string, mid: string, sid: string, tid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid, 'tasks');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${tid}-${suffix}.md`), content);
}
/** Standard machine-readable roadmap with checkbox slices */
function standardRoadmap(mid: string, title: string, slices: Array<{ id: string; title: string; done: boolean; risk?: string; depends?: string[] }>): string {
const lines = [
`# ${mid}: ${title}`,
'',
'## Slices',
'',
];
for (const s of slices) {
const check = s.done ? 'x' : ' ';
const risk = s.risk ?? 'low';
const deps = s.depends ?? [];
lines.push(`- [${check}] **${s.id}: ${s.title}** \`risk:${risk}\` \`depends:[${deps.join(',')}]\``);
}
lines.push('', '## Boundary Map', '');
return lines.join('\n');
}
/** Standard slice plan with tasks */
function standardPlan(sid: string, title: string, tasks: Array<{ id: string; title: string; done: boolean; est?: string }>): string {
const lines = [
`# ${sid}: ${title}`,
'',
'## Tasks',
'',
];
for (const t of tasks) {
const check = t.done ? 'x' : ' ';
const est = t.est ?? '1h';
lines.push(`- [${check}] **${t.id}: ${t.title}** \`est:${est}\``);
}
return lines.join('\n');
}
function freshState(): void {
invalidateAllCaches();
invalidateStateCache();
}
async function dispatchFor(base: string): Promise<ReturnType<typeof resolveDispatch>> {
freshState();
const state = await deriveState(base);
const mid = state.activeMilestone?.id;
if (!mid) return { action: 'stop', reason: 'No active milestone', level: 'info' };
const midTitle = state.activeMilestone?.title ?? mid;
const ctx: DispatchContext = { basePath: base, mid, midTitle, state, prefs: undefined };
return resolveDispatch(ctx);
}
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── 1. Basic state derivation: pre-planning → plan-milestone ─────────
console.log('\n=== 1. pre-planning with context → plan-milestone (or research) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test Project\n\nBuild a thing.\n');
const result = await dispatchFor(base);
assertTrue(
result.action === 'dispatch',
'pre-planning with context dispatches a unit',
);
if (result.action === 'dispatch') {
assertTrue(
result.unitType === 'research-milestone' || result.unitType === 'plan-milestone',
`dispatches research-milestone or plan-milestone, got ${result.unitType}`,
);
assertEq(result.unitId, 'M001', 'unit ID is M001');
}
} finally {
cleanup(base);
}
}
// ─── 2. Planning → plan-slice ─────────────────────────────────────────
console.log('\n=== 2. has roadmap, no slice plan → plan-slice ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
{ id: 'S02', title: 'Second Slice', done: false, depends: ['S01'] },
]));
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', 'planning phase dispatches');
if (result.action === 'dispatch') {
assertTrue(
result.unitType === 'plan-slice' || result.unitType === 'research-slice',
`dispatches plan-slice or research-slice, got ${result.unitType}`,
);
assertMatch(result.unitId, /M001\/S01/, 'targets S01');
}
} finally {
cleanup(base);
}
}
// ─── 3. Executing → execute-task ──────────────────────────────────────
console.log('\n=== 3. has plan with incomplete task → execute-task ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
{ id: 'T01', title: 'First Task', done: false },
{ id: 'T02', title: 'Second Task', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01: First Task\n\nDo the thing.\n');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02: Second Task\n\nDo more.\n');
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', 'executing phase dispatches');
if (result.action === 'dispatch') {
assertEq(result.unitType, 'execute-task', 'dispatches execute-task');
assertEq(result.unitId, 'M001/S01/T01', 'targets T01');
}
} finally {
cleanup(base);
}
}
// ─── 4. All tasks done → complete-slice (summarizing) ─────────────────
console.log('\n=== 4. all tasks done → summarizing → complete-slice ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
{ id: 'T01', title: 'First Task', done: true },
{ id: 'T02', title: 'Second Task', done: true },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDone.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDone.');
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', 'summarizing phase dispatches');
if (result.action === 'dispatch') {
assertEq(result.unitType, 'complete-slice', 'dispatches complete-slice');
assertEq(result.unitId, 'M001/S01', 'targets S01');
}
} finally {
cleanup(base);
}
}
// ─── 5. Regression #909: Missing task plan files → plan-slice ─────────
console.log('\n=== 5. #909: tasks in plan but empty tasks/ dir → plan-slice (not stuck loop) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
// Add milestone research so research-slice doesn't fire first
writeMilestoneFile(base, 'M001', 'RESEARCH', '# Research\n\nDone.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
]));
// Also write slice research so research-slice is skipped
writeSliceFile(base, 'M001', 'S01', 'RESEARCH', '# Slice Research\n\nDone.\n');
// Plan references tasks but tasks/ dir has no files
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
{ id: 'T01', title: 'First Task', done: false },
]));
// Create empty tasks directory (no task plan files)
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true });
freshState();
const state = await deriveState(base);
// Should fall back to planning phase since tasks dir is empty
assertEq(state.phase, 'planning', '#909: empty tasks dir → planning phase (not executing)');
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', '#909: dispatches');
if (result.action === 'dispatch') {
assertEq(result.unitType, 'plan-slice', '#909: dispatches plan-slice to regenerate task plans');
}
} finally {
cleanup(base);
}
}
// ─── 6. Regression #1277: Non-artifact UAT not dispatched ─────────────
console.log('\n=== 6. #1277: human-experience UAT → null (skip, not dispatch) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: human-experience\n');
const state = {
activeMilestone: { id: 'M001', title: 'Test' },
activeSlice: { id: 'S02', title: 'Next Slice' },
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: 'Plan S02',
registry: [],
};
freshState();
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertEq(result, null, '#1277: human-experience UAT returns null (not dispatched)');
} finally {
cleanup(base);
}
}
// ─── 7. Regression #1277: artifact-driven UAT without result → dispatch ──
console.log('\n=== 7. artifact-driven UAT without result → dispatch ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
// No UAT-RESULT file
const state = {
activeMilestone: { id: 'M001', title: 'Test' },
activeSlice: { id: 'S02', title: 'Next Slice' },
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: 'Plan S02',
registry: [],
};
freshState();
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertTrue(result !== null, 'artifact-driven UAT without result → dispatch (not null)');
if (result) {
assertEq(result.sliceId, 'S01', 'targets S01');
}
} finally {
cleanup(base);
}
}
// ─── 8. Regression #1270: Existing UAT-RESULT never re-dispatches ─────
console.log('\n=== 8. #1270: UAT-RESULT exists → no re-dispatch (any verdict) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed.\n');
const state = {
activeMilestone: { id: 'M001', title: 'Test' },
activeSlice: { id: 'S02', title: 'Next Slice' },
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: 'Plan S02',
registry: [],
};
freshState();
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertEq(result, null, '#1270: existing UAT-RESULT with FAIL → null (no re-dispatch)');
} finally {
cleanup(base);
}
}
// ─── 9. Regression #1241: UAT verdict gate blocks non-PASS ────────────
console.log('\n=== 9. #1241: UAT verdict gate blocks progression on non-PASS verdict ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Done Slice', [
{ id: 'T01', title: 'Task', done: true },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed some check.\n');
freshState();
const state = await deriveState(base);
const ctx: DispatchContext = {
basePath: base,
mid: 'M001',
midTitle: 'Test',
state,
prefs: { uat_dispatch: true } as any,
};
const result = await resolveDispatch(ctx);
// The uat-verdict-gate rule should stop progression
assertEq(result.action, 'stop', '#1241: non-PASS verdict → stop (blocks progression)');
} finally {
cleanup(base);
}
}
// ─── 10. #1241: UAT verdict PASS allows progression ───────────────────
console.log('\n=== 10. UAT verdict PASS → allows progression ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: PASS\n---\nAll good.\n');
freshState();
const state = await deriveState(base);
const ctx: DispatchContext = {
basePath: base,
mid: 'M001',
midTitle: 'Test',
state,
prefs: { uat_dispatch: true } as any,
};
const result = await resolveDispatch(ctx);
// PASS verdict should NOT block — dispatch should continue to plan-slice for S02
assertTrue(result.action !== 'stop' || !('reason' in result && result.reason.includes('verdict')), 'PASS verdict does not block progression');
} finally {
cleanup(base);
}
}
// ─── 11. Complete state derivation: all slices done → completing ───────
console.log('\n=== 11. all slices done, no validation → validating-milestone ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: true },
]));
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'validating-milestone', 'all slices done → validating-milestone');
} finally {
cleanup(base);
}
}
// ─── 12. Complete milestone → complete phase ──────────────────────────
console.log('\n=== 12. validated + summarized milestone → complete ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: true },
]));
writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nAll good.\n');
writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\nDone.\n');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'validated+summarized → complete');
} finally {
cleanup(base);
}
}
// ─── 13. Multi-milestone: M001 complete, M002 active ─────────────────
console.log('\n=== 13. multi-milestone: M001 complete, M002 becomes active ===');
{
const base = createBase();
try {
// M001 — complete
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDone.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'First', [
{ id: 'S01', title: 'Slice', done: true },
]));
writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n');
writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\n');
// M002 — active
writeMilestoneFile(base, 'M002', 'CONTEXT', '# M002\n\nNext.\n');
freshState();
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M002', 'M002 is the active milestone');
assertEq(state.phase, 'pre-planning', 'M002 is in pre-planning');
assertEq(state.registry.length, 2, 'registry has 2 milestones');
assertEq(state.registry[0].status, 'complete', 'M001 is complete');
assertEq(state.registry[1].status, 'active', 'M002 is active');
} finally {
cleanup(base);
}
}
// ─── 14. Dependency blocking: S02 depends on S01 ─────────────────────
console.log('\n=== 14. slice dependency: S02 blocked until S01 done ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First', done: false },
{ id: 'S02', title: 'Second', done: false, depends: ['S01'] },
]));
freshState();
const state = await deriveState(base);
// Active slice should be S01, not S02
assertEq(state.activeSlice?.id, 'S01', 'S01 is the active slice (S02 is dep-blocked)');
} finally {
cleanup(base);
}
}
// ─── 15. Blocker detection: task with blocker_discovered → replan ─────
console.log('\n=== 15. blocker_discovered in task summary → replanning-slice ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task One', done: true },
{ id: 'T02', title: 'Task Two', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.');
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01 Summary\nFound a blocker.');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'replanning-slice', 'blocker_discovered → replanning-slice');
} finally {
cleanup(base);
}
}
// ─── 16. Blocker + REPLAN exists → loop protection, resume executing ──
console.log('\n=== 16. blocker_discovered + REPLAN exists → loop protection (executing) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task One', done: true },
{ id: 'T02', title: 'Task Two', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.');
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01\nBlocker.');
// REPLAN.md exists → loop protection
writeSliceFile(base, 'M001', 'S01', 'REPLAN', '# Replan\nAlready replanned.\n');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'executing', 'blocker + REPLAN exists → executing (loop protection)');
} finally {
cleanup(base);
}
}
// ─── 17. Needs-discussion phase ───────────────────────────────────────
console.log('\n=== 17. CONTEXT-DRAFT without CONTEXT → needs-discussion ===');
{
const base = createBase();
try {
const mDir = join(base, '.gsd', 'milestones', 'M001');
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome rough ideas.\n');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'needs-discussion', 'CONTEXT-DRAFT without CONTEXT → needs-discussion');
} finally {
cleanup(base);
}
}
// ─── 18. Idempotency: completed key → skip ───────────────────────────
console.log('\n=== 18. idempotency: completed key → skip ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
// Task must be marked [x] in the plan for verifyExpectedArtifact to return true
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task', done: true },
{ id: 'T02', title: 'Next Task', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nNext.');
// Write SUMMARY as the expected artifact for execute-task
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# T01 Summary\nDone.');
// Force cache clearance so verifyExpectedArtifact finds the file
freshState();
const session = new AutoSession();
session.basePath = base;
session.completedKeySet.add('execute-task/M001/S01/T01');
const notifications: string[] = [];
const result = checkIdempotency({
s: session,
unitType: 'execute-task',
unitId: 'M001/S01/T01',
basePath: base,
notify: (msg) => notifications.push(msg),
});
assertEq(result.action, 'skip', 'completed key → skip');
assertTrue('reason' in result && result.reason === 'completed', 'reason is completed');
} finally {
cleanup(base);
}
}
// ─── 19. Idempotency: stale key (artifact missing) → rerun ───────────
console.log('\n=== 19. idempotency: stale key (no artifact) → rerun ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
// NO summary file — artifact missing
const session = new AutoSession();
session.basePath = base;
session.completedKeySet.add('execute-task/M001/S01/T01');
const notifications: string[] = [];
const result = checkIdempotency({
s: session,
unitType: 'execute-task',
unitId: 'M001/S01/T01',
basePath: base,
notify: (msg) => notifications.push(msg),
});
assertEq(result.action, 'rerun', 'stale key (no artifact) → rerun');
assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'stale key removed from set');
} finally {
cleanup(base);
}
}
// ─── 20. Idempotency: consecutive skip loop → evict ──────────────────
console.log('\n=== 20. idempotency: consecutive skip loop → evict ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# Done');
const session = new AutoSession();
session.basePath = base;
session.completedKeySet.add('execute-task/M001/S01/T01');
// Pre-fill skip count to just below threshold
session.unitConsecutiveSkips.set('execute-task/M001/S01/T01', 3);
const notifications: string[] = [];
const result = checkIdempotency({
s: session,
unitType: 'execute-task',
unitId: 'M001/S01/T01',
basePath: base,
notify: (msg) => notifications.push(msg),
});
assertEq(result.action, 'skip', 'exceeds consecutive skip threshold → skip with eviction');
assertTrue('reason' in result && result.reason === 'evicted', 'reason is evicted');
assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'key evicted from completed set');
assertTrue(session.recentlyEvictedKeys.has('execute-task/M001/S01/T01'), 'key tracked in evicted set');
} finally {
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

File diff suppressed because it is too large Load diff

View file

@ -11,10 +11,6 @@ import {
diagnoseExpectedArtifact,
buildLoopRemediationSteps,
selfHealRuntimeRecords,
completedKeysPath,
persistCompletedKey,
removePersistedKey,
loadPersistedKeys,
} from "../auto-recovery.ts";
import { parseRoadmap, clearParseCache } from "../files.ts";
import { invalidateAllCaches } from "../cache.ts";
@ -201,143 +197,6 @@ test("buildLoopRemediationSteps returns null for unknown type", () => {
}
});
// ─── Completed-unit key persistence ───────────────────────────────────────
test("completedKeysPath returns path inside .gsd", () => {
const path = completedKeysPath("/project");
assert.ok(path.includes(".gsd"));
assert.ok(path.includes("completed-units.json"));
});
test("persistCompletedKey and loadPersistedKeys round-trip", () => {
const base = makeTmpBase();
try {
persistCompletedKey(base, "execute-task/M001/S01/T01");
persistCompletedKey(base, "plan-slice/M001/S02");
const keys = new Set<string>();
loadPersistedKeys(base, keys);
assert.ok(keys.has("execute-task/M001/S01/T01"));
assert.ok(keys.has("plan-slice/M001/S02"));
assert.equal(keys.size, 2);
} finally {
cleanup(base);
}
});
test("persistCompletedKey is idempotent", () => {
const base = makeTmpBase();
try {
persistCompletedKey(base, "execute-task/M001/S01/T01");
persistCompletedKey(base, "execute-task/M001/S01/T01");
const keys = new Set<string>();
loadPersistedKeys(base, keys);
assert.equal(keys.size, 1);
} finally {
cleanup(base);
}
});
test("removePersistedKey removes a key", () => {
const base = makeTmpBase();
try {
persistCompletedKey(base, "a");
persistCompletedKey(base, "b");
removePersistedKey(base, "a");
const keys = new Set<string>();
loadPersistedKeys(base, keys);
assert.ok(!keys.has("a"));
assert.ok(keys.has("b"));
} finally {
cleanup(base);
}
});
test("loadPersistedKeys handles missing file gracefully", () => {
const base = makeTmpBase();
try {
const keys = new Set<string>();
assert.doesNotThrow(() => loadPersistedKeys(base, keys));
assert.equal(keys.size, 0);
} finally {
cleanup(base);
}
});
test("removePersistedKey is safe when file doesn't exist", () => {
const base = makeTmpBase();
try {
assert.doesNotThrow(() => removePersistedKey(base, "nonexistent"));
} finally {
cleanup(base);
}
});
// ─── Dual-load across worktree boundary (#769) ───────────────────────────
test("loadPersistedKeys unions keys from project root and worktree", () => {
// Simulate two separate .gsd directories (project root + worktree)
// each with a different set of completed keys. Loading from both
// into the same Set should produce the union.
const projectRoot = makeTmpBase();
const worktree = makeTmpBase();
try {
// Persist different keys in each location
persistCompletedKey(projectRoot, "execute-task/M001/S01/T01");
persistCompletedKey(projectRoot, "plan-slice/M001/S02");
persistCompletedKey(worktree, "execute-task/M001/S01/T02");
persistCompletedKey(worktree, "plan-slice/M001/S02"); // overlap
// Load from both into the same set (mimicking startup dual-load)
const keys = new Set<string>();
loadPersistedKeys(projectRoot, keys);
loadPersistedKeys(worktree, keys);
assert.ok(keys.has("execute-task/M001/S01/T01"), "key from project root");
assert.ok(keys.has("plan-slice/M001/S02"), "shared key");
assert.ok(keys.has("execute-task/M001/S01/T02"), "key from worktree");
assert.equal(keys.size, 3, "union should deduplicate overlapping keys");
} finally {
cleanup(projectRoot);
cleanup(worktree);
}
});
test("completed-units.json set-union merge produces correct result", () => {
// Verify that a manual set-union merge correctly merges two JSON arrays
// of completed-unit keys.
const projectRoot = makeTmpBase();
const worktree = makeTmpBase();
try {
// Write keys to both locations
const prKeysFile = join(projectRoot, ".gsd", "completed-units.json");
const wtKeysFile = join(worktree, ".gsd", "completed-units.json");
writeFileSync(prKeysFile, JSON.stringify(["a", "b"]));
writeFileSync(wtKeysFile, JSON.stringify(["b", "c", "d"]));
// Perform a set-union merge of two JSON key arrays
const srcKeys: string[] = JSON.parse(readFileSync(wtKeysFile, "utf8"));
let dstKeys: string[] = [];
if (existsSync(prKeysFile)) {
dstKeys = JSON.parse(readFileSync(prKeysFile, "utf8"));
}
const merged = [...new Set([...dstKeys, ...srcKeys])];
writeFileSync(prKeysFile, JSON.stringify(merged, null, 2));
// Verify the merged result
const result: string[] = JSON.parse(readFileSync(prKeysFile, "utf8"));
assert.deepStrictEqual(result.sort(), ["a", "b", "c", "d"]);
} finally {
cleanup(projectRoot);
cleanup(worktree);
}
});
// ─── verifyExpectedArtifact: parse cache collision regression ─────────────
test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => {
@ -528,9 +387,9 @@ test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", ()
// ─── selfHealRuntimeRecords — worktree base path (#769) ──────────────────
test("selfHealRuntimeRecords clears stale record when artifact exists at worktree base (#769)", async () => {
// Simulate worktree layout: the runtime record AND the artifact both live
// under the worktree's .gsd/, not the main project root.
test("selfHealRuntimeRecords clears stale dispatched records (#769)", async () => {
// selfHealRuntimeRecords now only clears stale dispatched records (>1h).
// No completedKeySet parameter — deriveState is sole authority.
const worktreeBase = makeTmpBase();
const mainBase = makeTmpBase();
try {
@ -541,10 +400,6 @@ test("selfHealRuntimeRecords clears stale record when artifact exists at worktre
phase: "dispatched",
});
// Write the UAT result artifact in the worktree .gsd/milestones/
const uatPath = join(worktreeBase, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT-RESULT.md");
writeFileSync(uatPath, "---\nresult: pass\n---\n# UAT Result\nAll tests passed.\n");
// Verify the runtime record exists before heal
const before = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
assert.ok(before, "runtime record should exist before heal");
@ -555,32 +410,23 @@ test("selfHealRuntimeRecords clears stale record when artifact exists at worktre
ui: { notify: (msg: string) => { notifications.push(msg); } },
} as any;
// Call selfHeal with worktreeBase — this is the fix: using the worktree path
// so both the runtime record and artifact are found
const completedKeys = new Set<string>();
await selfHealRuntimeRecords(worktreeBase, mockCtx, completedKeys);
// Call selfHeal with worktreeBase — should clear the stale record
await selfHealRuntimeRecords(worktreeBase, mockCtx);
// The stale record should be cleared
const after = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
assert.equal(after, null, "runtime record should be cleared after heal");
// The completion key should be persisted
assert.ok(completedKeys.has("run-uat/M001/S01"), "completion key should be added");
assert.ok(notifications.some(n => n.includes("Self-heal")), "should emit self-heal notification");
// Now verify that calling with mainBase does NOT find/clear anything (the old bug)
// Write a stale record at mainBase but NO artifact there
// Write a stale record at mainBase
writeUnitRuntimeRecord(mainBase, "run-uat", "M001/S01", Date.now() - 7200_000, {
phase: "dispatched",
});
const mainKeys = new Set<string>();
await selfHealRuntimeRecords(mainBase, mockCtx, mainKeys);
await selfHealRuntimeRecords(mainBase, mockCtx);
// The record at mainBase should be cleared by the stale timeout (>1h),
// but the completion key should NOT be set (artifact doesn't exist at mainBase)
// The record at mainBase should also be cleared by the stale timeout (>1h)
const afterMain = readUnitRuntimeRecord(mainBase, "run-uat", "M001/S01");
assert.equal(afterMain, null, "stale record at main base should be cleared by timeout");
assert.ok(!mainKeys.has("run-uat/M001/S01"), "completion key should NOT be set when artifact is missing");
} finally {
cleanup(worktreeBase);
cleanup(mainBase);

View file

@ -1,127 +0,0 @@
/**
* auto-reentrancy-guard.test.ts Tests for the unconditional reentrancy guard.
*
* Regression for #1272: auto-mode stuck-loop where gap watchdog or
* pendingAgentEndRetry could enter dispatchNextUnit concurrently during
* recursive skip chains because the reentrancy guard was bypassed when
* skipDepth > 0.
*
* The fix makes the guard unconditional (`if (s.dispatching)` without
* `&& s.skipDepth === 0`), and defers recursive re-dispatch via
* setImmediate/setTimeout so s.dispatching is released first.
*/
import {
_getDispatching,
_setDispatching,
_getSkipDepth,
_setSkipDepth,
} from "../auto.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
async function main(): Promise<void> {
// ─── Test-only accessors work ───────────────────────────────────────────
console.log("\n=== reentrancy guard: test accessors round-trip ===");
{
_setDispatching(false);
assertEq(_getDispatching(), false, "dispatching starts false");
_setDispatching(true);
assertEq(_getDispatching(), true, "dispatching set to true");
_setDispatching(false);
assertEq(_getDispatching(), false, "dispatching reset to false");
}
// ─── skipDepth accessors ────────────────────────────────────────────────
console.log("\n=== reentrancy guard: skipDepth accessors round-trip ===");
{
_setSkipDepth(0);
assertEq(_getSkipDepth(), 0, "skipDepth starts at 0");
_setSkipDepth(3);
assertEq(_getSkipDepth(), 3, "skipDepth set to 3");
_setSkipDepth(0);
assertEq(_getSkipDepth(), 0, "skipDepth reset to 0");
}
// ─── Guard blocks even when skipDepth > 0 (#1272 regression) ───────────
console.log("\n=== reentrancy guard: blocks when dispatching=true regardless of skipDepth ===");
{
// Simulate the scenario from #1272: dispatching=true + skipDepth>0
// The old guard (`if (s.dispatching && s.skipDepth === 0)`) would allow
// concurrent entry when skipDepth > 0. The fix makes the check
// unconditional on skipDepth.
_setDispatching(true);
_setSkipDepth(2);
// Verify dispatching is true — guard should block regardless of skipDepth
assertTrue(
_getDispatching() === true,
"dispatching flag is true during skip chain"
);
// The actual reentrancy guard in dispatchNextUnit checks:
// if (s.dispatching) { return; }
// We verify the state that would trigger the guard:
const wouldBlock = _getDispatching(); // unconditional check
const wouldBlockOld = _getDispatching() && _getSkipDepth() === 0; // old check
assertTrue(wouldBlock === true, "new guard blocks when dispatching=true, skipDepth=2");
assertTrue(wouldBlockOld === false, "old guard WOULD NOT block when dispatching=true, skipDepth=2 (the bug)");
// Clean up
_setDispatching(false);
_setSkipDepth(0);
}
// ─── Guard allows entry when dispatching=false ──────────────────────────
console.log("\n=== reentrancy guard: allows entry when dispatching=false ===");
{
_setDispatching(false);
_setSkipDepth(0);
assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=0");
_setDispatching(false);
_setSkipDepth(3);
assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=3");
_setSkipDepth(0);
}
// ─── skipDepth does not affect guard decision (the fix) ─────────────────
console.log("\n=== reentrancy guard: skipDepth is irrelevant to guard decision ===");
{
for (const depth of [0, 1, 2, 5]) {
_setDispatching(true);
_setSkipDepth(depth);
assertTrue(
_getDispatching() === true,
`guard blocks at skipDepth=${depth} when dispatching=true`
);
}
for (const depth of [0, 1, 2, 5]) {
_setDispatching(false);
_setSkipDepth(depth);
assertTrue(
_getDispatching() === false,
`guard allows at skipDepth=${depth} when dispatching=false`
);
}
// Clean up
_setDispatching(false);
_setSkipDepth(0);
}
report();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -2,11 +2,10 @@
* Integration tests for the secrets collection gate in startAuto().
*
* Exercises getManifestStatus() collectSecretsFromManifest() composition
* end-to-end using real filesystem state. Proves the gate paths:
* end-to-end using real filesystem state. Proves the three gate paths:
* 1. No manifest exists gate skips silently
* 2. Pending keys exist gate triggers collection (direct call)
* 2. Pending keys exist gate triggers collection
* 3. No pending keys gate skips silently
* 4. Pending keys in auto-mode session pauses instead of blocking (#1146)
*
* Uses temp directories with real .gsd/milestones/M001/ structure, mirroring
* the pattern from manifest-status.test.ts.
@ -19,7 +18,6 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { getManifestStatus } from '../files.ts';
import { collectSecretsFromManifest } from '../../get-secrets-from-user.ts';
import { AutoSession } from '../auto/session.ts';
function makeTempDir(prefix: string): string {
const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
@ -148,110 +146,6 @@ test('secrets gate: pending keys exist — gate triggers collection, manifest up
// ─── Scenario 3: No pending keys — all collected or in env ──────────────────
// ─── Scenario 4: Pending keys pause AutoSession instead of blocking (#1146) ──
test('secrets gate: pending keys set pausedForSecrets on AutoSession', async () => {
const tmp = makeTempDir('gate-pause-session');
try {
// Ensure pending keys are NOT in env
delete process.env.GSD_PAUSE_TEST_KEY_A;
delete process.env.GSD_PAUSE_TEST_KEY_B;
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
**Generated:** 2025-06-20T10:00:00Z
### GSD_PAUSE_TEST_KEY_A
**Service:** ServiceA
**Status:** pending
**Destination:** dotenv
1. Get key A from dashboard
### GSD_PAUSE_TEST_KEY_B
**Service:** ServiceB
**Status:** pending
**Destination:** dotenv
1. Get key B from dashboard
`);
// Verify manifest has pending keys
const status = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(status, null, 'manifest should exist');
assert.deepStrictEqual(status!.pending, ['GSD_PAUSE_TEST_KEY_A', 'GSD_PAUSE_TEST_KEY_B']);
// Simulate what auto-start.ts now does: set pause flags on session
const session = new AutoSession();
session.active = true;
session.currentMilestoneId = 'M001';
// The new gate logic: if pending keys exist, pause instead of collecting
if (status!.pending.length > 0) {
session.paused = true;
session.pausedForSecrets = true;
}
assert.strictEqual(session.paused, true, 'session should be paused');
assert.strictEqual(session.pausedForSecrets, true, 'pausedForSecrets flag should be set');
// Verify reset() clears pausedForSecrets
session.reset();
assert.strictEqual(session.pausedForSecrets, false, 'reset() should clear pausedForSecrets');
} finally {
delete process.env.GSD_PAUSE_TEST_KEY_A;
delete process.env.GSD_PAUSE_TEST_KEY_B;
rmSync(tmp, { recursive: true, force: true });
}
});
test('secrets gate: no pending keys do not set pausedForSecrets', async () => {
const tmp = makeTempDir('gate-no-pause');
const savedKey = process.env.GSD_NO_PAUSE_TEST_KEY;
try {
process.env.GSD_NO_PAUSE_TEST_KEY = 'already-set';
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
**Generated:** 2025-06-20T10:00:00Z
### GSD_NO_PAUSE_TEST_KEY
**Service:** ServiceX
**Status:** pending
**Destination:** dotenv
1. Already in env
`);
const status = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(status, null, 'manifest should exist');
assert.deepStrictEqual(status!.pending, [], 'no pending keys — already in env');
// Simulate gate logic — no pending keys, no pause
const session = new AutoSession();
session.active = true;
if (status!.pending.length > 0) {
session.paused = true;
session.pausedForSecrets = true;
}
assert.strictEqual(session.paused, false, 'session should NOT be paused');
assert.strictEqual(session.pausedForSecrets, false, 'pausedForSecrets should NOT be set');
} finally {
delete process.env.GSD_NO_PAUSE_TEST_KEY;
if (savedKey !== undefined) process.env.GSD_NO_PAUSE_TEST_KEY = savedKey;
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Scenario 3: No pending keys — all collected or in env ──────────────────
test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => {
const tmp = makeTempDir('gate-no-pending');
const savedKey = process.env.GSD_GATE_TEST_ENVKEY;

View file

@ -145,8 +145,7 @@ test("AutoSession.reset() references every instance property", () => {
assert.ok(resetMatch, "AutoSession.reset() method not found");
const resetBody = resetMatch![1]!;
// completedKeySet is intentionally not cleared (documented in reset())
const intentionallySkipped = new Set(["completedKeySet"]);
const intentionallySkipped = new Set<string>([]);
const missingFromReset: string[] = [];
for (const prop of properties) {
@ -182,7 +181,6 @@ test("AutoSession.toJSON() includes key diagnostic properties", () => {
"basePath",
"currentMilestoneId",
"currentUnit",
"dispatching",
];
const missing = requiredDiagnostics.filter(prop => !toJSONBody.includes(prop));

View file

@ -1,123 +0,0 @@
/**
* auto-skip-loop.test.ts Tests for the consecutive-skip loop breaker.
*
* Regression for #728: auto-mode infinite skip loop on previously completed
* plan-slice units when deriveState keeps returning the same unit.
*
* The skip paths in dispatchNextUnit track consecutive skips per unit via
* unitConsecutiveSkips. When the same unit is skipped > MAX_CONSECUTIVE_SKIPS
* times without a real dispatch in between, the completion record is evicted
* so deriveState can reconcile.
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
_getUnitConsecutiveSkips,
_resetUnitConsecutiveSkips,
} from "../auto.ts";
import { MAX_CONSECUTIVE_SKIPS } from "../auto/session.ts";
import { persistCompletedKey, removePersistedKey, loadPersistedKeys } from "../auto-recovery.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function makeTmpBase(): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-skip-loop-test-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
return dir;
}
async function main(): Promise<void> {
// ─── Counter starts at zero ────────────────────────────────────────────
console.log("\n=== skip loop counter: initial state ===");
{
_resetUnitConsecutiveSkips();
const map = _getUnitConsecutiveSkips();
assertEq(map.size, 0, "counter map starts empty after reset");
}
// ─── Counter increments correctly ────────────────────────────────────
console.log("\n=== skip loop counter: increments on repeated calls ===");
{
_resetUnitConsecutiveSkips();
const map = _getUnitConsecutiveSkips();
const key = "plan-slice/M001/S04";
for (let i = 1; i <= MAX_CONSECUTIVE_SKIPS; i++) {
const prev = map.get(key) ?? 0;
map.set(key, prev + 1);
}
assertEq(map.get(key), MAX_CONSECUTIVE_SKIPS, `counter reaches MAX_CONSECUTIVE_SKIPS (${MAX_CONSECUTIVE_SKIPS})`);
}
// ─── Threshold constant is sane ──────────────────────────────────────
console.log("\n=== skip loop counter: threshold is reasonable ===");
{
assertTrue(MAX_CONSECUTIVE_SKIPS >= 3, "threshold allows a few legitimate skips");
assertTrue(MAX_CONSECUTIVE_SKIPS <= 10, "threshold catches loops quickly");
}
// ─── Reset clears all keys ────────────────────────────────────────────
console.log("\n=== skip loop counter: reset clears all keys ===");
{
_resetUnitConsecutiveSkips();
const map = _getUnitConsecutiveSkips();
map.set("plan-slice/M001/S01", 2);
map.set("plan-slice/M001/S02", 1);
assertEq(map.size, 2, "map has 2 entries before reset");
_resetUnitConsecutiveSkips();
assertEq(_getUnitConsecutiveSkips().size, 0, "map empty after reset");
}
// ─── Eviction path: persistCompletedKey + removePersistedKey round-trip
// (simulates what the loop-breaker does) ───────────────────────────
console.log("\n=== skip loop counter: eviction removes persisted key ===");
{
_resetUnitConsecutiveSkips();
const base = makeTmpBase();
try {
const key = "plan-slice/M001/S04";
const keySet = new Set<string>();
persistCompletedKey(base, key);
loadPersistedKeys(base, keySet);
assertTrue(keySet.has(key), "key persisted before eviction");
// Simulate loop-breaker eviction
keySet.delete(key);
removePersistedKey(base, key);
const keySet2 = new Set<string>();
loadPersistedKeys(base, keySet2);
assertTrue(!keySet2.has(key), "key absent after eviction");
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── Counter resets per-key, not globally ─────────────────────────────
console.log("\n=== skip loop counter: per-key isolation ===");
{
_resetUnitConsecutiveSkips();
const map = _getUnitConsecutiveSkips();
map.set("plan-slice/M001/S04", MAX_CONSECUTIVE_SKIPS + 1);
map.set("plan-slice/M001/S05", 1);
// Deleting S04 (eviction) should not affect S05
map.delete("plan-slice/M001/S04");
assertTrue(!map.has("plan-slice/M001/S04"), "S04 evicted");
assertEq(map.get("plan-slice/M001/S05"), 1, "S05 counter unaffected");
}
_resetUnitConsecutiveSkips();
report();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -32,9 +32,6 @@ function createTempRepo(): string {
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
// Mirror production: GSD runtime dirs are gitignored so autoCommitDirtyState
// doesn't pick up the worktrees directory as dirty state (#1127 fix).
writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n");
mkdirSync(join(dir, ".gsd"), { recursive: true });
writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
run("git add .", dir);

View file

@ -153,6 +153,64 @@ async function main(): Promise<void> {
// After teardown, originalBase should be null
assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
// ─── #778: reconcile plan checkboxes on re-attach ─────────────────
console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
{
// Simulate: T01 [x] was committed to milestone branch, T02 [x] was
// written to project root by syncStateToProjectRoot() but the
// auto-commit crashed before it fired. On restart the worktree is
// re-created from the milestone branch HEAD (T02 still [ ]).
// reconcilePlanCheckboxes should forward-apply T02 [x] from the root.
const planRelPath = join(".gsd", "milestones", "M004", "slices", "S01", "S01-PLAN.md");
const planDir = join(tempDir, ".gsd", "milestones", "M004", "slices", "S01");
const { mkdirSync: mkdir, writeFileSync: write, readFileSync: read } = await import("node:fs");
// Plan on integration branch (project root): T01 [x], T02 [x]
mkdir(planDir, { recursive: true });
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
);
// Write integration-branch plan to git so milestone branch starts from it
run(`git add .`, tempDir);
run(`git commit -m "add plan with T01 and T02 checked" --allow-empty`, tempDir);
// Create milestone branch with only T01 [x] (simulating crash before T02 commit)
const milestoneBranch = "milestone/M004";
run(`git checkout -b ${milestoneBranch}`, tempDir);
mkdir(planDir, { recursive: true });
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [ ] **T02:** task two\n- [ ] **T03:** task three\n",
);
run(`git add .`, tempDir);
run(`git commit -m "milestone: only T01 checked"`, tempDir);
run(`git checkout main`, tempDir);
// Restore project root plan (T01+T02 [x]) — simulates syncStateToProjectRoot
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
);
// Create worktree re-attached to existing milestone branch (T02 still [ ] in branch)
const wtPath = createAutoWorktree(tempDir, "M004");
try {
const wtPlanPath = join(wtPath, planRelPath);
assertTrue(existsSync(wtPlanPath), "plan file exists in worktree after re-attach");
const wtPlan = read(wtPlanPath, "utf-8");
assertTrue(wtPlan.includes("- [x] **T02:"), "T02 should be [x] after reconciliation (was [ ] on branch)");
assertTrue(wtPlan.includes("- [x] **T01:"), "T01 stays [x]");
assertTrue(wtPlan.includes("- [ ] **T03:"), "T03 stays [ ] (not in root either)");
} finally {
teardownAutoWorktree(tempDir, "M004");
}
}
} finally {
// Always restore cwd and clean up
process.chdir(savedCwd);

View file

@ -71,58 +71,3 @@ test("dispatch guard works without git repo", () => {
rmSync(repo, { recursive: true, force: true });
}
});
test("dispatch guard skips parked milestones — they do not block later milestones", () => {
const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-parked-"));
try {
// M004 is parked with incomplete slices
mkdirSync(join(repo, ".gsd", "milestones", "M004"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M004", "M004-ROADMAP.md"),
"# M004: Parked Milestone\n\n## Slices\n- [ ] **S01: Unfinished** `risk:high` `depends:[]`\n");
writeFileSync(join(repo, ".gsd", "milestones", "M004", "M004-PARKED.md"),
"---\nparked_at: 2026-03-18T09:00:00.000Z\nreason: \"Parked via /gsd park\"\n---\n\n# M004 — Parked\n");
// M010 is the target milestone
mkdirSync(join(repo, ".gsd", "milestones", "M010"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M010", "M010-ROADMAP.md"),
"# M010: Active Milestone\n\n## Slices\n- [ ] **S01: First** `risk:high` `depends:[]`\n");
// M004's incomplete S01 should NOT block M010/S01 because M004 is parked
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M010/S01"),
null,
);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test("dispatch guard still blocks on non-parked incomplete milestones", () => {
const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-mixed-"));
try {
// M003 is parked — should be skipped
mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"),
"# M003: Parked\n\n## Slices\n- [ ] **S01: Unfinished** `risk:high` `depends:[]`\n");
writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-PARKED.md"),
"---\nparked_at: 2026-03-18T09:00:00.000Z\nreason: \"Parked\"\n---\n");
// M005 is NOT parked and has incomplete slices — should block
mkdirSync(join(repo, ".gsd", "milestones", "M005"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M005", "M005-ROADMAP.md"),
"# M005: Active Incomplete\n\n## Slices\n- [ ] **S01: Pending** `risk:low` `depends:[]`\n");
// M010 is the target
mkdirSync(join(repo, ".gsd", "milestones", "M010"), { recursive: true });
writeFileSync(join(repo, ".gsd", "milestones", "M010", "M010-ROADMAP.md"),
"# M010: Target\n\n## Slices\n- [ ] **S01: First** `risk:low` `depends:[]`\n");
// M005/S01 should block M010/S01 (M003 is parked, so skipped)
assert.equal(
getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M010/S01"),
"Cannot dispatch plan-slice M010/S01: earlier slice M005/S01 is not complete.",
);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});

View file

@ -1,126 +0,0 @@
/**
* dispatch-stall-guard.test.ts Verifies defensive guards against dispatch stalls (#1073).
*
* After a slice completes, dispatchNextUnit must reliably dispatch the next unit.
* These tests verify:
* 1. newSession() has timeout protection (prevents permanent hang if session creation stalls)
* 2. handleAgentEnd has a dispatch hang guard (catches dispatchNextUnit itself hanging)
* 3. Session timeout constants are exported for configurability
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const AUTO_TS_PATH = join(__dirname, "..", "auto.ts");
const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts");
function getAutoTsSource(): string {
return readFileSync(AUTO_TS_PATH, "utf-8");
}
function getSessionTsSource(): string {
return readFileSync(SESSION_TS_PATH, "utf-8");
}
// ── Session timeout constants ───────────────────────────────────────────────
test("AutoSession exports NEW_SESSION_TIMEOUT_MS constant", () => {
const source = getSessionTsSource();
assert.ok(
source.includes("NEW_SESSION_TIMEOUT_MS"),
"auto/session.ts must export NEW_SESSION_TIMEOUT_MS for newSession() timeout",
);
});
test("AutoSession exports DISPATCH_HANG_TIMEOUT_MS constant", () => {
const source = getSessionTsSource();
assert.ok(
source.includes("DISPATCH_HANG_TIMEOUT_MS"),
"auto/session.ts must export DISPATCH_HANG_TIMEOUT_MS for dispatch hang detection",
);
});
test("NEW_SESSION_TIMEOUT_MS is a reasonable value (15-120 seconds)", () => {
const source = getSessionTsSource();
const match = source.match(/NEW_SESSION_TIMEOUT_MS\s*=\s*(\d[\d_]*)/);
assert.ok(match, "NEW_SESSION_TIMEOUT_MS must have a numeric value");
const value = parseInt(match![1]!.replace(/_/g, ""), 10);
assert.ok(value >= 15_000 && value <= 120_000,
`NEW_SESSION_TIMEOUT_MS must be 15-120s, got ${value}ms`,
);
});
test("DISPATCH_HANG_TIMEOUT_MS is greater than NEW_SESSION_TIMEOUT_MS", () => {
const source = getSessionTsSource();
const sessionMatch = source.match(/NEW_SESSION_TIMEOUT_MS\s*=\s*(\d[\d_]*)/);
const dispatchMatch = source.match(/DISPATCH_HANG_TIMEOUT_MS\s*=\s*(\d[\d_]*)/);
assert.ok(sessionMatch && dispatchMatch, "Both timeout constants must exist");
const sessionTimeout = parseInt(sessionMatch![1]!.replace(/_/g, ""), 10);
const dispatchTimeout = parseInt(dispatchMatch![1]!.replace(/_/g, ""), 10);
assert.ok(dispatchTimeout > sessionTimeout,
`DISPATCH_HANG_TIMEOUT_MS (${dispatchTimeout}) must be > NEW_SESSION_TIMEOUT_MS (${sessionTimeout})`,
);
});
// ── newSession() timeout in dispatchNextUnit ─────────────────────────────────
test("dispatchNextUnit wraps newSession() with Promise.race timeout", () => {
const source = getAutoTsSource();
// Search the full file — dispatchNextUnit is very large
assert.ok(
source.includes("Promise.race") && source.includes("NEW_SESSION_TIMEOUT_MS"),
"dispatchNextUnit must use Promise.race with NEW_SESSION_TIMEOUT_MS to timeout newSession() (#1073)",
);
});
test("dispatchNextUnit handles newSession() timeout gracefully", () => {
const source = getAutoTsSource();
// Must notify user when session times out
assert.ok(
source.includes("Session creation timed out") || source.includes("Session creation failed"),
"dispatchNextUnit must notify user when newSession() times out or fails (#1073)",
);
});
// ── Dispatch hang guard in handleAgentEnd ────────────────────────────────────
test("handleAgentEnd has a dispatch hang guard before dispatchNextUnit", () => {
const source = getAutoTsSource();
const fnIdx = source.indexOf("export async function handleAgentEnd");
assert.ok(fnIdx > -1, "handleAgentEnd must exist");
// Find the section between step mode check and dispatchNextUnit call
const fnBlock = source.slice(fnIdx, source.indexOf("\n// ─── Step Mode", fnIdx + 100));
assert.ok(
fnBlock.includes("DISPATCH_HANG_TIMEOUT_MS") || fnBlock.includes("dispatchHangGuard"),
"handleAgentEnd must have a dispatch hang guard before calling dispatchNextUnit (#1073)",
);
});
test("dispatch hang guard is cleared in finally block", () => {
const source = getAutoTsSource();
const fnIdx = source.indexOf("export async function handleAgentEnd");
const fnBlock = source.slice(fnIdx, source.indexOf("\n// ─── Step Mode", fnIdx + 100));
assert.ok(
fnBlock.includes("clearTimeout(dispatchHangGuard)"),
"dispatch hang guard must be cleared in finally block to prevent false alarms (#1073)",
);
});
// ── Constants are imported in auto.ts ────────────────────────────────────────
test("auto.ts imports NEW_SESSION_TIMEOUT_MS and DISPATCH_HANG_TIMEOUT_MS", () => {
const source = getAutoTsSource();
assert.ok(
source.includes("NEW_SESSION_TIMEOUT_MS"),
"auto.ts must import NEW_SESSION_TIMEOUT_MS from session.ts",
);
assert.ok(
source.includes("DISPATCH_HANG_TIMEOUT_MS"),
"auto.ts must import DISPATCH_HANG_TIMEOUT_MS from session.ts",
);
});

View file

@ -159,4 +159,26 @@ describe('headless query', () => {
assert.equal(snap.state.activeMilestone!.id, 'M001')
assert.equal(snap.next.action, 'dispatch')
})
it('reports all milestones complete with a clean stop reason', async () => {
writeRoadmap(base, 'M001', `# M001: Test Milestone
## Slices
- [x] **S01: First Slice** \`risk:low\` \`depends:[]\`
> Done.
`)
writeFileSync(
join(base, '.gsd', 'milestones', 'M001', 'M001-SUMMARY.md'),
'# M001 Summary\n\nComplete.',
)
const result = await handleQuery(base)
const snap = result.data as QuerySnapshot
assert.equal(result.exitCode, 0)
assert.equal(snap.state.phase, 'complete')
assert.equal(snap.next.action, 'stop')
assert.equal(snap.next.reason, 'All milestones complete.')
})
})

View file

@ -1,874 +0,0 @@
/**
* Regression test suite for the auto-mode dispatch loop.
* Covers phase transitions, dispatch rule matching, state derivation edge cases,
* and every fix from the #1308 issue catalog.
*
* Run: node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs \
* --experimental-strip-types --test src/resources/extensions/gsd/tests/loop-regression.test.ts
*/
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import test from "node:test";
import assert from "node:assert/strict";
import { deriveState } from "../state.ts";
import { resolveDispatch, getDispatchRuleNames } from "../auto-dispatch.ts";
import type { GSDState } from "../types.ts";
// ─── Helpers ──────────────────────────────────────────────────────────────
function makeTmp(name: string): string {
const dir = join(tmpdir(), `loop-regression-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function writeGsdFile(base: string, ...pathParts: string[]): void {
const fullPath = join(base, ".gsd", ...pathParts);
mkdirSync(join(fullPath, ".."), { recursive: true });
// Default to empty content; callers use writeGsdFileContent for real content
}
function writeGsdFileContent(base: string, relativePath: string, content: string): void {
const fullPath = join(base, ".gsd", relativePath);
mkdirSync(join(fullPath, ".."), { recursive: true });
writeFileSync(fullPath, content, "utf-8");
}
function buildMinimalRoadmap(slices: Array<{ id: string; title: string; done: boolean; depends?: string[] }>): string {
const lines = ["# M001: Test Milestone", "", "## Slices", ""];
for (const s of slices) {
const cb = s.done ? "x" : " ";
const deps = s.depends?.length ? ` \`depends:[${s.depends.join(",")}]\`` : " `depends:[]`";
lines.push(`- [${cb}] **${s.id}: ${s.title}** \`risk:low\`${deps}`);
lines.push(` > Demo text for ${s.id}`);
lines.push("");
}
return lines.join("\n");
}
function buildMinimalPlan(tasks: Array<{ id: string; title: string; done: boolean }>): string {
const lines = ["# S01: Test Slice", "", "**Goal:** test", "", "## Tasks", ""];
for (const t of tasks) {
const cb = t.done ? "x" : " ";
lines.push(`- [${cb}] **${t.id}: ${t.title}** \`est:5m\``);
}
return lines.join("\n");
}
function buildMinimalSummary(id: string): string {
return [
"---",
`id: ${id}`,
"parent: S01",
"milestone: M001",
"duration: 5m",
"verification_result: passed",
`completed_at: ${new Date().toISOString()}`,
"---",
"",
`# ${id}: Done`,
"",
"Completed.",
].join("\n");
}
// ─── Phase 1: Dispatch Rule Ordering ──────────────────────────────────────
test("dispatch rules are in the expected order", () => {
const names = getDispatchRuleNames();
assert.ok(names.length >= 15, `expected ≥15 rules, got ${names.length}`);
// Verify critical ordering: override gate first, complete-slice before UAT,
// needs-discussion before pre-planning, executing last
const overrideIdx = names.indexOf("rewrite-docs (override gate)");
const completeSliceIdx = names.indexOf("summarizing → complete-slice");
const uatGateIdx = names.indexOf("uat-verdict-gate (non-PASS blocks progression)");
const needsDiscussIdx = names.indexOf("needs-discussion → stop");
const prePlanNoCtxIdx = names.indexOf("pre-planning (no context) → stop");
const executeIdx = names.indexOf("executing → execute-task");
assert.ok(overrideIdx === 0, "override gate should be first rule");
assert.ok(completeSliceIdx < uatGateIdx, "complete-slice should fire before UAT gate");
assert.ok(needsDiscussIdx < prePlanNoCtxIdx, "needs-discussion should fire before pre-planning");
assert.ok(executeIdx > prePlanNoCtxIdx, "execute-task should fire after pre-planning rules");
});
// ─── Phase 2: State Derivation — Phase Transitions ───────────────────────
test("deriveState: empty project → pre-planning with no milestones", async () => {
const tmp = makeTmp("empty");
try {
mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true });
const state = await deriveState(tmp);
assert.equal(state.phase, "pre-planning");
assert.equal(state.activeMilestone, null);
assert.deepEqual(state.registry, []);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: milestone with context but no roadmap → pre-planning", async () => {
const tmp = makeTmp("no-roadmap");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\nContext here.");
const state = await deriveState(tmp);
assert.equal(state.phase, "pre-planning");
assert.equal(state.activeMilestone?.id, "M001");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: milestone with CONTEXT-DRAFT.md → needs-discussion", async () => {
const tmp = makeTmp("draft");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nSome ideas.");
const state = await deriveState(tmp);
assert.equal(state.phase, "needs-discussion");
assert.equal(state.activeMilestone?.id, "M001");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: roadmap with no plan → planning", async () => {
const tmp = makeTmp("planning");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(join(mDir, "slices", "S01"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "First Slice", done: false },
]));
const state = await deriveState(tmp);
assert.equal(state.phase, "planning");
assert.equal(state.activeSlice?.id, "S01");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: plan with incomplete tasks → executing", async () => {
const tmp = makeTmp("executing");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "First Slice", done: false },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Task One", done: false },
{ id: "T02", title: "Task Two", done: false },
]));
writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01 Plan\n\nDo stuff.");
writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02 Plan\n\nDo more.");
const state = await deriveState(tmp);
assert.equal(state.phase, "executing");
assert.equal(state.activeTask?.id, "T01");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: all tasks done → summarizing", async () => {
const tmp = makeTmp("summarizing");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "First Slice", done: false },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Task One", done: true },
]));
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
const state = await deriveState(tmp);
assert.equal(state.phase, "summarizing");
assert.equal(state.activeSlice?.id, "S01");
assert.equal(state.activeTask, null);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: all slices done → validating-milestone", async () => {
const tmp = makeTmp("validating");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "First Slice", done: true },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Task One", done: true },
]));
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone.");
const state = await deriveState(tmp);
assert.equal(state.phase, "validating-milestone");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: validation terminal → completing-milestone", async () => {
const tmp = makeTmp("completing");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "First Slice", done: true },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Task One", done: true },
]));
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone.");
writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\n\nAll good.");
const state = await deriveState(tmp);
assert.equal(state.phase, "completing-milestone");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: milestone with summary → complete", async () => {
const tmp = makeTmp("complete");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "First Slice", done: true },
]));
writeFileSync(join(mDir, "M001-SUMMARY.md"), "# M001 Summary\n\nMilestone complete.");
const state = await deriveState(tmp);
assert.equal(state.phase, "complete");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Phase 3: Regression Tests for Specific Bug Fixes ────────────────────
test("#1155: completion-transition codes should NOT be fixable at task level", async () => {
// Verify COMPLETION_TRANSITION_CODES exists and contains expected codes
const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts");
assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_summary"));
assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_uat"));
assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_roadmap_not_checked"));
});
test("#1170: needs-discussion phase is correctly derived from CONTEXT-DRAFT.md", async () => {
const tmp = makeTmp("needs-discussion");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nDraft context.");
const state = await deriveState(tmp);
assert.equal(state.phase, "needs-discussion");
// Verify the dispatch table returns stop for needs-discussion
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "stop");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("#1176: state.registry is always an array even with corrupt/missing state", async () => {
const tmp = makeTmp("empty-registry");
try {
mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true });
const state = await deriveState(tmp);
assert.ok(Array.isArray(state.registry), "registry should be an array");
assert.equal(state.registry.length, 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("#1243: prose H3 slice headers are parsed correctly", async () => {
const { parseRoadmapSlices } = await import("../roadmap-slices.ts");
const content = `# M001: Test
## Roadmap
### S01: First Feature
Depends on: none
### S02: Second Feature
Depends on: S01
### S03: Third Feature
`;
const slices = parseRoadmapSlices(content);
assert.equal(slices.length, 3, "should parse 3 H3 slices");
assert.equal(slices[0]!.id, "S01");
assert.equal(slices[1]!.id, "S02");
assert.equal(slices[2]!.id, "S03");
assert.deepEqual(slices[1]!.depends, ["S01"]);
});
test("#1243: bold-wrapped and dot-separator slice headers are parsed", async () => {
const { parseRoadmapSlices } = await import("../roadmap-slices.ts");
const content = `# M001
## **S01: Bold Wrapped**
> Demo
## S02. Dot Separator Title
> Demo
`;
const slices = parseRoadmapSlices(content);
assert.equal(slices.length, 2);
assert.equal(slices[0]!.id, "S01");
assert.ok(slices[0]!.title.includes("Bold"), `title should contain Bold, got: ${slices[0]!.title}`);
assert.equal(slices[1]!.id, "S02");
});
test("slice dependency blocking → phase: blocked", async () => {
const tmp = makeTmp("dep-blocked");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(join(mDir, "slices"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
// S01 depends on S02 and S02 depends on S01 — circular!
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Slice A", done: false, depends: ["S02"] },
{ id: "S02", title: "Slice B", done: false, depends: ["S01"] },
]));
const state = await deriveState(tmp);
assert.equal(state.phase, "blocked");
assert.ok(state.blockers.length > 0, "should have blockers");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("multi-milestone: M001 complete, M002 active", async () => {
const tmp = makeTmp("multi-milestone");
try {
// M001 — complete
const m1Dir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(m1Dir, { recursive: true });
writeFileSync(join(m1Dir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Done", done: true },
]));
writeFileSync(join(m1Dir, "M001-SUMMARY.md"), "# M001 Summary\n\nComplete.");
// M002 — active, needs planning
const m2Dir = join(tmp, ".gsd", "milestones", "M002");
mkdirSync(m2Dir, { recursive: true });
writeFileSync(join(m2Dir, "M002-CONTEXT.md"), "# M002\n\nNew work.");
const state = await deriveState(tmp);
assert.equal(state.activeMilestone?.id, "M002");
assert.equal(state.phase, "pre-planning");
assert.equal(state.registry.length, 2);
assert.equal(state.registry[0]!.status, "complete");
assert.equal(state.registry[1]!.status, "active");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("blocker_discovered in task summary → replanning-slice", async () => {
const tmp = makeTmp("replan");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: false },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Done", done: true },
{ id: "T02", title: "Todo", done: false },
]));
writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nPlan.");
writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02\nPlan.");
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), [
"---",
"id: T01",
"parent: S01",
"milestone: M001",
"blocker_discovered: true",
"---",
"",
"# T01: Blocker found",
"",
"API doesn't support this.",
].join("\n"));
const state = await deriveState(tmp);
assert.equal(state.phase, "replanning-slice");
assert.ok(state.blockers[0]!.includes("T01"), "blocker should reference T01");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Phase 4: Edge Cases ─────────────────────────────────────────────────
test("empty plan file (0 tasks) → stays in planning", async () => {
const tmp = makeTmp("empty-plan");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: false },
]));
// Plan file exists but has no task entries
writeFileSync(join(sDir, "S01-PLAN.md"), "# S01: Test\n\n**Goal:** test\n\n## Tasks\n");
const state = await deriveState(tmp);
assert.equal(state.phase, "planning");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("parked milestone is not treated as active or complete", async () => {
const tmp = makeTmp("parked");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: false },
]));
writeFileSync(join(mDir, "M001-PARKED.md"), "Parked for later.");
const state = await deriveState(tmp);
assert.equal(state.registry[0]!.status, "parked");
assert.equal(state.activeMilestone, null);
// Phase should be pre-planning (all milestones parked, not complete)
assert.equal(state.phase, "pre-planning");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Phase 5: Defensive Guards ───────────────────────────────────────────
test("dispatch returns stop when phase=summarizing but activeSlice is null (corrupt state)", async () => {
const corruptState: GSDState = {
activeMilestone: { id: "M001", title: "Test" },
activeSlice: null, // BUG: summarizing should always have activeSlice
activeTask: null,
phase: "summarizing",
recentDecisions: [],
blockers: [],
nextAction: "",
registry: [{ id: "M001", title: "Test", status: "active" }],
requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
progress: { milestones: { done: 0, total: 1 } },
};
const result = await resolveDispatch({
basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined,
});
assert.equal(result.action, "stop", "should stop instead of crashing");
assert.ok((result as any).reason.includes("no active slice"), `reason should mention missing slice: ${(result as any).reason}`);
});
test("dispatch returns stop when phase=executing but activeSlice is null (corrupt state)", async () => {
const corruptState: GSDState = {
activeMilestone: { id: "M001", title: "Test" },
activeSlice: null,
activeTask: { id: "T01", title: "Task" },
phase: "executing",
recentDecisions: [],
blockers: [],
nextAction: "",
registry: [{ id: "M001", title: "Test", status: "active" }],
requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
progress: { milestones: { done: 0, total: 1 } },
};
const result = await resolveDispatch({
basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined,
});
assert.equal(result.action, "stop", "should stop instead of crashing");
});
// ─── Phase 6: Worktree & Lock Consistency ────────────────────────────────
test("repoIdentity returns a 12-char hex hash", async () => {
const { repoIdentity } = await import("../repo-identity.ts");
const hash = repoIdentity(process.cwd());
assert.ok(hash.length === 12, `hash should be 12 hex chars, got: ${hash}`);
assert.match(hash, /^[a-f0-9]{12}$/, `hash should be hex, got: ${hash}`);
});
test("session lock settings: retry path matches primary stale timeout", async () => {
// Verify the fix for #1304 — retry lock must use same settings as primary
const lockSource = (await import("node:fs")).readFileSync(
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
);
// Find all stale: settings
const staleMatches = [...lockSource.matchAll(/stale:\s*(\d[\d_]*)/g)];
const staleValues = staleMatches.map(m => parseInt(m[1]!.replace(/_/g, ""), 10));
// All stale values should be the same (primary and retry aligned)
const uniqueStale = [...new Set(staleValues)];
assert.equal(uniqueStale.length, 1, `all stale timeouts should be identical, got: ${staleValues.join(", ")}`);
});
test("COMPLETION_TRANSITION_CODES are a subset of DoctorIssueCode", async () => {
const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts");
// Just verify the set is non-empty and contains expected codes
assert.ok(COMPLETION_TRANSITION_CODES.size >= 3, "should have at least 3 transition codes");
for (const code of COMPLETION_TRANSITION_CODES) {
assert.ok(typeof code === "string", `code should be string: ${code}`);
assert.ok(code.startsWith("all_tasks_done_"), `code should start with all_tasks_done_: ${code}`);
}
});
// ─── Scope 2: State Derivation — Array Safety ────────────────────────────
test("deriveState: registry is always an array with malformed roadmap", async () => {
const tmp = makeTmp("malformed-roadmap");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
// Roadmap exists but is completely empty
writeFileSync(join(mDir, "M001-ROADMAP.md"), "");
const state = await deriveState(tmp);
assert.ok(Array.isArray(state.registry), "registry must be array");
assert.equal(state.activeMilestone?.id, "M001");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState: plan with garbled content still returns valid state", async () => {
const tmp = makeTmp("garbled-plan");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: false },
]));
// Plan file exists but contains garbage
writeFileSync(join(sDir, "S01-PLAN.md"), "just some random text\nno tasks here\n!!!");
const state = await deriveState(tmp);
// Should fall back to planning since no tasks parsed
assert.equal(state.phase, "planning");
assert.equal(state.activeSlice?.id, "S01");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Scope 4: Lock Management — Exit Handler Verification ────────────────
test("session lock: releaseSessionLock removes auto.lock file", async () => {
const tmp = makeTmp("lock-release");
try {
const gsd = join(tmp, ".gsd");
mkdirSync(gsd, { recursive: true });
const lockFile = join(gsd, "auto.lock");
writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
assert.ok(existsSync(lockFile), "lock file should exist before release");
const { releaseSessionLock } = await import("../session-lock.ts");
releaseSessionLock(tmp);
assert.ok(!existsSync(lockFile), "lock file should be removed after release");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("session lock: onCompromised handler exists in both primary and retry paths", async () => {
const lockSource = readFileSync(
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
);
const compromisedMatches = [...lockSource.matchAll(/onCompromised/g)];
// Should have at least 2 onCompromised handlers (primary + retry)
// plus the flag declaration and the check in validateSessionLock
assert.ok(compromisedMatches.length >= 3,
`expected ≥3 onCompromised references (primary + retry + flag), got ${compromisedMatches.length}`);
});
test("session lock: both onCompromised handlers null _releaseFunction (#1315)", async () => {
const lockSource = readFileSync(
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
);
// Extract onCompromised handler blocks — both should set _releaseFunction = null
const handlers = lockSource.match(/onCompromised:\s*\(\)\s*=>\s*\{[^}]+\}/g) || [];
assert.ok(handlers.length >= 2, `expected ≥2 onCompromised handlers, got ${handlers.length}`);
for (const h of handlers) {
assert.ok(h.includes("_releaseFunction = null"),
`onCompromised handler should null _releaseFunction: ${h}`);
}
});
test("session lock: exit handler uses ensureExitHandler to prevent double-registration (#1315)", async () => {
const lockSource = readFileSync(
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
);
// Should use ensureExitHandler instead of direct process.once("exit") in acquire paths
const directExitHandlers = (lockSource.match(/process\.once\("exit"/g) || []).length;
const ensureExitCalls = (lockSource.match(/ensureExitHandler\(/g) || []).length;
// Only 1 direct process.once("exit") allowed — inside ensureExitHandler itself
assert.ok(directExitHandlers <= 1,
`expected ≤1 direct process.once("exit") (inside ensureExitHandler), got ${directExitHandlers}`);
assert.ok(ensureExitCalls >= 2,
`expected ≥2 ensureExitHandler calls (primary + retry path), got ${ensureExitCalls}`);
});
test("signal handler: SIGINT handler registered alongside SIGTERM (#1315)", async () => {
const supervisorSource = readFileSync(
"src/resources/extensions/gsd/auto-supervisor.ts", "utf-8"
);
// registerSigtermHandler should register on both SIGTERM and SIGINT
assert.ok(supervisorSource.includes('process.on("SIGINT"') || supervisorSource.includes("process.on('SIGINT'"),
"registerSigtermHandler should register SIGINT handler");
assert.ok(supervisorSource.includes('process.off("SIGINT"') || supervisorSource.includes("process.off('SIGINT'"),
"deregisterSigtermHandler should deregister SIGINT handler");
});
// ─── Scope 5: Crash Recovery — Message Guidance per Unit Type ────────────
test("crash recovery: formatCrashInfo includes guidance for bootstrap crash", async () => {
const { formatCrashInfo } = await import("../crash-recovery.ts");
const info = formatCrashInfo({
pid: 12345,
startedAt: new Date().toISOString(),
unitType: "starting",
unitId: "bootstrap",
unitStartedAt: new Date().toISOString(),
completedUnits: 0,
});
assert.ok(info.includes("bootstrap"), "should mention bootstrap");
assert.ok(info.includes("No work was lost") || info.includes("/gsd auto"),
"should include recovery guidance for bootstrap crash");
});
test("crash recovery: formatCrashInfo includes guidance for execute-task crash", async () => {
const { formatCrashInfo } = await import("../crash-recovery.ts");
const info = formatCrashInfo({
pid: 12345,
startedAt: new Date().toISOString(),
unitType: "execute-task",
unitId: "M001/S01/T02",
unitStartedAt: new Date().toISOString(),
completedUnits: 5,
});
assert.ok(info.includes("execute"), "should mention execute");
assert.ok(info.includes("resume") || info.includes("preserved") || info.includes("/gsd auto"),
"should include recovery guidance for task crash");
});
test("crash recovery: formatCrashInfo includes guidance for complete-slice crash", async () => {
const { formatCrashInfo } = await import("../crash-recovery.ts");
const info = formatCrashInfo({
pid: 12345,
startedAt: new Date().toISOString(),
unitType: "complete-slice",
unitId: "M001/S01",
unitStartedAt: new Date().toISOString(),
completedUnits: 10,
});
assert.ok(info.includes("complete"), "should mention complete");
assert.ok(info.includes("finish") || info.includes("/gsd auto"),
"should include recovery guidance for completion crash");
});
test("crash recovery: formatCrashInfo includes guidance for research crash", async () => {
const { formatCrashInfo } = await import("../crash-recovery.ts");
const info = formatCrashInfo({
pid: 12345,
startedAt: new Date().toISOString(),
unitType: "research-milestone",
unitId: "M001",
unitStartedAt: new Date().toISOString(),
completedUnits: 1,
});
assert.ok(info.includes("research"), "should mention research");
assert.ok(info.includes("incomplete") || info.includes("re-run") || info.includes("/gsd auto"),
"should include recovery guidance for research crash");
});
// ─── Scope 6: Milestone Transitions — Dispatch Flow ─────────────────────
test("dispatch: needs-discussion stops with discussion guidance", async () => {
const tmp = makeTmp("dispatch-discussion");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nIdeas.");
const state = await deriveState(tmp);
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "stop");
assert.ok((result as any).reason.includes("discussion") || (result as any).reason.includes("discuss"),
"stop reason should mention discussion");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: pre-planning without context stops with guidance", async () => {
const tmp = makeTmp("dispatch-no-context");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
// No context, no roadmap — just a bare milestone directory
const state = await deriveState(tmp);
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "stop");
assert.ok((result as any).reason.includes("context") || (result as any).reason.includes("discuss"),
"stop reason should mention missing context");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: pre-planning with context dispatches research-milestone", async () => {
const tmp = makeTmp("dispatch-research");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nBuild a thing.");
const state = await deriveState(tmp);
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "dispatch");
assert.equal((result as any).unitType, "research-milestone");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: executing phase dispatches execute-task", async () => {
const tmp = makeTmp("dispatch-execute");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: false },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Do work", done: false },
]));
writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nDo the thing.");
const state = await deriveState(tmp);
assert.equal(state.phase, "executing");
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "dispatch");
assert.equal((result as any).unitType, "execute-task");
assert.equal((result as any).unitId, "M001/S01/T01");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: summarizing phase dispatches complete-slice", async () => {
const tmp = makeTmp("dispatch-complete-slice");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: false },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Done task", done: true },
]));
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
const state = await deriveState(tmp);
assert.equal(state.phase, "summarizing");
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "dispatch");
assert.equal((result as any).unitType, "complete-slice");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: validating-milestone dispatches validate-milestone", async () => {
const tmp = makeTmp("dispatch-validate");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: true },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Done", done: true },
]));
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone.");
const state = await deriveState(tmp);
assert.equal(state.phase, "validating-milestone");
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "dispatch");
assert.equal((result as any).unitType, "validate-milestone");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: completing-milestone dispatches complete-milestone", async () => {
const tmp = makeTmp("dispatch-complete-ms");
try {
const mDir = join(tmp, ".gsd", "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
mkdirSync(join(sDir, "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
{ id: "S01", title: "Test", done: true },
]));
writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
{ id: "T01", title: "Done", done: true },
]));
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone.");
writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nPassed.");
const state = await deriveState(tmp);
assert.equal(state.phase, "completing-milestone");
const result = await resolveDispatch({
basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
});
assert.equal(result.action, "dispatch");
assert.equal((result as any).unitType, "complete-milestone");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});

View file

@ -1,356 +0,0 @@
/**
* Mechanical Completion unit tests (ADR-003).
*
* Tests deterministic slice/milestone completion using fixture data.
* Uses node:test + node:assert for consistency with token-profile.test.ts.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomBytes } from "node:crypto";
// ─── Fixture Helpers ──────────────────────────────────────────────────────────
function createTmpBase(): string {
const base = join(tmpdir(), `gsd-mech-test-${randomBytes(4).toString("hex")}`);
mkdirSync(base, { recursive: true });
return base;
}
function scaffold(base: string, mid: string, sid: string, taskSummaries: Array<{ tid: string; content: string }>) {
const gsdRoot = join(base, ".gsd");
const mDir = join(gsdRoot, "milestones", mid);
const sDir = join(mDir, "slices", sid);
const tDir = join(sDir, "tasks");
mkdirSync(tDir, { recursive: true });
for (const { tid, content } of taskSummaries) {
writeFileSync(join(tDir, `${tid}-SUMMARY.md`), content, "utf-8");
}
return { gsdRoot, mDir, sDir, tDir };
}
function makeTaskSummary(tid: string, opts: {
oneLiner?: string;
provides?: string[];
key_files?: string[];
key_decisions?: string[];
verification_result?: string;
}): string {
const lines: string[] = [
"---",
`id: ${tid}`,
`parent: S01`,
`milestone: M001`,
];
if (opts.provides?.length) lines.push(`provides:\n${opts.provides.map(p => ` - ${p}`).join("\n")}`);
if (opts.key_files?.length) lines.push(`key_files:\n${opts.key_files.map(f => ` - ${f}`).join("\n")}`);
if (opts.key_decisions?.length) lines.push(`key_decisions:\n${opts.key_decisions.map(d => ` - ${d}`).join("\n")}`);
lines.push(`verification_result: ${opts.verification_result ?? "passed"}`);
lines.push("---");
lines.push("");
lines.push(`# ${tid}: Test Task`);
lines.push("");
if (opts.oneLiner) lines.push(`**${opts.oneLiner}**`);
lines.push("");
lines.push("## What Happened");
lines.push("");
lines.push(`Implemented the feature described in ${tid}. This was a significant change that modified multiple files across the codebase to support the new functionality.`);
lines.push("");
return lines.join("\n");
}
// ─── Source-level structural tests ────────────────────────────────────────────
const mechanicalSrc = readFileSync(
join(import.meta.dirname!, "..", "mechanical-completion.ts"),
"utf-8",
);
test("mechanical-completion: exports mechanicalSliceCompletion", () => {
assert.ok(
mechanicalSrc.includes("export async function mechanicalSliceCompletion"),
"should export mechanicalSliceCompletion",
);
});
test("mechanical-completion: exports aggregateMilestoneVerification", () => {
assert.ok(
mechanicalSrc.includes("export async function aggregateMilestoneVerification"),
"should export aggregateMilestoneVerification",
);
});
test("mechanical-completion: exports generateMilestoneSummary", () => {
assert.ok(
mechanicalSrc.includes("export async function generateMilestoneSummary"),
"should export generateMilestoneSummary",
);
});
test("mechanical-completion: exports appendNewDecisions", () => {
assert.ok(
mechanicalSrc.includes("export async function appendNewDecisions"),
"should export appendNewDecisions",
);
});
test("mechanical-completion: uses atomicWriteSync for file writes", () => {
assert.ok(
mechanicalSrc.includes("atomicWriteSync"),
"should use atomicWriteSync for safe file writes",
);
});
test("mechanical-completion: quality gate checks summary length for multi-task slices", () => {
assert.ok(
mechanicalSrc.includes("totalContent.length < 200"),
"should have quality gate for summary content length",
);
});
test("mechanical-completion: marks slice [x] in roadmap", () => {
assert.ok(
mechanicalSrc.includes("markSliceInRoadmap"),
"should mark slice done in roadmap",
);
});
test("mechanical-completion: aggregates VERIFY.json files for milestone validation", () => {
assert.ok(
mechanicalSrc.includes("resolveTaskJsonFiles") && mechanicalSrc.includes("VERIFY"),
"should read VERIFY.json files for milestone validation",
);
});
test("mechanical-completion: deduplicates decisions against existing DECISIONS.md", () => {
assert.ok(
mechanicalSrc.includes("existing.includes(d.trim())"),
"should deduplicate decisions against existing content",
);
});
test("mechanical-completion: produces VALIDATION.md with verdict frontmatter", () => {
assert.ok(
mechanicalSrc.includes("verdict:") && mechanicalSrc.includes("remediation_round: 0"),
"VALIDATION.md should have verdict and remediation_round frontmatter",
);
});
// ─── Integration tests with fixture data ──────────────────────────────────────
test("mechanical: slice completion with 2 task summaries produces SUMMARY.md", async () => {
const base = createTmpBase();
try {
const mid = "M001";
const sid = "S01";
// Scaffold task summaries
scaffold(base, mid, sid, [
{
tid: "T01",
content: makeTaskSummary("T01", {
oneLiner: "Set up project structure",
provides: ["project-scaffold"],
key_files: ["src/index.ts", "package.json"],
verification_result: "passed",
}),
},
{
tid: "T02",
content: makeTaskSummary("T02", {
oneLiner: "Add core API endpoints",
provides: ["api-endpoints"],
key_files: ["src/api.ts"],
key_decisions: ["Used Express over Fastify"],
verification_result: "passed",
}),
},
]);
// Write a roadmap with the slice unchecked
const roadmapPath = join(base, ".gsd", "milestones", mid, `${mid}-ROADMAP.md`);
writeFileSync(roadmapPath, `# Roadmap\n\n- [ ] **${sid}: First Slice**\n`, "utf-8");
// Write a slice plan with Verification section
const planPath = join(base, ".gsd", "milestones", mid, "slices", sid, `${sid}-PLAN.md`);
writeFileSync(planPath, `# Plan\n\n## Verification\n\n- Run \`npm test\`\n- Check output\n`, "utf-8");
// Dynamic import to get the actual module
const { mechanicalSliceCompletion } = await import("../mechanical-completion.js");
const ok = await mechanicalSliceCompletion(base, mid, sid);
assert.ok(ok, "should return true for valid slice completion");
// Check SUMMARY.md was written
const summaryPath = join(base, ".gsd", "milestones", mid, "slices", sid, `${sid}-SUMMARY.md`);
assert.ok(existsSync(summaryPath), "SUMMARY.md should exist");
const summaryContent = readFileSync(summaryPath, "utf-8");
assert.ok(summaryContent.includes("T01"), "summary should reference T01");
assert.ok(summaryContent.includes("T02"), "summary should reference T02");
assert.ok(summaryContent.includes("verification_result: passed"), "should have passed verification");
// Check roadmap was updated
const updatedRoadmap = readFileSync(roadmapPath, "utf-8");
assert.ok(updatedRoadmap.includes("[x]"), "roadmap should have [x] checkbox");
// Check UAT was written
const uatPath = join(base, ".gsd", "milestones", mid, "slices", sid, `${sid}-UAT.md`);
assert.ok(existsSync(uatPath), "UAT.md should exist");
const uatContent = readFileSync(uatPath, "utf-8");
assert.ok(uatContent.includes("npm test"), "UAT should contain verification content");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("mechanical: returns false for empty task summaries", async () => {
const base = createTmpBase();
try {
const mid = "M001";
const sid = "S01";
scaffold(base, mid, sid, []);
const { mechanicalSliceCompletion } = await import("../mechanical-completion.js");
const ok = await mechanicalSliceCompletion(base, mid, sid);
assert.ok(!ok, "should return false when no summaries exist");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("mechanical: returns false for insufficient summary content in multi-task slice", async () => {
const base = createTmpBase();
try {
const mid = "M001";
const sid = "S01";
// Two tasks but with very short content (under 200 chars)
scaffold(base, mid, sid, [
{ tid: "T01", content: "---\nid: T01\nparent: S01\nmilestone: M001\n---\n\n# T01: A\n\n**Short**\n" },
{ tid: "T02", content: "---\nid: T02\nparent: S01\nmilestone: M001\n---\n\n# T02: B\n\n**Brief**\n" },
]);
const { mechanicalSliceCompletion } = await import("../mechanical-completion.js");
const ok = await mechanicalSliceCompletion(base, mid, sid);
assert.ok(!ok, "should return false when summaries are too short");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("mechanical: milestone verification aggregates VERIFY.json files", async () => {
const base = createTmpBase();
try {
const mid = "M001";
const sid = "S01";
const { tDir } = scaffold(base, mid, sid, []);
// Write VERIFY.json files
const evidence = {
schemaVersion: 1,
taskId: "T01",
unitId: "M001/S01/T01",
timestamp: Date.now(),
passed: true,
discoverySource: "plan",
checks: [
{ command: "npm test", exitCode: 0, durationMs: 1500, verdict: "pass", blocking: true },
],
};
writeFileSync(join(tDir, "T01-VERIFY.json"), JSON.stringify(evidence), "utf-8");
const evidence2 = { ...evidence, taskId: "T02", passed: false, checks: [
{ command: "npm test", exitCode: 1, durationMs: 500, verdict: "fail", blocking: true },
]};
writeFileSync(join(tDir, "T02-VERIFY.json"), JSON.stringify(evidence2), "utf-8");
const { aggregateMilestoneVerification } = await import("../mechanical-completion.js");
const result = await aggregateMilestoneVerification(base, mid);
assert.equal(result.verdict, "mixed", "should be mixed when some pass and some fail");
assert.equal(result.checks.length, 2, "should have 2 checks");
// Check VALIDATION.md was written
const validationPath = join(base, ".gsd", "milestones", mid, `${mid}-VALIDATION.md`);
assert.ok(existsSync(validationPath), "VALIDATION.md should exist");
const validationContent = readFileSync(validationPath, "utf-8");
assert.ok(validationContent.includes("verdict: mixed"), "should have mixed verdict in frontmatter");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("mechanical: milestone summary aggregates slice summaries", async () => {
const base = createTmpBase();
try {
const mid = "M001";
// Create two slices with summaries
for (const sid of ["S01", "S02"]) {
const sDir = join(base, ".gsd", "milestones", mid, "slices", sid);
mkdirSync(sDir, { recursive: true });
writeFileSync(
join(sDir, `${sid}-SUMMARY.md`),
`---\nid: ${sid}\nprovides:\n - feature-${sid.toLowerCase()}\nkey_files:\n - src/${sid.toLowerCase()}.ts\n---\n\n# ${sid}: Slice\n\n**${sid} implemented**\n`,
"utf-8",
);
}
const { generateMilestoneSummary } = await import("../mechanical-completion.js");
const content = await generateMilestoneSummary(base, mid);
assert.ok(content.includes("S01"), "should reference S01");
assert.ok(content.includes("S02"), "should reference S02");
assert.ok(content.includes("feature-s01"), "should aggregate provides");
assert.ok(content.includes("feature-s02"), "should aggregate provides");
const summaryPath = join(base, ".gsd", "milestones", mid, `${mid}-SUMMARY.md`);
assert.ok(existsSync(summaryPath), "M##-SUMMARY.md should exist");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("mechanical: decision deduplication skips existing decisions", async () => {
const base = createTmpBase();
try {
const gsdRoot = join(base, ".gsd");
mkdirSync(gsdRoot, { recursive: true });
// Write existing decisions
const decisionsPath = join(gsdRoot, "DECISIONS.md");
writeFileSync(decisionsPath, "# Decisions\n\n- Used TypeScript for type safety\n", "utf-8");
const { appendNewDecisions } = await import("../mechanical-completion.js");
// Call with one existing and one new decision
const mockSummaries = [
{
frontmatter: {
id: "T01", parent: "S01", milestone: "M001",
provides: [], requires: [], affects: [],
key_files: [], key_decisions: ["Used TypeScript for type safety", "Chose Express over Koa"],
patterns_established: [], drill_down_paths: [], observability_surfaces: [],
duration: "", verification_result: "passed", completed_at: "", blocker_discovered: false,
},
title: "T01", oneLiner: "", whatHappened: "", deviations: "", filesModified: [],
},
];
await appendNewDecisions(base, mockSummaries as any);
const updated = readFileSync(decisionsPath, "utf-8");
assert.ok(updated.includes("Chose Express over Koa"), "should append new decision");
// The existing decision should not be duplicated
const matches = updated.match(/Used TypeScript for type safety/g);
assert.equal(matches?.length, 1, "should not duplicate existing decision");
} finally {
rmSync(base, { recursive: true, force: true });
}
});

View file

@ -38,9 +38,6 @@ function createTempRepo(): string {
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
// Mirror production: .gsd/worktrees/ is gitignored so autoCommitDirtyState
// doesn't pick up the worktrees directory as dirty state (#1127 fix).
writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n");
run("git add .", dir);
run("git commit -m init", dir);
run("git branch -M main", dir);
@ -125,23 +122,23 @@ test("worktree swap on milestone transition: merge old, create new", () => {
// ─── Verify the transition code path exists in auto.ts ──────────────────────
test("auto.ts milestone transition block contains worktree lifecycle", () => {
test("auto-loop.ts milestone transition block contains worktree lifecycle", () => {
const autoSrc = readFileSync(
join(__dirname, "..", "auto.ts"),
join(__dirname, "..", "auto-loop.ts"),
"utf-8",
);
// The fix adds worktree merge + create inside the milestone transition block
// The resolver handles worktree merge + enter inside the milestone transition block
assert.ok(
autoSrc.includes("Worktree lifecycle on milestone transition"),
"auto.ts should contain the worktree lifecycle comment marker",
"auto-loop.ts should contain the worktree lifecycle comment marker",
);
assert.ok(
autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== s.currentMilestoneId"),
"auto.ts should call mergeMilestoneToMain during milestone transition",
autoSrc.includes("resolver.mergeAndExit") && autoSrc.includes("mid !== s.currentMilestoneId"),
"auto-loop.ts should call resolver.mergeAndExit during milestone transition",
);
assert.ok(
autoSrc.includes("createAutoWorktree") && autoSrc.includes("Created auto-worktree for"),
"auto.ts should create new worktree for incoming milestone",
autoSrc.includes("resolver.enterMilestone"),
"auto-loop.ts should call resolver.enterMilestone for incoming milestone",
);
});

View file

@ -1,206 +0,0 @@
/**
* progress-score.test.ts Tests for progress score / traffic light (#1221).
*
* Tests:
* - Score computation from health signals
* - Signal evaluation (trend, error streak, recent errors)
* - Context-aware scoring (retry counts, unit progress)
* - Formatting (single-line, detailed report)
*/
import {
recordHealthSnapshot,
resetProactiveHealing,
} from "../doctor-proactive.ts";
import {
computeProgressScore,
computeProgressScoreWithContext,
formatProgressLine,
formatProgressReport,
} from "../progress-score.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
async function main(): Promise<void> {
try {
// ── Base Score: No Data ─────────────────────────────────────────────
console.log("\n=== progress: green with no data ===");
{
resetProactiveHealing();
const score = computeProgressScore();
assertEq(score.level, "green", "green when no data available");
assertTrue(score.summary.includes("Progressing well"), "summary says progressing");
assertTrue(score.signals.length > 0, "has signals");
}
// ── Green: Clean Health Data ────────────────────────────────────────
console.log("\n=== progress: green with clean health ===");
{
resetProactiveHealing();
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScore();
assertEq(score.level, "green", "green with all clean snapshots");
}
// ── Yellow: Some Warnings ──────────────────────────────────────────
console.log("\n=== progress: yellow with error streak ===");
{
resetProactiveHealing();
recordHealthSnapshot(1, 2, 0);
recordHealthSnapshot(1, 1, 0);
const score = computeProgressScore();
assertEq(score.level, "yellow", "yellow with consecutive errors");
assertTrue(score.summary.includes("Struggling"), "summary says struggling");
}
// ── Red: Degrading Health ──────────────────────────────────────────
console.log("\n=== progress: red with degrading trend ===");
{
resetProactiveHealing();
// 5 older clean snapshots
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(0, 0, 0);
}
// 5 recent error snapshots — triggers degrading trend
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(3, 5, 0);
}
const score = computeProgressScore();
assertEq(score.level, "red", "red with degrading trend and persistent errors");
assertTrue(score.summary.includes("Stuck"), "summary says stuck");
}
// ── Red: High Error Streak ─────────────────────────────────────────
console.log("\n=== progress: red with high error streak ===");
{
resetProactiveHealing();
for (let i = 0; i < 4; i++) {
recordHealthSnapshot(2, 0, 0);
}
const score = computeProgressScore();
assertEq(score.level, "red", "red with 4 consecutive error units");
}
// ── Context-Aware Scoring ──────────────────────────────────────────
console.log("\n=== progress: context with retries ===");
{
resetProactiveHealing();
for (let i = 0; i < 3; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScoreWithContext({
currentUnitId: "M001/S01/T03",
completedUnits: 2,
totalUnits: 5,
retryCount: 0,
maxRetries: 5,
});
assertEq(score.level, "green", "green with no retries");
assertTrue(score.summary.includes("M001/S01/T03"), "summary includes unit ID");
assertTrue(score.summary.includes("2 of 5"), "summary includes progress");
}
console.log("\n=== progress: context with high retry count ===");
{
resetProactiveHealing();
for (let i = 0; i < 3; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScoreWithContext({
currentUnitId: "M001/S01/T03",
retryCount: 4,
maxRetries: 5,
});
assertEq(score.level, "red", "red with high retry count");
assertTrue(score.summary.includes("looping"), "summary mentions looping");
}
console.log("\n=== progress: context with moderate retries ===");
{
resetProactiveHealing();
for (let i = 0; i < 3; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScoreWithContext({
currentUnitId: "M001/S01/T03",
retryCount: 1,
maxRetries: 5,
});
assertEq(score.level, "yellow", "yellow with 1 retry");
}
// ── Formatting ─────────────────────────────────────────────────────
console.log("\n=== progress: formatProgressLine ===");
{
resetProactiveHealing();
const score = computeProgressScore();
const line = formatProgressLine(score);
assertTrue(line.includes("Progressing well"), "line includes summary");
// Should start with green circle emoji
assertTrue(line.startsWith("\uD83D\uDFE2"), "starts with green circle");
}
console.log("\n=== progress: formatProgressLine yellow ===");
{
resetProactiveHealing();
recordHealthSnapshot(1, 0, 0);
recordHealthSnapshot(1, 0, 0);
const score = computeProgressScore();
const line = formatProgressLine(score);
assertTrue(line.startsWith("\uD83D\uDFE1"), "starts with yellow circle");
}
console.log("\n=== progress: formatProgressReport ===");
{
resetProactiveHealing();
recordHealthSnapshot(0, 1, 0);
const score = computeProgressScore();
const detailed = formatProgressReport(score);
assertTrue(detailed.includes("Signals:"), "report has signals section");
assertTrue(detailed.includes("health_trend"), "report includes trend signal");
assertTrue(detailed.includes("error_streak"), "report includes streak signal");
}
// ── Signal Details ─────────────────────────────────────────────────
console.log("\n=== progress: signal names are consistent ===");
{
resetProactiveHealing();
recordHealthSnapshot(0, 0, 0);
const score = computeProgressScore();
const names = score.signals.map(s => s.name);
assertTrue(names.includes("health_trend"), "has health_trend signal");
assertTrue(names.includes("error_streak"), "has error_streak signal");
assertTrue(names.includes("recent_errors"), "has recent_errors signal");
assertTrue(names.includes("artifact_production"), "has artifact_production signal");
assertTrue(names.includes("dispatch_velocity"), "has dispatch_velocity signal");
}
console.log("\n=== progress: all signals have valid levels ===");
{
resetProactiveHealing();
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(1, 1, 1);
}
const score = computeProgressScore();
for (const signal of score.signals) {
assertTrue(
signal.level === "green" || signal.level === "yellow" || signal.level === "red",
`signal ${signal.name} has valid level: ${signal.level}`,
);
assertTrue(signal.detail.length > 0, `signal ${signal.name} has non-empty detail`);
}
}
} finally {
resetProactiveHealing();
}
report();
}
main();

View file

@ -277,13 +277,11 @@ test("index.ts tracks consecutive transient errors for escalating backoff", () =
test("index.ts resets consecutive transient error counter on success", () => {
const indexSource = readFileSync(join(__dirname, "..", "index.ts"), "utf-8");
// After successful unit completion, the counter must be reset
const marker = "successful unit completion";
const successSection = indexSource.indexOf(marker);
assert.ok(successSection > -1, "must have success section that clears network retries");
const nearbyCode = indexSource.slice(Math.max(0, successSection - 100), successSection + 200);
// After successful unit completion, the counter must be reset.
// Use a regex across the success block so CRLF checkouts on Windows do not
// push the reset line outside a fixed substring window.
assert.ok(
nearbyCode.includes("consecutiveTransientErrors = 0"),
/consecutiveTransientErrors\s*=\s*0\s*;[\s\S]{0,250}successful unit completion/.test(indexSource),
"consecutive transient error counter must be reset on successful unit completion (#1166)",
);
});

View file

@ -334,7 +334,7 @@ async function main(): Promise<void> {
].join('\n'),
);
// human-experience UAT — should not dispatch
// human-experience UAT still dispatches, but auto-mode later pauses for manual review
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('human-experience'));
const state = {
@ -351,8 +351,8 @@ async function main(): Promise<void> {
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertEq(
result,
null,
'human-experience UAT is skipped — auto-mode only dispatches artifact-driven UATs',
{ sliceId: 'S01', uatType: 'human-experience' },
'human-experience UAT dispatches so auto-mode can pause for manual review',
);
} finally {
cleanup(base);

View file

@ -1,434 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
acquireSessionLock,
releaseSessionLock,
updateSessionLock,
validateSessionLock,
readSessionLockData,
isSessionLockHeld,
isSessionLockProcessAlive,
cleanupStrayLockFiles,
} from "../session-lock.ts";
// ─── acquireSessionLock ──────────────────────────────────────────────────
test("acquireSessionLock succeeds on empty directory", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true, "should acquire lock on empty dir");
// Verify lock file was created with correct data
const lockPath = join(dir, ".gsd", "auto.lock");
assert.ok(existsSync(lockPath), "auto.lock should exist after acquire");
const data = JSON.parse(readFileSync(lockPath, "utf-8"));
assert.equal(data.pid, process.pid, "lock should contain current PID");
assert.equal(data.unitType, "starting", "initial unit type should be 'starting'");
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
test("acquireSessionLock rejects when another live process holds lock", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
// Simulate another process holding the lock by writing a lock with parent PID
const fakeLockData = {
pid: process.ppid,
startedAt: new Date().toISOString(),
unitType: "execute-task",
unitId: "M001/S01/T01",
unitStartedAt: new Date().toISOString(),
completedUnits: 2,
};
writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(fakeLockData, null, 2));
// First acquire to set up proper-lockfile state
const result1 = acquireSessionLock(dir);
// If proper-lockfile is available, it should manage the OS lock.
// If not (fallback mode), the PID check should detect the live process.
// Either way, we can't fully simulate another process holding an OS lock
// from within the same process, so we test the fallback path.
if (result1.acquired) {
// We got the lock (proper-lockfile saw no OS lock from another process)
// This is expected since we're in the same process
releaseSessionLock(dir);
}
rmSync(dir, { recursive: true, force: true });
});
test("acquireSessionLock takes over stale lock from dead process", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
// Write a lock from a dead process
const staleLockData = {
pid: 9999999,
startedAt: "2026-03-01T00:00:00Z",
unitType: "execute-task",
unitId: "M001/S01/T01",
unitStartedAt: "2026-03-01T00:00:00Z",
completedUnits: 0,
};
writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(staleLockData, null, 2));
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true, "should take over lock from dead process");
// Verify our PID is now in the lock
const data = readSessionLockData(dir);
assert.ok(data, "lock data should exist after acquire");
assert.equal(data!.pid, process.pid, "lock should contain our PID now");
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
// ─── releaseSessionLock ─────────────────────────────────────────────────
test("releaseSessionLock removes the lock file", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true);
releaseSessionLock(dir);
const lockPath = join(dir, ".gsd", "auto.lock");
assert.ok(!existsSync(lockPath), "auto.lock should be removed after release");
rmSync(dir, { recursive: true, force: true });
});
test("releaseSessionLock is safe when no lock exists", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
// Should not throw
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
// ─── updateSessionLock ──────────────────────────────────────────────────
test("updateSessionLock updates the lock data without re-acquiring", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true);
updateSessionLock(dir, "execute-task", "M001/S01/T02", 3, "/tmp/session.jsonl");
const data = readSessionLockData(dir);
assert.ok(data, "lock data should exist after update");
assert.equal(data!.pid, process.pid, "PID should still be ours");
assert.equal(data!.unitType, "execute-task", "unit type should be updated");
assert.equal(data!.unitId, "M001/S01/T02", "unit ID should be updated");
assert.equal(data!.completedUnits, 3, "completed count should be updated");
assert.equal(data!.sessionFile, "/tmp/session.jsonl", "session file should be recorded");
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
// ─── validateSessionLock ────────────────────────────────────────────────
test("validateSessionLock returns true when we hold the lock", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true);
assert.equal(validateSessionLock(dir), true, "should validate when we hold the lock");
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
test("validateSessionLock returns false after release", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true);
assert.equal(validateSessionLock(dir), true, "should be valid while held");
// Release the lock — both OS lock and lock file are removed
releaseSessionLock(dir);
// After release, _lockedPath is cleared and lock file is gone
assert.equal(isSessionLockHeld(dir), false, "should not be held after release");
rmSync(dir, { recursive: true, force: true });
});
test("validateSessionLock returns false when another PID owns the lock", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
// Write lock data with a different PID (parent process)
const foreignLockData = {
pid: process.ppid,
startedAt: new Date().toISOString(),
unitType: "execute-task",
unitId: "M001/S01/T01",
unitStartedAt: new Date().toISOString(),
completedUnits: 0,
};
writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(foreignLockData, null, 2));
// Without holding the OS lock, validate should check PID
assert.equal(validateSessionLock(dir), false, "should fail when another PID owns lock");
rmSync(dir, { recursive: true, force: true });
});
// ─── isSessionLockHeld ──────────────────────────────────────────────────
test("isSessionLockHeld returns true after acquire", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
acquireSessionLock(dir);
assert.equal(isSessionLockHeld(dir), true);
releaseSessionLock(dir);
assert.equal(isSessionLockHeld(dir), false, "should return false after release");
rmSync(dir, { recursive: true, force: true });
});
// ─── isSessionLockProcessAlive ──────────────────────────────────────────
test("isSessionLockProcessAlive returns false for dead PID", () => {
const data = {
pid: 9999999,
startedAt: new Date().toISOString(),
unitType: "starting",
unitId: "bootstrap",
unitStartedAt: new Date().toISOString(),
completedUnits: 0,
};
assert.equal(isSessionLockProcessAlive(data), false);
});
test("isSessionLockProcessAlive returns false for own PID (recycled)", () => {
const data = {
pid: process.pid,
startedAt: new Date().toISOString(),
unitType: "starting",
unitId: "bootstrap",
unitStartedAt: new Date().toISOString(),
completedUnits: 0,
};
// Own PID returns false because it means the lock is from a recycled PID
assert.equal(isSessionLockProcessAlive(data), false);
});
// ─── readSessionLockData ────────────────────────────────────────────────
test("readSessionLockData returns null when no lock exists", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
const data = readSessionLockData(dir);
assert.equal(data, null);
rmSync(dir, { recursive: true, force: true });
});
test("readSessionLockData reads existing lock data", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
const lockData = {
pid: 12345,
startedAt: "2026-03-18T00:00:00Z",
unitType: "execute-task",
unitId: "M001/S01/T01",
unitStartedAt: "2026-03-18T00:01:00Z",
completedUnits: 2,
sessionFile: "/tmp/session.jsonl",
};
writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
const data = readSessionLockData(dir);
assert.ok(data, "should read lock data");
assert.equal(data!.pid, 12345);
assert.equal(data!.unitType, "execute-task");
assert.equal(data!.unitId, "M001/S01/T01");
assert.equal(data!.completedUnits, 2);
assert.equal(data!.sessionFile, "/tmp/session.jsonl");
rmSync(dir, { recursive: true, force: true });
});
// ─── Acquire → Release → Re-Acquire lifecycle ──────────────────────────
test("session lock supports acquire → release → re-acquire cycle", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
// First acquire
const r1 = acquireSessionLock(dir);
assert.equal(r1.acquired, true, "first acquire should succeed");
assert.equal(isSessionLockHeld(dir), true);
// Release
releaseSessionLock(dir);
assert.equal(isSessionLockHeld(dir), false);
// Re-acquire
const r2 = acquireSessionLock(dir);
assert.equal(r2.acquired, true, "re-acquire after release should succeed");
assert.equal(isSessionLockHeld(dir), true);
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
// ─── Lock creates .gsd/ directory if needed ─────────────────────────────
test("acquireSessionLock creates .gsd/ if it does not exist", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
// Do NOT create .gsd/ — let the lock function do it
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true, "should succeed even without .gsd/");
assert.ok(existsSync(join(dir, ".gsd")), ".gsd/ should be created");
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
// ─── cleanupStrayLockFiles (#1315) ──────────────────────────────────────
test("cleanupStrayLockFiles removes numbered lock variants but preserves auto.lock", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
const gsdDir = join(dir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
// Create canonical lock file + numbered variants
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":2}');
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":3}');
writeFileSync(join(gsdDir, "auto 4.lock"), '{"pid":4}');
cleanupStrayLockFiles(dir);
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed");
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed");
assert.ok(!existsSync(join(gsdDir, "auto 4.lock")), "auto 4.lock should be removed");
rmSync(dir, { recursive: true, force: true });
});
test("cleanupStrayLockFiles handles parenthesized variants", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
const gsdDir = join(dir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
// macOS sometimes uses parenthesized format: "auto (2).lock"
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
writeFileSync(join(gsdDir, "auto (2).lock"), '{"pid":2}');
cleanupStrayLockFiles(dir);
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
assert.ok(!existsSync(join(gsdDir, "auto (2).lock")), "auto (2).lock should be removed");
rmSync(dir, { recursive: true, force: true });
});
test("cleanupStrayLockFiles does not remove unrelated files", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
const gsdDir = join(dir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
// Create unrelated files that should NOT be removed
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
writeFileSync(join(gsdDir, "config.json"), '{}');
writeFileSync(join(gsdDir, "other.lock"), '{}');
cleanupStrayLockFiles(dir);
assert.ok(existsSync(join(gsdDir, "auto.lock")), "auto.lock should be preserved");
assert.ok(existsSync(join(gsdDir, "config.json")), "config.json should be preserved");
assert.ok(existsSync(join(gsdDir, "other.lock")), "other.lock should be preserved");
rmSync(dir, { recursive: true, force: true });
});
test("cleanupStrayLockFiles is safe on empty directory", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
const gsdDir = join(dir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
// Should not throw
cleanupStrayLockFiles(dir);
rmSync(dir, { recursive: true, force: true });
});
test("cleanupStrayLockFiles is safe when .gsd/ does not exist", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
// Should not throw even without .gsd/
cleanupStrayLockFiles(dir);
rmSync(dir, { recursive: true, force: true });
});
test("acquireSessionLock cleans stray lock files before acquiring", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
const gsdDir = join(dir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
// Plant stray lock files before acquire
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":9999998}');
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true, "should acquire lock");
// Stray files should be cleaned up
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during acquire");
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed during acquire");
releaseSessionLock(dir);
rmSync(dir, { recursive: true, force: true });
});
test("releaseSessionLock cleans stray lock files after releasing", () => {
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
const gsdDir = join(dir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
const result = acquireSessionLock(dir);
assert.equal(result.acquired, true);
// Plant stray lock files (simulating cloud sync creating them during session)
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
releaseSessionLock(dir);
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during release");
assert.ok(!existsSync(join(gsdDir, "auto.lock")), "auto.lock should also be removed");
rmSync(dir, { recursive: true, force: true });
});

View file

@ -0,0 +1,181 @@
/**
* sidecar-queue.test.ts Source-level contract tests for the sidecar queue pattern (S03).
*
* Verifies the structural invariants of the sidecar queue: the SidecarItem type,
* AutoSession sidecarQueue field, enqueue patterns in postUnitPostVerification,
* and dequeue logic in autoLoop. These are source-reading tests no runtime required.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts");
const POST_UNIT_TS_PATH = join(__dirname, "..", "auto-post-unit.ts");
const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts");
function getSessionTsSource(): string {
return readFileSync(SESSION_TS_PATH, "utf-8");
}
function getPostUnitTsSource(): string {
return readFileSync(POST_UNIT_TS_PATH, "utf-8");
}
function getAutoLoopTsSource(): string {
return readFileSync(AUTO_LOOP_TS_PATH, "utf-8");
}
/**
* Extract the body of postUnitPostVerification from auto-post-unit.ts source.
*/
function getPostUnitPostVerificationBody(): string {
const source = getPostUnitTsSource();
const fnIdx = source.indexOf("export async function postUnitPostVerification");
assert.ok(fnIdx > -1, "postUnitPostVerification must exist in auto-post-unit.ts");
return source.slice(fnIdx);
}
// ─── SidecarItem type contract ───────────────────────────────────────────────
test("SidecarItem type is exported from session.ts", () => {
const source = getSessionTsSource();
assert.ok(
source.includes("export interface SidecarItem"),
"session.ts must export the SidecarItem interface",
);
});
test("SidecarItem has required kind field with hook/triage/quick-task union", () => {
const source = getSessionTsSource();
const ifaceIdx = source.indexOf("export interface SidecarItem");
const ifaceBlock = source.slice(ifaceIdx, ifaceIdx + 500);
assert.ok(
ifaceBlock.includes('"hook"') && ifaceBlock.includes('"triage"') && ifaceBlock.includes('"quick-task"'),
"SidecarItem.kind must be a union of 'hook' | 'triage' | 'quick-task'",
);
});
// ─── AutoSession sidecarQueue field ──────────────────────────────────────────
test("AutoSession declares sidecarQueue field", () => {
const source = getSessionTsSource();
assert.ok(
source.includes("sidecarQueue"),
"AutoSession must declare sidecarQueue property",
);
assert.ok(
source.includes("SidecarItem[]"),
"sidecarQueue must be typed as SidecarItem[]",
);
});
test("AutoSession resets sidecarQueue in reset()", () => {
const source = getSessionTsSource();
const resetIdx = source.indexOf("reset(): void");
assert.ok(resetIdx > -1, "AutoSession must have a reset() method");
const resetBlock = source.slice(resetIdx, resetIdx + 3000);
assert.ok(
resetBlock.includes("sidecarQueue"),
"reset() must clear sidecarQueue",
);
});
// ─── postUnitPostVerification: no inline dispatch ────────────────────────────
test("postUnitPostVerification does not call pi.sendMessage", () => {
const body = getPostUnitPostVerificationBody();
assert.ok(
!body.includes("pi.sendMessage"),
"postUnitPostVerification must not call pi.sendMessage — all dispatch goes through sidecar queue",
);
});
test("postUnitPostVerification does not call newSession", () => {
const body = getPostUnitPostVerificationBody();
assert.ok(
!body.includes("s.cmdCtx.newSession") && !body.includes("cmdCtx.newSession"),
"postUnitPostVerification must not call newSession — all dispatch goes through sidecar queue",
);
});
// ─── postUnitPostVerification: sidecar enqueue for hooks ─────────────────────
test("postUnitPostVerification pushes to sidecarQueue for hooks", () => {
const source = getPostUnitTsSource();
// Find the hook section (marked by the post-unit hooks comment)
const hookSectionStart = source.indexOf("// ── Post-unit hooks");
assert.ok(hookSectionStart > -1, "auto-post-unit.ts must have a post-unit hooks section");
const triageSectionStart = source.indexOf("// ── Triage check");
assert.ok(triageSectionStart > -1, "auto-post-unit.ts must have a triage check section");
const hookSection = source.slice(hookSectionStart, triageSectionStart);
assert.ok(
hookSection.includes("s.sidecarQueue.push("),
"hook section must push to s.sidecarQueue",
);
assert.ok(
hookSection.includes('kind: "hook"'),
"hook sidecar item must have kind: 'hook'",
);
});
// ─── postUnitPostVerification: sidecar enqueue for triage ────────────────────
test("postUnitPostVerification pushes to sidecarQueue for triage", () => {
const source = getPostUnitTsSource();
const triageSectionStart = source.indexOf("// ── Triage check");
const quickTaskSectionStart = source.indexOf("// ── Quick-task dispatch");
assert.ok(triageSectionStart > -1, "auto-post-unit.ts must have a triage check section");
assert.ok(quickTaskSectionStart > -1, "auto-post-unit.ts must have a quick-task dispatch section");
const triageSection = source.slice(triageSectionStart, quickTaskSectionStart);
assert.ok(
triageSection.includes("s.sidecarQueue.push("),
"triage section must push to s.sidecarQueue",
);
assert.ok(
triageSection.includes('kind: "triage"'),
"triage sidecar item must have kind: 'triage'",
);
});
// ─── postUnitPostVerification: sidecar enqueue for quick-tasks ───────────────
test("postUnitPostVerification pushes to sidecarQueue for quick-tasks", () => {
const source = getPostUnitTsSource();
const quickTaskSectionStart = source.indexOf("// ── Quick-task dispatch");
assert.ok(quickTaskSectionStart > -1, "auto-post-unit.ts must have a quick-task dispatch section");
const quickTaskSection = source.slice(quickTaskSectionStart);
assert.ok(
quickTaskSection.includes("s.sidecarQueue.push("),
"quick-task section must push to s.sidecarQueue",
);
assert.ok(
quickTaskSection.includes('kind: "quick-task"'),
"quick-task sidecar item must have kind: 'quick-task'",
);
});
// ─── autoLoop: sidecar dequeue ───────────────────────────────────────────────
test("autoLoop has sidecar-dequeue phase", () => {
const source = getAutoLoopTsSource();
assert.ok(
source.includes('"sidecar-dequeue"'),
"autoLoop must log phase: 'sidecar-dequeue' when draining the sidecar queue",
);
});
test("autoLoop does not have inline dispatch loop", () => {
const source = getAutoLoopTsSource();
assert.ok(
!source.includes('"await-inline-dispatch"'),
"autoLoop must not contain 'await-inline-dispatch' — replaced by sidecar queue",
);
assert.ok(
!source.includes("while (inlineResult"),
"autoLoop must not contain a while(inlineResult...) loop — replaced by sidecar queue drain",
);
});

View file

@ -28,9 +28,6 @@ function createTempRepo(): string {
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
// Mirror production: .gsd/worktrees/ is gitignored so autoCommitDirtyState
// doesn't pick up the worktrees directory as dirty state (#1127 fix).
writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n");
run("git add .", dir);
run("git commit -m init", dir);
run("git branch -M main", dir);

View file

@ -36,16 +36,16 @@ const typesSrc = readFileSync(join(__dirname, "..", "types.ts"), "utf-8");
test("types: TokenProfile type exported with budget/balanced/quality", () => {
assert.ok(typesSrc.includes("export type TokenProfile"), "TokenProfile should be exported");
assert.ok(typesSrc.includes("'budget'"), "should include budget");
assert.ok(typesSrc.includes("'balanced'"), "should include balanced");
assert.ok(typesSrc.includes("'quality'"), "should include quality");
assert.match(typesSrc, /["']budget["']/, "should include budget");
assert.match(typesSrc, /["']balanced["']/, "should include balanced");
assert.match(typesSrc, /["']quality["']/, "should include quality");
});
test("types: InlineLevel type exported with full/standard/minimal", () => {
assert.ok(typesSrc.includes("export type InlineLevel"), "InlineLevel should be exported");
assert.ok(typesSrc.includes("'full'"), "should include full");
assert.ok(typesSrc.includes("'standard'"), "should include standard");
assert.ok(typesSrc.includes("'minimal'"), "should include minimal");
assert.match(typesSrc, /["']full["']/, "should include full");
assert.match(typesSrc, /["']standard["']/, "should include standard");
assert.match(typesSrc, /["']minimal["']/, "should include minimal");
});
test("types: PhaseSkipPreferences interface exported", () => {

View file

@ -108,14 +108,14 @@ test("dispatch: triage check guards against quick-task triggering triage", () =>
);
});
test("dispatch: triage dispatch uses return-value pattern", () => {
test("dispatch: triage dispatch keeps the loop in continue mode", () => {
const triageBlock = postUnitSrc.slice(
postUnitSrc.indexOf("// ── Triage check"),
postUnitSrc.indexOf("// ── Quick-task dispatch"),
);
assert.ok(
triageBlock.includes('return "dispatched"'),
"triage dispatch should return 'dispatched' after sending message",
triageBlock.includes('return "continue"'),
"triage dispatch should return 'continue' after enqueuing sidecar work",
);
});
@ -309,14 +309,14 @@ test("dispatch: quick-task dispatch marks capture as executed", () => {
);
});
test("dispatch: quick-task dispatch uses return-value pattern", () => {
test("dispatch: quick-task dispatch keeps the loop in continue mode", () => {
const quickTaskSection = postUnitSrc.slice(
postUnitSrc.indexOf("// ── Quick-task dispatch"),
postUnitSrc.indexOf("if (s.stepMode)"),
);
assert.ok(
quickTaskSection.includes('return "dispatched"'),
"quick-task dispatch should return 'dispatched' after sending message",
quickTaskSection.includes('return "continue"'),
"quick-task dispatch should return 'continue' after enqueuing sidecar work",
);
});

View file

@ -19,11 +19,17 @@ test("handleUndo without --force only warns and leaves completed units intact",
const base = makeTempDir("gsd-undo-confirm");
try {
mkdirSync(join(base, ".gsd"), { recursive: true });
mkdirSync(join(base, ".gsd", "activity"), { recursive: true });
writeFileSync(
join(base, ".gsd", "completed-units.json"),
JSON.stringify(["execute-task/M001/S01/T01"]),
"utf-8",
);
writeFileSync(
join(base, ".gsd", "activity", "001-execute-task-M001-S01-T01.jsonl"),
"",
"utf-8",
);
const notifications: Array<{ message: string; level: string }> = [];
const ctx = {

View file

@ -58,7 +58,6 @@ test("verification-evidence: writeVerificationJSON writes correct JSON shape", (
stdout: "all good",
stderr: "",
durationMs: 2340,
blocking: true,
},
],
});
@ -106,9 +105,9 @@ test("verification-evidence: writeVerificationJSON maps exitCode to verdict corr
const result = makeResult({
passed: false,
checks: [
{ command: "lint", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true },
{ command: "test", exitCode: 1, stdout: "", stderr: "fail", durationMs: 200, blocking: true },
{ command: "audit", exitCode: 2, stdout: "", stderr: "err", durationMs: 300, blocking: true },
{ command: "lint", exitCode: 0, stdout: "", stderr: "", durationMs: 100 },
{ command: "test", exitCode: 1, stdout: "", stderr: "fail", durationMs: 200 },
{ command: "audit", exitCode: 2, stdout: "", stderr: "err", durationMs: 300 },
],
});
@ -134,7 +133,6 @@ test("verification-evidence: writeVerificationJSON excludes stdout/stderr from o
stdout: "hello\n",
stderr: "some warning",
durationMs: 50,
blocking: true,
},
],
});
@ -183,8 +181,8 @@ test("verification-evidence: writeVerificationJSON uses optional unitId when pro
test("verification-evidence: formatEvidenceTable returns markdown table with correct columns", () => {
const result = makeResult({
checks: [
{ command: "npm run typecheck", exitCode: 0, stdout: "", stderr: "", durationMs: 2340, blocking: true },
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "err", durationMs: 1100, blocking: true },
{ command: "npm run typecheck", exitCode: 0, stdout: "", stderr: "", durationMs: 2340 },
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "err", durationMs: 1100 },
],
});
@ -216,9 +214,9 @@ test("verification-evidence: formatEvidenceTable returns no-checks message for e
test("verification-evidence: formatEvidenceTable formats duration as seconds with 1 decimal", () => {
const result = makeResult({
checks: [
{ command: "fast", exitCode: 0, stdout: "", stderr: "", durationMs: 150, blocking: true },
{ command: "slow", exitCode: 0, stdout: "", stderr: "", durationMs: 2340, blocking: true },
{ command: "zero", exitCode: 0, stdout: "", stderr: "", durationMs: 0, blocking: true },
{ command: "fast", exitCode: 0, stdout: "", stderr: "", durationMs: 150 },
{ command: "slow", exitCode: 0, stdout: "", stderr: "", durationMs: 2340 },
{ command: "zero", exitCode: 0, stdout: "", stderr: "", durationMs: 0 },
],
});
@ -232,8 +230,8 @@ test("verification-evidence: formatEvidenceTable uses ✅/❌ emoji for pass/fai
const result = makeResult({
passed: false,
checks: [
{ command: "pass-cmd", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true },
{ command: "fail-cmd", exitCode: 1, stdout: "", stderr: "", durationMs: 200, blocking: true },
{ command: "pass-cmd", exitCode: 0, stdout: "", stderr: "", durationMs: 100 },
{ command: "fail-cmd", exitCode: 1, stdout: "", stderr: "", durationMs: 200 },
],
});
@ -337,8 +335,8 @@ test("verification-evidence: integration — VerificationResult → JSON → tab
const result = makeResult({
passed: false,
checks: [
{ command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500, blocking: true },
{ command: "npm run test:unit", exitCode: 1, stdout: "", stderr: "1 failed", durationMs: 3200, blocking: true },
{ command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500 },
{ command: "npm run test:unit", exitCode: 1, stdout: "", stderr: "1 failed", durationMs: 3200 },
],
discoverySource: "package-json",
});
@ -392,7 +390,7 @@ test("verification-evidence: writeVerificationJSON with retryAttempt and maxRetr
const result = makeResult({
passed: false,
checks: [
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "error", durationMs: 300, blocking: true },
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "error", durationMs: 300 },
],
});
@ -417,7 +415,7 @@ test("verification-evidence: writeVerificationJSON without retry params omits re
const result = makeResult({
passed: true,
checks: [
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 },
],
});
@ -443,7 +441,7 @@ test("verification-evidence: writeVerificationJSON includes runtimeErrors when p
const result = makeResult({
passed: false,
checks: [
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 },
],
runtimeErrors: [
{ source: "bg-shell", severity: "crash", message: "Server crashed", blocking: true },
@ -475,7 +473,7 @@ test("verification-evidence: writeVerificationJSON omits runtimeErrors when abse
const result = makeResult({
passed: true,
checks: [
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50, blocking: true },
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50 },
],
});
@ -514,7 +512,7 @@ test("verification-evidence: formatEvidenceTable appends runtime errors section"
const result = makeResult({
passed: false,
checks: [
{ command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100 },
],
runtimeErrors: [
{ source: "bg-shell", severity: "crash", message: "Server crashed with SIGKILL", blocking: true },
@ -539,7 +537,7 @@ test("verification-evidence: formatEvidenceTable omits runtime errors section wh
const result = makeResult({
passed: true,
checks: [
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200, blocking: true },
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200 },
],
});
@ -554,7 +552,7 @@ test("verification-evidence: formatEvidenceTable truncates runtime error message
const result = makeResult({
passed: false,
checks: [
{ command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100 },
],
runtimeErrors: [
{ source: "bg-shell", severity: "error", message: longMessage, blocking: false },
@ -600,7 +598,7 @@ test("verification-evidence: writeVerificationJSON includes auditWarnings when p
const result = makeResult({
passed: true,
checks: [
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 },
],
auditWarnings: SAMPLE_AUDIT_WARNINGS,
});
@ -629,7 +627,7 @@ test("verification-evidence: writeVerificationJSON omits auditWarnings when abse
const result = makeResult({
passed: true,
checks: [
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50, blocking: true },
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50 },
],
});
@ -668,7 +666,7 @@ test("verification-evidence: formatEvidenceTable appends audit warnings section"
const result = makeResult({
passed: true,
checks: [
{ command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100 },
],
auditWarnings: SAMPLE_AUDIT_WARNINGS,
});
@ -691,7 +689,7 @@ test("verification-evidence: formatEvidenceTable omits audit warnings section wh
const result = makeResult({
passed: true,
checks: [
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200, blocking: true },
{ command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200 },
],
});
@ -707,7 +705,7 @@ test("verification-evidence: integration — VerificationResult with auditWarnin
const result = makeResult({
passed: true,
checks: [
{ command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500, blocking: true },
{ command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500 },
],
auditWarnings: [
{

View file

@ -261,71 +261,6 @@ test("verification-gate: each check has durationMs", () => {
}
});
// ─── Infra Error Tagging Tests ───────────────────────────────────────────────
test("verification-gate: spawnSync ETIMEDOUT → infraError: true on the check", () => {
const tmp = makeTempDir("vg-etimedout");
try {
// Use a short timeout against a long sleep to guarantee ETIMEDOUT
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["sleep 60"],
commandTimeoutMs: 200,
});
assert.equal(result.passed, false);
assert.equal(result.checks.length, 1);
assert.ok(result.checks[0].exitCode !== 0, "should have non-zero exit code");
assert.equal(result.checks[0].infraError, true, "ETIMEDOUT should be tagged as infraError");
} finally {
rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
});
test("verification-gate: real command failure does NOT have infraError", () => {
const tmp = makeTempDir("vg-real-fail");
try {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
// Cross-platform: node with --eval flag and no shell-sensitive characters
preferenceCommands: ["node --eval \"process.exitCode=1\""],
});
assert.equal(result.passed, false);
assert.equal(result.checks.length, 1);
assert.equal(result.checks[0].exitCode, 1);
assert.equal(result.checks[0].infraError, undefined, "real failure should not be tagged as infraError");
} finally {
rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
});
test("verification-gate: mixed infra + real failure — only infra check is tagged", () => {
const tmp = makeTempDir("vg-mixed-infra");
try {
// Use a timeout that kills "sleep 60" but lets "node --eval" complete (~80ms).
// The gate applies the same timeout to each command sequentially.
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["sleep 60", "node --eval \"process.exitCode=2\""],
commandTimeoutMs: 500,
});
assert.equal(result.passed, false);
assert.equal(result.checks.length, 2);
// First check: ETIMEDOUT → infraError
assert.equal(result.checks[0].infraError, true, "timed-out command should be infraError");
// Second check: real exit 2 → no infraError
assert.equal(result.checks[1].exitCode, 2);
assert.equal(result.checks[1].infraError, undefined, "real failure should not be infraError");
} finally {
rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
});
// ─── Preference Validation Tests ─────────────────────────────────────────────
test("verification-gate: validatePreferences accepts valid verification keys", () => {
@ -646,7 +581,7 @@ test("formatFailureContext: formats a single failure with command, exit code, st
const result: import("../types.ts").VerificationResult = {
passed: false,
checks: [
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "error: unused var", durationMs: 500, blocking: true },
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "error: unused var", durationMs: 500 },
],
discoverySource: "preference",
timestamp: Date.now(),
@ -663,9 +598,9 @@ test("formatFailureContext: formats multiple failures", () => {
const result: import("../types.ts").VerificationResult = {
passed: false,
checks: [
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint error", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 2, stdout: "", stderr: "test failure", durationMs: 200, blocking: true },
{ command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 50, blocking: true },
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint error", durationMs: 100 },
{ command: "npm run test", exitCode: 2, stdout: "", stderr: "test failure", durationMs: 200 },
{ command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 50 },
],
discoverySource: "preference",
timestamp: Date.now(),
@ -684,7 +619,7 @@ test("formatFailureContext: truncates stderr longer than 2000 chars", () => {
const result: import("../types.ts").VerificationResult = {
passed: false,
checks: [
{ command: "big-err", exitCode: 1, stdout: "", stderr: longStderr, durationMs: 100, blocking: true },
{ command: "big-err", exitCode: 1, stdout: "", stderr: longStderr, durationMs: 100 },
],
discoverySource: "preference",
timestamp: Date.now(),
@ -699,8 +634,8 @@ test("formatFailureContext: returns empty string when all checks pass", () => {
const result: import("../types.ts").VerificationResult = {
passed: true,
checks: [
{ command: "npm run lint", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true },
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 200, blocking: true },
{ command: "npm run lint", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 },
{ command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 200 },
],
discoverySource: "preference",
timestamp: Date.now(),
@ -728,7 +663,6 @@ test("formatFailureContext: caps total output at 10,000 chars", () => {
stdout: "",
stderr: "e".repeat(1000), // 1000 chars each, 20 * ~1050 (with formatting) > 10,000
durationMs: 100,
blocking: true,
});
}
const result: import("../types.ts").VerificationResult = {
@ -1143,131 +1077,3 @@ test("dependency-audit: subdirectory package.json does not trigger audit", () =>
assert.equal(npmAuditCalled, false, "subdirectory dependency files should not trigger audit");
assert.deepStrictEqual(result, []);
});
// ─── Non-Blocking Discovery Tests ────────────────────────────────────────────
test("non-blocking: package-json discovered commands failing → result.passed is still true", () => {
const tmp = makeTempDir("vg-nb-pkg-fail");
try {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { lint: "eslint .", test: "vitest" } }),
);
// These commands will fail because eslint/vitest don't exist in the temp dir
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
// No preference commands — discovery falls through to package.json
});
assert.equal(result.discoverySource, "package-json");
assert.ok(result.checks.length > 0, "should have discovered package.json checks");
assert.equal(result.passed, true, "package-json failures should not block the gate");
for (const check of result.checks) {
assert.equal(check.blocking, false, "package-json checks should be non-blocking");
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("non-blocking: preference commands failing → result.passed is false", () => {
const tmp = makeTempDir("vg-nb-pref-fail");
try {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["sh -c 'exit 1'"],
});
assert.equal(result.discoverySource, "preference");
assert.equal(result.passed, false, "preference failures should block the gate");
assert.equal(result.checks[0].blocking, true, "preference checks should be blocking");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("non-blocking: task-plan commands failing → result.passed is false", () => {
const tmp = makeTempDir("vg-nb-tp-fail");
try {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
taskPlanVerify: "sh -c 'exit 1'",
});
assert.equal(result.discoverySource, "task-plan");
assert.equal(result.passed, false, "task-plan failures should block the gate");
assert.equal(result.checks[0].blocking, true, "task-plan checks should be blocking");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("non-blocking: blocking field is set correctly based on discovery source", () => {
const tmp = makeTempDir("vg-nb-field");
try {
// preference → blocking
const prefResult = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["echo ok"],
});
assert.equal(prefResult.checks[0].blocking, true);
// task-plan → blocking
const tpResult = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
taskPlanVerify: "echo ok",
});
assert.equal(tpResult.checks[0].blocking, true);
// package-json → non-blocking
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { test: "echo ok" } }),
);
const pkgResult = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
});
assert.equal(pkgResult.checks[0].blocking, false);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("non-blocking: formatFailureContext only includes blocking failures", () => {
const result: import("../types.ts").VerificationResult = {
passed: true,
checks: [
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint warning", durationMs: 100, blocking: false },
{ command: "npm run test", exitCode: 1, stdout: "", stderr: "test error", durationMs: 200, blocking: true },
{ command: "npm run typecheck", exitCode: 1, stdout: "", stderr: "type error", durationMs: 50, blocking: false },
],
discoverySource: "preference",
timestamp: Date.now(),
};
const output = formatFailureContext(result);
assert.ok(output.includes("`npm run test`"), "should include blocking failure");
assert.ok(!output.includes("npm run lint"), "should not include non-blocking failure");
assert.ok(!output.includes("npm run typecheck"), "should not include non-blocking failure");
});
test("non-blocking: formatFailureContext returns empty when only non-blocking failures exist", () => {
const result: import("../types.ts").VerificationResult = {
passed: true,
checks: [
{ command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint warning", durationMs: 100, blocking: false },
{ command: "npm run test", exitCode: 1, stdout: "", stderr: "test warning", durationMs: 200, blocking: false },
],
discoverySource: "package-json",
timestamp: Date.now(),
};
assert.equal(formatFailureContext(result), "", "should return empty when only non-blocking failures");
});

View file

@ -0,0 +1,205 @@
/**
* worktree-db-integration.test.ts
*
* Integration tests for the worktree DB copy and reconcile hooks.
* Uses real temp git repos and real SQLite databases.
*
* Test cases:
* 1. Copy: createAutoWorktree seeds .gsd/gsd.db into the worktree when main has one
* 2. Copy-skip: createAutoWorktree silently skips when main has no gsd.db
* 3. Reconcile: reconcileWorktreeDb merges worktree rows into main DB
* 4. Reconcile-skip: reconcileWorktreeDb is non-fatal when both paths are nonexistent
* 5. Failure path: reconcileWorktreeDb emits to stderr on open failure (observable)
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { createAutoWorktree } from "../auto-worktree.ts";
import { worktreePath } from "../worktree-manager.ts";
import {
copyWorktreeDb,
reconcileWorktreeDb,
openDatabase,
closeDatabase,
upsertDecision,
getActiveDecisions,
isDbAvailable,
} from "../gsd-db.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function run(command: string, cwd: string): string {
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
function createTempRepo(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-test-")));
run("git init", dir);
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
run("git add .", dir);
run("git commit -m init", dir);
run("git branch -M main", dir);
return dir;
}
async function main(): Promise<void> {
const savedCwd = process.cwd();
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-")));
tempDirs.push(dir);
return dir;
}
try {
// ─── Test 1: copy on worktree creation ───────────────────────────
console.log("\n=== Test 1: copy on worktree creation ===");
{
const tempDir = createTempRepo();
tempDirs.push(tempDir);
// Seed a gsd.db in the main repo
const gsdDir = join(tempDir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
const mainDbPath = join(gsdDir, "gsd.db");
openDatabase(mainDbPath);
closeDatabase();
// Commit so createAutoWorktree can copy planning artifacts
run("git add .", tempDir);
run('git commit -m "add gsd dir"', tempDir);
// createAutoWorktree should copy the DB into the worktree
const wtPath = createAutoWorktree(tempDir, "M004");
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
assertTrue(
existsSync(worktreeDbPath),
"gsd.db exists in worktree .gsd after createAutoWorktree",
);
// Restore cwd for next test
process.chdir(savedCwd);
}
// ─── Test 2: copy skip when no source DB ─────────────────────────
console.log("\n=== Test 2: copy skip when no source DB ===");
{
const tempDir = createTempRepo();
tempDirs.push(tempDir);
// No gsd.db — just a bare repo
let threw = false;
let wtPath: string | null = null;
try {
wtPath = createAutoWorktree(tempDir, "M004");
} catch (err) {
threw = true;
console.error(" Unexpected throw:", err);
}
assertTrue(!threw, "createAutoWorktree does not throw when no source DB");
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
assertTrue(
!existsSync(worktreeDbPath),
"gsd.db is absent in worktree when source had none",
);
process.chdir(savedCwd);
}
// ─── Test 3: reconcile inserts worktree rows into main ───────────
console.log("\n=== Test 3: reconcile merges worktree rows into main ===");
{
const mainDbPath = join(makeTempDir(), "main.db");
const worktreeDbPath = join(makeTempDir(), "wt.db");
// Seed main DB (empty schema)
openDatabase(mainDbPath);
closeDatabase();
// Seed worktree DB with one decision
openDatabase(worktreeDbPath);
upsertDecision({
id: "D-WT-001",
when_context: "integration test",
scope: "test",
decision: "use reconcile",
choice: "reconcile on merge",
rationale: "test coverage",
revisable: "no",
superseded_by: null,
});
closeDatabase();
// Reconcile worktree → main
const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath);
assertTrue(result.decisions >= 1, "reconcile reports at least 1 decision merged");
// Open main DB and verify the row is present
openDatabase(mainDbPath);
const decisions = getActiveDecisions();
closeDatabase();
const found = decisions.some((d) => d.id === "D-WT-001");
assertTrue(found, "worktree decision D-WT-001 present in main DB after reconcile");
}
// ─── Test 4: reconcile non-fatal when both paths nonexistent ─────
console.log("\n=== Test 4: reconcile non-fatal on nonexistent paths ===");
{
let threw = false;
try {
reconcileWorktreeDb("/nonexistent/path/gsd.db", "/also/nonexistent/gsd.db");
} catch {
threw = true;
}
assertTrue(!threw, "reconcileWorktreeDb does not throw when worktree DB is absent");
}
// ─── Test 5: failure path observable via stderr (diagnostic) ─────
// reconcileWorktreeDb emits to stderr on reconciliation failures.
// We can't easily intercept stderr in this test harness, but we verify
// that the function returns the zero-result shape (not undefined/throws)
// when the worktree DB is missing — confirming the failure path is non-fatal
// and returns a structured result.
console.log("\n=== Test 5: reconcile returns zero-shape when worktree DB absent ===");
{
const mainDbPath = join(makeTempDir(), "main2.db");
openDatabase(mainDbPath);
closeDatabase();
const result = reconcileWorktreeDb(mainDbPath, "/definitely/does/not/exist.db");
assertEq(result.decisions, 0, "decisions is 0 when worktree DB absent");
assertEq(result.requirements, 0, "requirements is 0 when worktree DB absent");
assertEq(result.artifacts, 0, "artifacts is 0 when worktree DB absent");
assertEq(result.conflicts.length, 0, "conflicts is empty when worktree DB absent");
}
} finally {
// Always restore cwd
process.chdir(savedCwd);
// Ensure DB is closed
if (isDbAvailable()) closeDatabase();
// Remove all temp dirs
for (const dir of tempDirs) {
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true });
}
}
}
report();
}
main();

View file

@ -0,0 +1,442 @@
import { createTestContext } from './test-helpers.ts';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
openDatabase,
closeDatabase,
isDbAvailable,
insertDecision,
insertRequirement,
insertArtifact,
getDecisionById,
getRequirementById,
_getAdapter,
copyWorktreeDb,
reconcileWorktreeDb,
} from '../gsd-db.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
function tempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-test-'));
}
function cleanup(...dirs: string[]): void {
closeDatabase();
for (const dir of dirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// best effort
}
}
}
function seedMainDb(dbPath: string): void {
openDatabase(dbPath);
insertDecision({
id: 'D001',
when_context: '2025-01-01',
scope: 'M001/S01',
decision: 'Use SQLite',
choice: 'node:sqlite',
rationale: 'Built-in',
revisable: 'yes',
superseded_by: null,
});
insertRequirement({
id: 'R001',
class: 'functional',
status: 'active',
description: 'Must store decisions',
why: 'Core feature',
source: 'design',
primary_owner: 'S01',
supporting_slices: '',
validation: 'test',
notes: '',
full_content: 'Full requirement text',
superseded_by: null,
});
insertArtifact({
path: 'docs/arch.md',
artifact_type: 'plan',
milestone_id: 'M001',
slice_id: null,
task_id: null,
full_content: 'Architecture document',
});
}
// ═══════════════════════════════════════════════════════════════════════════
// copyWorktreeDb tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== worktree-db: copyWorktreeDb ===');
// Test: copies DB file and data is queryable
{
const srcDir = tempDir();
const destDir = tempDir();
const srcDb = path.join(srcDir, 'gsd.db');
const destDb = path.join(destDir, 'nested', 'gsd.db');
seedMainDb(srcDb);
closeDatabase();
const result = copyWorktreeDb(srcDb, destDb);
assertTrue(result === true, 'copyWorktreeDb returns true on success');
assertTrue(fs.existsSync(destDb), 'dest DB file exists after copy');
// Open the copy and verify data is queryable
openDatabase(destDb);
const d = getDecisionById('D001');
assertTrue(d !== null, 'decision queryable in copied DB');
assertEq(d?.choice, 'node:sqlite', 'decision data preserved in copy');
const r = getRequirementById('R001');
assertTrue(r !== null, 'requirement queryable in copied DB');
assertEq(r?.description, 'Must store decisions', 'requirement data preserved in copy');
cleanup(srcDir, destDir);
}
// Test: skips -wal and -shm files
{
const srcDir = tempDir();
const destDir = tempDir();
const srcDb = path.join(srcDir, 'gsd.db');
const destDb = path.join(destDir, 'gsd.db');
seedMainDb(srcDb);
closeDatabase();
// Create fake WAL/SHM files
fs.writeFileSync(srcDb + '-wal', 'fake wal data');
fs.writeFileSync(srcDb + '-shm', 'fake shm data');
copyWorktreeDb(srcDb, destDb);
assertTrue(fs.existsSync(destDb), 'DB file copied');
assertTrue(!fs.existsSync(destDb + '-wal'), 'WAL file NOT copied');
assertTrue(!fs.existsSync(destDb + '-shm'), 'SHM file NOT copied');
cleanup(srcDir, destDir);
}
// Test: returns false when source doesn't exist (no throw)
{
const destDir = tempDir();
const result = copyWorktreeDb('/nonexistent/path/gsd.db', path.join(destDir, 'gsd.db'));
assertEq(result, false, 'returns false for missing source');
cleanup(destDir);
}
// Test: creates dest directory if needed
{
const srcDir = tempDir();
const destDir = tempDir();
const srcDb = path.join(srcDir, 'gsd.db');
const deepDest = path.join(destDir, 'a', 'b', 'c', 'gsd.db');
seedMainDb(srcDb);
closeDatabase();
const result = copyWorktreeDb(srcDb, deepDest);
assertTrue(result === true, 'copyWorktreeDb succeeds with nested dest');
assertTrue(fs.existsSync(deepDest), 'DB file created at deeply nested path');
cleanup(srcDir, destDir);
}
// ═══════════════════════════════════════════════════════════════════════════
// reconcileWorktreeDb tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== worktree-db: reconcileWorktreeDb ===');
// Test: merges new decisions from worktree into main
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
// Seed main with D001
seedMainDb(mainDb);
closeDatabase();
// Copy to worktree, add D002 in worktree
copyWorktreeDb(mainDb, wtDb);
openDatabase(wtDb);
insertDecision({
id: 'D002',
when_context: '2025-02-01',
scope: 'M001/S02',
decision: 'Use WAL mode',
choice: 'WAL',
rationale: 'Performance',
revisable: 'yes',
superseded_by: null,
});
closeDatabase();
// Re-open main and reconcile
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.decisions > 0, 'decisions merged count > 0');
const d2 = getDecisionById('D002');
assertTrue(d2 !== null, 'D002 from worktree now in main');
assertEq(d2?.choice, 'WAL', 'D002 data correct after merge');
cleanup(mainDir, wtDir);
}
// Test: merges new requirements from worktree into main
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
openDatabase(wtDb);
insertRequirement({
id: 'R002',
class: 'non-functional',
status: 'active',
description: 'Must be fast',
why: 'UX',
source: 'design',
primary_owner: 'S02',
supporting_slices: '',
validation: 'benchmark',
notes: '',
full_content: 'Performance requirement',
superseded_by: null,
});
closeDatabase();
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.requirements > 0, 'requirements merged count > 0');
const r2 = getRequirementById('R002');
assertTrue(r2 !== null, 'R002 from worktree now in main');
assertEq(r2?.description, 'Must be fast', 'R002 data correct after merge');
cleanup(mainDir, wtDir);
}
// Test: merges new artifacts from worktree into main
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
openDatabase(wtDb);
insertArtifact({
path: 'docs/api.md',
artifact_type: 'reference',
milestone_id: 'M001',
slice_id: 'S01',
task_id: 'T01',
full_content: 'API documentation',
});
closeDatabase();
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.artifacts > 0, 'artifacts merged count > 0');
const adapter = _getAdapter()!;
const row = adapter.prepare('SELECT * FROM artifacts WHERE path = ?').get('docs/api.md');
assertTrue(row !== null, 'artifact from worktree now in main');
assertEq(row?.['artifact_type'], 'reference', 'artifact data correct after merge');
cleanup(mainDir, wtDir);
}
// Test: detects conflicts (same PK, different content in both DBs)
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
// Seed main with D001
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
// Modify D001 in main
openDatabase(mainDb);
const mainAdapter = _getAdapter()!;
mainAdapter.prepare(
`UPDATE decisions SET choice = 'better-sqlite3' WHERE id = 'D001'`,
).run();
closeDatabase();
// Modify D001 in worktree differently
openDatabase(wtDb);
const wtAdapter = _getAdapter()!;
wtAdapter.prepare(
`UPDATE decisions SET choice = 'sql.js' WHERE id = 'D001'`,
).run();
closeDatabase();
// Reconcile
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.conflicts.length > 0, 'conflicts detected');
assertTrue(
result.conflicts.some(c => c.includes('D001')),
'conflict mentions D001',
);
// Worktree-wins: D001 should now have worktree's value
const d1 = getDecisionById('D001');
assertEq(d1?.choice, 'sql.js', 'worktree wins on conflict (INSERT OR REPLACE)');
cleanup(mainDir, wtDir);
}
// Test: handles missing worktree DB gracefully
{
const mainDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
seedMainDb(mainDb);
const result = reconcileWorktreeDb(mainDb, '/nonexistent/worktree.db');
assertEq(result.decisions, 0, 'no decisions merged for missing worktree DB');
assertEq(result.requirements, 0, 'no requirements merged for missing worktree DB');
assertEq(result.artifacts, 0, 'no artifacts merged for missing worktree DB');
assertEq(result.conflicts.length, 0, 'no conflicts for missing worktree DB');
cleanup(mainDir);
}
// Test: path with spaces works
{
const baseDir = tempDir();
const mainDir = path.join(baseDir, 'main dir');
const wtDir = path.join(baseDir, 'worktree dir');
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(wtDir, { recursive: true });
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
// Add a decision in worktree
openDatabase(wtDb);
insertDecision({
id: 'D003',
when_context: '2025-03-01',
scope: 'M001/S03',
decision: 'Path spaces test',
choice: 'yes',
rationale: 'Robustness',
revisable: 'no',
superseded_by: null,
});
closeDatabase();
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.decisions > 0, 'reconciliation works with spaces in path');
const d3 = getDecisionById('D003');
assertTrue(d3 !== null, 'D003 merged from worktree with spaces in path');
cleanup(baseDir);
}
// Test: main DB is usable after reconciliation (DETACH cleanup verified)
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
openDatabase(mainDb);
reconcileWorktreeDb(mainDb, wtDb);
// Verify main DB is still fully usable after DETACH
assertTrue(isDbAvailable(), 'DB still available after reconciliation');
insertDecision({
id: 'D099',
when_context: '2025-12-01',
scope: 'test',
decision: 'Post-reconcile insert',
choice: 'works',
rationale: 'Verify DETACH cleanup',
revisable: 'no',
superseded_by: null,
});
const d99 = getDecisionById('D099');
assertTrue(d99 !== null, 'can insert and query after reconciliation');
assertEq(d99?.choice, 'works', 'post-reconcile data correct');
// Verify no "wt" database still attached
const adapter = _getAdapter()!;
let wtAccessible = false;
try {
adapter.prepare('SELECT count(*) FROM wt.decisions').get();
wtAccessible = true;
} catch {
// Expected — wt should be detached
}
assertTrue(!wtAccessible, 'wt database is detached after reconciliation');
cleanup(mainDir, wtDir);
}
// Test: reconcile with empty worktree DB (no new rows, no conflicts)
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
// Don't modify the worktree DB at all — reconcile the identical copy
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
// Should still report counts for the existing rows (INSERT OR REPLACE touches them)
assertTrue(result.conflicts.length === 0, 'no conflicts when DBs are identical');
assertTrue(isDbAvailable(), 'DB usable after no-change reconciliation');
cleanup(mainDir, wtDir);
}
// ─── Final Report ──────────────────────────────────────────────────────────
report();

View file

@ -38,9 +38,6 @@ function createTempRepo(): string {
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
// Mirror production: .gsd/worktrees/ is gitignored so autoCommitDirtyState
// doesn't pick up the worktrees directory as dirty state (#1127 fix).
writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n");
mkdirSync(join(dir, ".gsd"), { recursive: true });
writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
run("git add .", dir);

View file

@ -0,0 +1,705 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
WorktreeResolver,
type WorktreeResolverDeps,
type NotifyCtx,
} from "../worktree-resolver.js";
import { AutoSession } from "../auto/session.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Track calls to mock deps for assertion. */
interface CallLog {
fn: string;
args: unknown[];
}
function makeSession(
overrides?: Partial<{ basePath: string; originalBasePath: string }>,
): AutoSession {
const s = new AutoSession();
s.basePath = overrides?.basePath ?? "/project";
s.originalBasePath = overrides?.originalBasePath ?? "/project";
return s;
}
function makeDeps(
overrides?: Partial<WorktreeResolverDeps>,
): WorktreeResolverDeps & { calls: CallLog[] } {
const calls: CallLog[] = [];
const deps: WorktreeResolverDeps & { calls: CallLog[] } = {
calls,
isInAutoWorktree: (basePath: string) => {
calls.push({ fn: "isInAutoWorktree", args: [basePath] });
return false;
},
shouldUseWorktreeIsolation: () => {
calls.push({ fn: "shouldUseWorktreeIsolation", args: [] });
return true;
},
getIsolationMode: () => {
calls.push({ fn: "getIsolationMode", args: [] });
return "worktree";
},
mergeMilestoneToMain: (
basePath: string,
milestoneId: string,
roadmapContent: string,
) => {
calls.push({
fn: "mergeMilestoneToMain",
args: [basePath, milestoneId, roadmapContent],
});
return { pushed: false };
},
syncWorktreeStateBack: (
mainBasePath: string,
worktreePath: string,
milestoneId: string,
) => {
calls.push({
fn: "syncWorktreeStateBack",
args: [mainBasePath, worktreePath, milestoneId],
});
return { synced: [] };
},
teardownAutoWorktree: (
basePath: string,
milestoneId: string,
opts?: { preserveBranch?: boolean },
) => {
calls.push({
fn: "teardownAutoWorktree",
args: [basePath, milestoneId, opts],
});
},
createAutoWorktree: (basePath: string, milestoneId: string) => {
calls.push({ fn: "createAutoWorktree", args: [basePath, milestoneId] });
return `/project/.gsd/worktrees/${milestoneId}`;
},
enterAutoWorktree: (basePath: string, milestoneId: string) => {
calls.push({ fn: "enterAutoWorktree", args: [basePath, milestoneId] });
return `/project/.gsd/worktrees/${milestoneId}`;
},
getAutoWorktreePath: (basePath: string, milestoneId: string) => {
calls.push({ fn: "getAutoWorktreePath", args: [basePath, milestoneId] });
return null;
},
autoCommitCurrentBranch: (
basePath: string,
reason: string,
milestoneId: string,
) => {
calls.push({
fn: "autoCommitCurrentBranch",
args: [basePath, reason, milestoneId],
});
},
getCurrentBranch: (basePath: string) => {
calls.push({ fn: "getCurrentBranch", args: [basePath] });
return "main";
},
autoWorktreeBranch: (milestoneId: string) => {
calls.push({ fn: "autoWorktreeBranch", args: [milestoneId] });
return `milestone/${milestoneId}`;
},
resolveMilestoneFile: (
basePath: string,
milestoneId: string,
fileType: string,
) => {
calls.push({
fn: "resolveMilestoneFile",
args: [basePath, milestoneId, fileType],
});
return `/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`;
},
readFileSync: (path: string, _encoding: string) => {
calls.push({ fn: "readFileSync", args: [path] });
return "# Roadmap\n- [x] S01: Slice one\n";
},
GitServiceImpl: class MockGitServiceImpl {
basePath: string;
gitConfig: unknown;
constructor(basePath: string, gitConfig: unknown) {
calls.push({ fn: "GitServiceImpl", args: [basePath, gitConfig] });
this.basePath = basePath;
this.gitConfig = gitConfig;
}
} as unknown as WorktreeResolverDeps["GitServiceImpl"],
loadEffectiveGSDPreferences: () => {
calls.push({ fn: "loadEffectiveGSDPreferences", args: [] });
return { preferences: { git: {} } };
},
invalidateAllCaches: () => {
calls.push({ fn: "invalidateAllCaches", args: [] });
},
captureIntegrationBranch: (
basePath: string,
mid: string | undefined,
opts?: { commitDocs?: boolean },
) => {
calls.push({
fn: "captureIntegrationBranch",
args: [basePath, mid, opts],
});
},
...overrides,
};
// Re-apply overrides that add the call tracking
if (overrides) {
for (const [key, val] of Object.entries(overrides)) {
if (key !== "calls") {
(deps as unknown as Record<string, unknown>)[key] = val;
}
}
}
return deps;
}
function makeNotifyCtx(): NotifyCtx & {
messages: Array<{ msg: string; level?: string }>;
} {
const messages: Array<{ msg: string; level?: string }> = [];
return {
messages,
notify: (msg: string, level?: "info" | "warning" | "error" | "success") => {
messages.push({ msg, level });
},
};
}
function findCalls(calls: CallLog[], fn: string): CallLog[] {
return calls.filter((c) => c.fn === fn);
}
// ─── Getter Tests ────────────────────────────────────────────────────────────
test("workPath returns s.basePath", () => {
const s = makeSession({ basePath: "/project/.gsd/worktrees/M001" });
const resolver = new WorktreeResolver(s, makeDeps());
assert.equal(resolver.workPath, "/project/.gsd/worktrees/M001");
});
test("projectRoot returns originalBasePath when set", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const resolver = new WorktreeResolver(s, makeDeps());
assert.equal(resolver.projectRoot, "/project");
});
test("projectRoot falls back to basePath when originalBasePath is empty", () => {
const s = makeSession({ basePath: "/project", originalBasePath: "" });
const resolver = new WorktreeResolver(s, makeDeps());
assert.equal(resolver.projectRoot, "/project");
});
test("lockPath returns originalBasePath when set (same as lockBase)", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const resolver = new WorktreeResolver(s, makeDeps());
assert.equal(resolver.lockPath, "/project");
});
test("lockPath falls back to basePath when originalBasePath is empty", () => {
const s = makeSession({ basePath: "/project", originalBasePath: "" });
const resolver = new WorktreeResolver(s, makeDeps());
assert.equal(resolver.lockPath, "/project");
});
// ─── enterMilestone Tests ────────────────────────────────────────────────────
test("enterMilestone creates new worktree when none exists", () => {
const s = makeSession();
const deps = makeDeps({
getAutoWorktreePath: () => null,
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", ctx);
assert.equal(s.basePath, "/project/.gsd/worktrees/M001");
assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 1);
assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 0);
assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1);
assert.ok(
ctx.messages.some(
(m) => m.level === "info" && m.msg.includes("Entered worktree"),
),
);
});
test("enterMilestone enters existing worktree instead of creating", () => {
const s = makeSession();
const deps = makeDeps({
getAutoWorktreePath: () => "/project/.gsd/worktrees/M001",
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", ctx);
assert.equal(s.basePath, "/project/.gsd/worktrees/M001");
assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 1);
assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 0);
});
test("enterMilestone is no-op when shouldUseWorktreeIsolation is false", () => {
const s = makeSession();
const deps = makeDeps({
shouldUseWorktreeIsolation: () => false,
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", ctx);
assert.equal(s.basePath, "/project"); // unchanged
assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 0);
assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 0);
});
test("enterMilestone does NOT update basePath on creation failure", () => {
const s = makeSession();
const deps = makeDeps({
getAutoWorktreePath: () => null,
createAutoWorktree: () => {
throw new Error("disk full");
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", ctx);
assert.equal(s.basePath, "/project"); // unchanged — error recovery
assert.ok(
ctx.messages.some(
(m) => m.level === "warning" && m.msg.includes("disk full"),
),
);
});
test("enterMilestone uses originalBasePath as base for worktree ops", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
let createdFrom = "";
const deps = makeDeps({
getAutoWorktreePath: () => null,
createAutoWorktree: (basePath: string, _mid: string) => {
createdFrom = basePath;
return "/project/.gsd/worktrees/M002";
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M002", ctx);
assert.equal(createdFrom, "/project"); // uses originalBasePath, not current basePath
});
// ─── exitMilestone Tests ─────────────────────────────────────────────────────
test("exitMilestone commits, tears down, and resets basePath", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: () => true,
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.exitMilestone("M001", ctx);
assert.equal(s.basePath, "/project"); // reset to originalBasePath
assert.equal(findCalls(deps.calls, "autoCommitCurrentBranch").length, 1);
assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 1);
assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); // rebuilt
assert.equal(findCalls(deps.calls, "invalidateAllCaches").length, 1);
});
test("exitMilestone is no-op when not in worktree", () => {
const s = makeSession();
const deps = makeDeps({
isInAutoWorktree: () => false,
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.exitMilestone("M001", ctx);
assert.equal(s.basePath, "/project"); // unchanged
assert.equal(findCalls(deps.calls, "autoCommitCurrentBranch").length, 0);
assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0);
});
test("exitMilestone passes preserveBranch option", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
let preserveOpts: unknown = null;
const deps = makeDeps({
isInAutoWorktree: () => true,
teardownAutoWorktree: (
_basePath: string,
_mid: string,
opts?: { preserveBranch?: boolean },
) => {
preserveOpts = opts;
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.exitMilestone("M001", ctx, { preserveBranch: true });
assert.deepEqual(preserveOpts, { preserveBranch: true });
});
test("exitMilestone still resets basePath even if auto-commit fails", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: () => true,
autoCommitCurrentBranch: () => {
throw new Error("commit error");
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.exitMilestone("M001", ctx);
// Should still complete: reset basePath, rebuild git service
assert.equal(s.basePath, "/project");
assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1);
});
// ─── mergeAndExit Tests (worktree mode) ──────────────────────────────────────
test("mergeAndExit in worktree mode reads roadmap and merges", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "syncWorktreeStateBack").length, 1);
assert.equal(findCalls(deps.calls, "resolveMilestoneFile").length, 1);
assert.equal(findCalls(deps.calls, "readFileSync").length, 1);
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1);
assert.equal(s.basePath, "/project"); // restored
assert.ok(ctx.messages.some((m) => m.msg.includes("merged to main")));
});
test("mergeAndExit in worktree mode shows pushed status", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
mergeMilestoneToMain: () => ({ pushed: true }),
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.ok(ctx.messages.some((m) => m.msg.includes("Pushed to remote")));
});
test("mergeAndExit falls back to teardown when roadmap is missing", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
resolveMilestoneFile: () => null,
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 1);
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
assert.equal(s.basePath, "/project"); // restored
assert.ok(ctx.messages.some((m) => m.msg.includes("no roadmap for merge")));
});
test("mergeAndExit in worktree mode restores to project root on merge failure", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
mergeMilestoneToMain: () => {
throw new Error("conflict in main");
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(s.basePath, "/project"); // error recovery — restored
assert.ok(
ctx.messages.some(
(m) => m.level === "warning" && m.msg.includes("conflict in main"),
),
);
assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); // rebuilt after recovery
});
// ─── mergeAndExit Tests (branch mode) ────────────────────────────────────────
test("mergeAndExit in branch mode merges when on milestone branch", () => {
const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
const deps = makeDeps({
isInAutoWorktree: () => false,
getIsolationMode: () => "branch",
getCurrentBranch: () => "milestone/M001",
autoWorktreeBranch: () => "milestone/M001",
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1);
assert.ok(ctx.messages.some((m) => m.msg.includes("branch mode")));
});
test("mergeAndExit in branch mode skips when not on milestone branch", () => {
const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
const deps = makeDeps({
isInAutoWorktree: () => false,
getIsolationMode: () => "branch",
getCurrentBranch: () => "main",
autoWorktreeBranch: () => "milestone/M001",
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
assert.equal(ctx.messages.length, 0);
});
test("mergeAndExit in branch mode handles merge failure gracefully", () => {
const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
const deps = makeDeps({
isInAutoWorktree: () => false,
getIsolationMode: () => "branch",
getCurrentBranch: () => "milestone/M001",
autoWorktreeBranch: () => "milestone/M001",
mergeMilestoneToMain: () => {
throw new Error("branch merge conflict");
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.ok(
ctx.messages.some(
(m) => m.level === "warning" && m.msg.includes("branch merge conflict"),
),
);
});
test("mergeAndExit in branch mode skips when no roadmap", () => {
const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
const deps = makeDeps({
isInAutoWorktree: () => false,
getIsolationMode: () => "branch",
getCurrentBranch: () => "milestone/M001",
autoWorktreeBranch: () => "milestone/M001",
resolveMilestoneFile: () => null,
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
});
test("mergeAndExit in branch mode rebuilds GitService after merge", () => {
const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
const deps = makeDeps({
isInAutoWorktree: () => false,
getIsolationMode: () => "branch",
getCurrentBranch: () => "milestone/M001",
autoWorktreeBranch: () => "milestone/M001",
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1);
});
// ─── mergeAndExit Tests (none mode) ──────────────────────────────────────────
test("mergeAndExit in none mode is a no-op", () => {
const s = makeSession();
const deps = makeDeps({
getIsolationMode: () => "none",
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0);
assert.equal(ctx.messages.length, 0);
});
// ─── mergeAndEnterNext Tests ─────────────────────────────────────────────────
test("mergeAndEnterNext calls mergeAndExit then enterMilestone", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const callOrder: string[] = [];
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
shouldUseWorktreeIsolation: () => true,
mergeMilestoneToMain: (
basePath: string,
milestoneId: string,
_roadmap: string,
) => {
callOrder.push(`merge:${milestoneId}`);
return { pushed: false };
},
getAutoWorktreePath: () => null,
createAutoWorktree: (basePath: string, milestoneId: string) => {
callOrder.push(`create:${milestoneId}`);
return `/project/.gsd/worktrees/${milestoneId}`;
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndEnterNext("M001", "M002", ctx);
assert.deepEqual(callOrder, ["merge:M001", "create:M002"]);
assert.equal(s.basePath, "/project/.gsd/worktrees/M002");
});
test("mergeAndEnterNext enters next milestone even if merge fails", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: (basePath: string) => basePath.includes("worktrees"),
getIsolationMode: () => "worktree",
shouldUseWorktreeIsolation: () => true,
mergeMilestoneToMain: () => {
throw new Error("merge failed");
},
getAutoWorktreePath: () => null,
createAutoWorktree: (_basePath: string, milestoneId: string) => {
return `/project/.gsd/worktrees/${milestoneId}`;
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndEnterNext("M001", "M002", ctx);
// Merge failed but enter should still happen
assert.equal(s.basePath, "/project/.gsd/worktrees/M002");
assert.ok(
ctx.messages.some(
(m) => m.level === "warning" && m.msg.includes("merge failed"),
),
);
assert.ok(
ctx.messages.some(
(m) => m.level === "info" && m.msg.includes("Entered worktree"),
),
);
});
// ─── GitService Rebuild Atomicity ────────────────────────────────────────────
test("GitService is rebuilt with the NEW basePath after enterMilestone", () => {
const s = makeSession();
let gitServiceBasePath = "";
const deps = makeDeps({
getAutoWorktreePath: () => null,
GitServiceImpl: class {
constructor(basePath: string, _config: unknown) {
gitServiceBasePath = basePath;
}
} as unknown as WorktreeResolverDeps["GitServiceImpl"],
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", ctx);
assert.equal(gitServiceBasePath, "/project/.gsd/worktrees/M001"); // new path, not old
});
test("GitService is rebuilt with originalBasePath after exitMilestone", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
let gitServiceBasePath = "";
const deps = makeDeps({
isInAutoWorktree: () => true,
GitServiceImpl: class {
constructor(basePath: string, _config: unknown) {
gitServiceBasePath = basePath;
}
} as unknown as WorktreeResolverDeps["GitServiceImpl"],
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.exitMilestone("M001", ctx);
assert.equal(gitServiceBasePath, "/project"); // project root, not worktree
});

View file

@ -1,26 +1,27 @@
/**
* worktree-sync-milestones.test.ts Regression test for #1311.
*
* Verifies that syncGsdStateToWorktree copies missing milestones,
* milestone files, and slice directories from the main repo's .gsd/
* into the worktree's .gsd/.
* Verifies that syncProjectRootToWorktree copies milestone artifacts
* from the main repo's .gsd/ into the worktree's .gsd/ for the
* specified milestone, and deletes gsd.db so it rebuilds from fresh state.
*
* Covers:
* - Entirely missing milestone directory
* - Milestone exists but missing CONTEXT/ROADMAP files
* - Missing slices within an existing milestone
* - No-op when directories are identical (symlinked)
* - Root-level files (DECISIONS, REQUIREMENTS, etc.)
* - Milestone directory synced from main to worktree
* - Missing slices within a milestone are synced
* - gsd.db deleted in worktree after sync
* - No-op when paths are equal
* - No-op when milestoneId is null
* - Non-existent directories handled gracefully
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, symlinkSync, realpathSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { syncGsdStateToWorktree } from '../auto-worktree.ts';
import { syncProjectRootToWorktree } from '../auto-worktree-sync.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
const { assertTrue, report } = createTestContext();
function createBase(name: string): string {
const base = mkdtempSync(join(tmpdir(), `gsd-wt-sync-${name}-`));
@ -34,156 +35,106 @@ function cleanup(base: string): void {
async function main(): Promise<void> {
// ─── 1. Missing milestone directory is synced ─────────────────────────
console.log('\n=== 1. missing milestone directory is copied from main ===');
// ─── 1. Milestone directory synced from main to worktree ──────────────
console.log('\n=== 1. milestone directory synced from main to worktree ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');
try {
// Main repo has M001 and M002
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
mkdirSync(m001Dir, { recursive: true });
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001\nDone.');
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001\nContext.');
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
const m002Dir = join(mainBase, '.gsd', 'milestones', 'M002');
mkdirSync(m002Dir, { recursive: true });
writeFileSync(join(m002Dir, 'M002-CONTEXT.md'), '# M002\nNew milestone.');
writeFileSync(join(m002Dir, 'M002-ROADMAP.md'), '# Roadmap');
// Worktree has no M001
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), 'M001 missing before sync');
// Worktree only has M001
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
mkdirSync(wtM001Dir, { recursive: true });
writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001\nDone.');
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
// M002 is missing from worktree
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), 'M002 missing before sync');
const result = syncGsdStateToWorktree(mainBase, wtBase);
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), '#1311: M002 synced to worktree');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-CONTEXT.md')), 'M002 CONTEXT synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-ROADMAP.md')), 'M002 ROADMAP synced');
assertTrue(result.synced.length > 0, 'sync reported files');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), '#1311: M001 synced to worktree');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md')), 'M001 CONTEXT synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'M001 ROADMAP synced');
} finally {
cleanup(mainBase);
cleanup(wtBase);
}
}
// ─── 2. Missing files within existing milestone ───────────────────────
console.log('\n=== 2. missing files within existing milestone are synced ===');
// ─── 2. Missing slices synced ──────────────────────────────────────────
console.log('\n=== 2. missing slices within milestone are synced ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');
try {
// Main repo M001 has CONTEXT, ROADMAP, RESEARCH
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
mkdirSync(m001Dir, { recursive: true });
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001 Context');
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# M001 Roadmap');
writeFileSync(join(m001Dir, 'M001-RESEARCH.md'), '# M001 Research');
// Worktree M001 only has CONTEXT (stale snapshot)
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
mkdirSync(wtM001Dir, { recursive: true });
writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001 Context');
const result = syncGsdStateToWorktree(mainBase, wtBase);
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'ROADMAP synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-RESEARCH.md')), 'RESEARCH synced');
// Existing file should NOT be overwritten
assertEq(
readFileSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'), 'utf-8'),
'# M001 Context',
'existing CONTEXT not overwritten',
);
} finally {
cleanup(mainBase);
cleanup(wtBase);
}
}
// ─── 3. Missing slices directory synced ───────────────────────────────
console.log('\n=== 3. missing slices directory synced ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');
try {
// Main repo has M001 with slices S01S03
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
mkdirSync(join(m001Dir, 'slices', 'S01'), { recursive: true });
mkdirSync(join(m001Dir, 'slices', 'S02'), { recursive: true });
mkdirSync(join(m001Dir, 'slices', 'S03'), { recursive: true });
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
writeFileSync(join(m001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan');
writeFileSync(join(m001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan');
writeFileSync(join(m001Dir, 'slices', 'S03', 'S03-PLAN.md'), '# S03 Plan');
// Worktree M001 has slices S01S02 only (S03 missing)
// Worktree only has S01
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
mkdirSync(join(wtM001Dir, 'slices', 'S01'), { recursive: true });
mkdirSync(join(wtM001Dir, 'slices', 'S02'), { recursive: true });
writeFileSync(join(wtM001Dir, 'M001-ROADMAP.md'), '# Roadmap');
writeFileSync(join(wtM001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan');
writeFileSync(join(wtM001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan');
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), 'S03 missing before sync');
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
syncGsdStateToWorktree(mainBase, wtBase);
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), '#1311: S03 synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03', 'S03-PLAN.md')), 'S03 PLAN synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02')), '#1311: S02 synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md')), 'S02 PLAN synced');
} finally {
cleanup(mainBase);
cleanup(wtBase);
}
}
// ─── 4. No-op when both resolve to same directory (symlink) ───────────
console.log('\n=== 4. no-op when .gsd/ resolves to same path (symlinked) ===');
{
const sharedDir = createBase('shared');
const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-main-'));
const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-wt-'));
try {
// Both main and worktree symlink to the same shared directory
writeFileSync(join(sharedDir, '.gsd', 'milestones', 'keep'), '');
symlinkSync(join(sharedDir, '.gsd'), join(mainBase, '.gsd'));
symlinkSync(join(sharedDir, '.gsd'), join(wtBase, '.gsd'));
const result = syncGsdStateToWorktree(mainBase, wtBase);
assertEq(result.synced.length, 0, 'no files synced when both point to same dir');
} finally {
cleanup(sharedDir);
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
// ─── 5. Root-level .gsd/ files synced ─────────────────────────────────
console.log('\n=== 5. root-level .gsd/ files synced ===');
// ─── 3. gsd.db deleted in worktree after sync ─────────────────────────
console.log('\n=== 3. gsd.db deleted in worktree after sync ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');
try {
writeFileSync(join(mainBase, '.gsd', 'DECISIONS.md'), '# Decisions');
writeFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements');
writeFileSync(join(mainBase, '.gsd', 'PROJECT.md'), '# Project');
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
mkdirSync(m001Dir, { recursive: true });
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
// Worktree has none of these
const result = syncGsdStateToWorktree(mainBase, wtBase);
// Worktree has a stale gsd.db
writeFileSync(join(wtBase, '.gsd', 'gsd.db'), 'stale data');
assertTrue(existsSync(join(wtBase, '.gsd', 'gsd.db')), 'gsd.db exists before sync');
assertTrue(existsSync(join(wtBase, '.gsd', 'DECISIONS.md')), 'DECISIONS.md synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'REQUIREMENTS.md')), 'REQUIREMENTS.md synced');
assertTrue(existsSync(join(wtBase, '.gsd', 'PROJECT.md')), 'PROJECT.md synced');
assertTrue(result.synced.length >= 3, 'at least 3 files synced');
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
assertTrue(!existsSync(join(wtBase, '.gsd', 'gsd.db')), '#853: gsd.db deleted after sync');
} finally {
cleanup(mainBase);
cleanup(wtBase);
}
}
// ─── 4. No-op when paths are equal ────────────────────────────────────
console.log('\n=== 4. no-op when paths are equal ===');
{
const base = createBase('same');
try {
// Should not throw
syncProjectRootToWorktree(base, base, 'M001');
assertTrue(true, 'no crash when paths are equal');
} finally {
cleanup(base);
}
}
// ─── 5. No-op when milestoneId is null ────────────────────────────────
console.log('\n=== 5. no-op when milestoneId is null ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');
try {
syncProjectRootToWorktree(mainBase, wtBase, null);
assertTrue(true, 'no crash when milestoneId is null');
} finally {
cleanup(mainBase);
cleanup(wtBase);
@ -193,8 +144,8 @@ async function main(): Promise<void> {
// ─── 6. Non-existent directories handled gracefully ───────────────────
console.log('\n=== 6. non-existent directories → no-op ===');
{
const result = syncGsdStateToWorktree('/tmp/does-not-exist-main', '/tmp/does-not-exist-wt');
assertEq(result.synced.length, 0, 'no crash on missing directories');
syncProjectRootToWorktree('/tmp/does-not-exist-main', '/tmp/does-not-exist-wt', 'M001');
assertTrue(true, 'no crash on missing directories');
}
report();

View file

@ -104,11 +104,15 @@ async function main(): Promise<void> {
run("git checkout -b f-123-thing", repo);
assertEq(getCurrentBranch(repo), "f-123-thing", "on feature branch");
const commitsBefore = run("git rev-list --count HEAD", repo);
captureIntegrationBranch(repo, "M001");
assertEq(readIntegrationBranch(repo, "M001"), "f-123-thing",
"captureIntegrationBranch records the current branch");
// .gsd/ metadata is written to disk only (not committed) since commit_docs removal
// Metadata is stored in external state, not committed to git.
const commitsAfter = run("git rev-list --count HEAD", repo);
assertEq(commitsAfter, commitsBefore, "captureIntegrationBranch does not create a git commit");
rmSync(repo, { recursive: true, force: true });
}

View file

@ -1,31 +1,19 @@
/**
* Unit tests for the CONTEXT.md write-gate.
* Unit tests for the CONTEXT.md write-gate (D031 guard chain).
*
* Exercises shouldBlockContextWrite() a pure function that implements:
* (a) toolName !== "write" pass
* (b) milestoneId null AND no queue phase pass (not in any flow)
* (b) milestoneId null pass (not in discussion)
* (c) path doesn't match /M\d+-CONTEXT\.md$/ pass
* (d) depthVerified pass (backward compat for discussion flows)
* (e) queuePhaseActive + per-milestone verified pass
* (f) queuePhaseActive + not verified block
* (g) else block with actionable reason
*
* Also exercises per-milestone verification helpers:
* markDepthVerified(), isDepthVerifiedFor()
* (d) depthVerified pass
* (e) else block with actionable reason
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
shouldBlockContextWrite,
markDepthVerified,
isDepthVerifiedFor,
isDepthVerified,
} from '../index.ts';
import { shouldBlockContextWrite } from '../index.ts';
// ═══════════════════════════════════════════════════════════════════════════
// Discussion flow tests (backward compatibility)
// ═══════════════════════════════════════════════════════════════════════════
// ─── Scenario 1: Blocks CONTEXT.md write during discussion without depth verification (absolute path) ──
test('write-gate: blocks CONTEXT.md write during discussion without depth verification (absolute path)', () => {
const result = shouldBlockContextWrite(
@ -38,6 +26,8 @@ test('write-gate: blocks CONTEXT.md write during discussion without depth verifi
assert.ok(result.reason, 'should provide a reason');
});
// ─── Scenario 2: Blocks CONTEXT.md write during discussion without depth verification (relative path) ──
test('write-gate: blocks CONTEXT.md write during discussion without depth verification (relative path)', () => {
const result = shouldBlockContextWrite(
'write',
@ -49,7 +39,9 @@ test('write-gate: blocks CONTEXT.md write during discussion without depth verifi
assert.ok(result.reason, 'should provide a reason');
});
test('write-gate: allows CONTEXT.md write after depth verification (discussion flow)', () => {
// ─── Scenario 3: Allows CONTEXT.md write after depth verification ──
test('write-gate: allows CONTEXT.md write after depth verification', () => {
const result = shouldBlockContextWrite(
'write',
'/Users/dev/project/.gsd/milestones/M001/M001-CONTEXT.md',
@ -60,28 +52,51 @@ test('write-gate: allows CONTEXT.md write after depth verification (discussion f
assert.strictEqual(result.reason, undefined, 'should have no reason');
});
test('write-gate: allows CONTEXT.md write outside any flow (milestoneId null, no queue)', () => {
// ─── Scenario 4: Allows CONTEXT.md write outside discussion phase (milestoneId null) ──
test('write-gate: allows CONTEXT.md write outside discussion phase', () => {
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/M001-CONTEXT.md',
null,
false,
false,
);
assert.strictEqual(result.block, false, 'should not block outside any flow');
assert.strictEqual(result.block, false, 'should not block outside discussion phase');
});
// ─── Scenario 5: Allows non-CONTEXT.md writes during discussion ──
test('write-gate: allows non-CONTEXT.md writes during discussion', () => {
const r1 = shouldBlockContextWrite('write', '.gsd/milestones/M001/M001-DISCUSSION.md', 'M001', false);
// DISCUSSION.md
const r1 = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/M001-DISCUSSION.md',
'M001',
false,
);
assert.strictEqual(r1.block, false, 'DISCUSSION.md should pass');
const r2 = shouldBlockContextWrite('write', '.gsd/milestones/M001/slices/S01/S01-PLAN.md', 'M001', false);
// Slice file
const r2 = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/slices/S01/S01-PLAN.md',
'M001',
false,
);
assert.strictEqual(r2.block, false, 'slice plan should pass');
const r3 = shouldBlockContextWrite('write', 'src/index.ts', 'M001', false);
// Regular code file
const r3 = shouldBlockContextWrite(
'write',
'src/index.ts',
'M001',
false,
);
assert.strictEqual(r3.block, false, 'regular code file should pass');
});
// ─── Scenario 6: Regex specificity — doesn't match S01-CONTEXT.md ──
test('write-gate: regex does not match slice context files (S01-CONTEXT.md)', () => {
const result = shouldBlockContextWrite(
'write',
@ -92,7 +107,9 @@ test('write-gate: regex does not match slice context files (S01-CONTEXT.md)', ()
assert.strictEqual(result.block, false, 'S01-CONTEXT.md should not be blocked');
});
test('write-gate: blocked reason contains actionable instructions', () => {
// ─── Scenario 7: Error message contains actionable instruction ──
test('write-gate: blocked reason contains depth_verification keyword', () => {
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M999/M999-CONTEXT.md',
@ -100,112 +117,6 @@ test('write-gate: blocked reason contains actionable instructions', () => {
false,
);
assert.strictEqual(result.block, true);
assert.ok(result.reason!.includes('depth_verification'), 'reason should mention depth_verification');
assert.ok(result.reason!.includes('ask_user_questions'), 'reason should mention ask_user_questions');
});
// ═══════════════════════════════════════════════════════════════════════════
// Queue flow tests (NEW — enforces write-gate during /gsd queue)
// ═══════════════════════════════════════════════════════════════════════════
test('write-gate: blocks CONTEXT.md write during queue flow without verification', () => {
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M010-3ym37m/M010-3ym37m-CONTEXT.md',
null, // queue flows have no pendingAutoStart → milestoneId is null
false,
true, // but queuePhaseActive is true
);
assert.strictEqual(result.block, true, 'should block during queue flow without verification');
assert.ok(result.reason!.includes('multi-milestone'), 'reason should mention multi-milestone');
});
test('write-gate: allows CONTEXT.md write during queue flow AFTER per-milestone verification', () => {
// Simulate: depth_verification_M010-3ym37m was answered
markDepthVerified('M010-3ym37m');
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M010-3ym37m/M010-3ym37m-CONTEXT.md',
null,
false,
true,
);
assert.strictEqual(result.block, false, 'should allow after per-milestone verification');
});
test('write-gate: blocks DIFFERENT milestone in queue flow when only one is verified', () => {
// M010-3ym37m was verified above, but M011-rfmd3q was NOT
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M011-rfmd3q/M011-rfmd3q-CONTEXT.md',
null,
false,
true,
);
assert.strictEqual(result.block, true, 'should block unverified milestone even when another is verified');
});
test('write-gate: wildcard verification unlocks all milestones in queue flow', () => {
markDepthVerified('*');
const r1 = shouldBlockContextWrite(
'write',
'.gsd/milestones/M099/M099-CONTEXT.md',
null,
false,
true,
);
assert.strictEqual(r1.block, false, 'wildcard should pass any milestone');
});
test('write-gate: allows non-CONTEXT.md writes during queue flow regardless', () => {
const result = shouldBlockContextWrite(
'write',
'.gsd/QUEUE.md',
null,
false,
true,
);
assert.strictEqual(result.block, false, 'QUEUE.md should pass during queue flow');
});
// ═══════════════════════════════════════════════════════════════════════════
// Unique milestone ID format tests
// ═══════════════════════════════════════════════════════════════════════════
test('write-gate: matches unique milestone ID format (M010-3ym37m)', () => {
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M010-3ym37m/M010-3ym37m-CONTEXT.md',
'M010-3ym37m',
false,
);
assert.strictEqual(result.block, true, 'should match unique milestone ID format');
});
test('write-gate: matches classic milestone ID format (M001)', () => {
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/M001-CONTEXT.md',
'M001',
false,
);
assert.strictEqual(result.block, true, 'should match classic milestone ID format');
});
// ═══════════════════════════════════════════════════════════════════════════
// Per-milestone depth verification helpers
// ═══════════════════════════════════════════════════════════════════════════
test('isDepthVerifiedFor: returns false for unknown milestone', () => {
assert.strictEqual(isDepthVerifiedFor('M999-xxxxxx'), true,
'returns true because wildcard * was set in earlier test');
// Note: test isolation would require clearing state, but these tests
// exercise the module as a singleton (matching production behavior)
});
test('isDepthVerified: returns true when any milestone verified', () => {
// At this point M010-3ym37m and * are verified from earlier tests
assert.strictEqual(isDepthVerified(), true);
assert.ok(result.reason!.includes('depth_verification'), 'reason should mention depth_verification question id');
assert.ok(result.reason!.includes('ask_user_questions'), 'reason should mention ask_user_questions tool');
});

View file

@ -4,30 +4,45 @@
// ─── Enums & Literal Unions ────────────────────────────────────────────────
export type RiskLevel = 'low' | 'medium' | 'high';
export type Phase = 'pre-planning' | 'needs-discussion' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'validating-milestone' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked';
export type ContinueStatus = 'in_progress' | 'interrupted' | 'compacted';
export type RiskLevel = "low" | "medium" | "high";
export type Phase =
| "pre-planning"
| "needs-discussion"
| "discussing"
| "researching"
| "planning"
| "executing"
| "verifying"
| "summarizing"
| "advancing"
| "validating-milestone"
| "completing-milestone"
| "replanning-slice"
| "complete"
| "paused"
| "blocked";
export type ContinueStatus = "in_progress" | "interrupted" | "compacted";
// ─── Roadmap (Milestone-level) ─────────────────────────────────────────────
export interface RoadmapSliceEntry {
id: string; // e.g. "S01"
title: string; // e.g. "Types + File I/O + Git Operations"
id: string; // e.g. "S01"
title: string; // e.g. "Types + File I/O + Git Operations"
risk: RiskLevel;
depends: string[]; // e.g. ["S01", "S02"]
depends: string[]; // e.g. ["S01", "S02"]
done: boolean;
demo: string; // the "After this:" sentence
demo: string; // the "After this:" sentence
}
export interface BoundaryMapEntry {
fromSlice: string; // e.g. "S01"
toSlice: string; // e.g. "S02" or "terminal"
produces: string; // raw text block of what this slice produces
consumes: string; // raw text block of what it consumes (or "nothing")
fromSlice: string; // e.g. "S01"
toSlice: string; // e.g. "S02" or "terminal"
produces: string; // raw text block of what this slice produces
consumes: string; // raw text block of what it consumes (or "nothing")
}
export interface Roadmap {
title: string; // e.g. "M001: GSD Extension — Hierarchical Planning with Auto Mode"
title: string; // e.g. "M001: GSD Extension — Hierarchical Planning with Auto Mode"
vision: string;
successCriteria: string[];
slices: RoadmapSliceEntry[];
@ -37,29 +52,24 @@ export interface Roadmap {
// ─── Slice Plan ────────────────────────────────────────────────────────────
export interface TaskPlanEntry {
id: string; // e.g. "T01"
title: string; // e.g. "Core Type Definitions"
id: string; // e.g. "T01"
title: string; // e.g. "Core Type Definitions"
description: string;
done: boolean;
estimate: string; // e.g. "30m", "2h" — informational only
files?: string[]; // e.g. ["types.ts", "files.ts"] — extracted from "- Files:" subline
verify?: string; // e.g. "run tests" — extracted from "- Verify:" subline
estimate: string; // e.g. "30m", "2h" — informational only
files?: string[]; // e.g. ["types.ts", "files.ts"] — extracted from "- Files:" subline
verify?: string; // e.g. "run tests" — extracted from "- Verify:" subline
}
// ─── Verification Gate ─────────────────────────────────────────────────────
/** Result of a single verification command execution */
export interface VerificationCheck {
command: string; // e.g. "npm run lint"
exitCode: number; // 0 = pass
command: string; // e.g. "npm run lint"
exitCode: number; // 0 = pass
stdout: string;
stderr: string;
durationMs: number;
blocking: boolean; // true for preference/task-plan sources, false for package-json (advisory only)
/** True when the failure was a spawn/infra error (ETIMEDOUT, ENOENT, ENOMEM)
* rather than the command itself failing. Infra errors are transient and
* should not trigger auto-fix retries the agent cannot fix the OS. */
infraError?: boolean;
}
/** A runtime error captured from bg-shell processes or browser console */
@ -81,17 +91,17 @@ export interface AuditWarning {
/** Aggregate result from the verification gate */
export interface VerificationResult {
passed: boolean; // true if all checks passed (or no checks discovered)
checks: VerificationCheck[]; // per-command results
passed: boolean; // true if all checks passed (or no checks discovered)
checks: VerificationCheck[]; // per-command results
discoverySource: "preference" | "task-plan" | "package-json" | "none";
timestamp: number; // Date.now() at gate start
runtimeErrors?: RuntimeError[]; // optional — populated by captureRuntimeErrors()
auditWarnings?: AuditWarning[]; // optional — populated by runDependencyAudit()
timestamp: number; // Date.now() at gate start
runtimeErrors?: RuntimeError[]; // optional — populated by captureRuntimeErrors()
auditWarnings?: AuditWarning[]; // optional — populated by runDependencyAudit()
}
export interface SlicePlan {
id: string; // e.g. "S01"
title: string; // from the H1
id: string; // e.g. "S01"
title: string; // from the H1
goal: string;
demo: string;
mustHaves: string[]; // top-level must-have bullet points
@ -161,29 +171,29 @@ export interface Continue {
// ─── Secrets Manifest ──────────────────────────────────────────────────────
export type SecretsManifestEntryStatus = 'pending' | 'collected' | 'skipped';
export type SecretsManifestEntryStatus = "pending" | "collected" | "skipped";
export interface SecretsManifestEntry {
key: string; // e.g. "OPENAI_API_KEY"
service: string; // e.g. "OpenAI"
dashboardUrl: string; // e.g. "https://platform.openai.com/api-keys" — empty if unknown
guidance: string[]; // numbered setup steps
formatHint: string; // e.g. "starts with sk-" — empty if unknown
key: string; // e.g. "OPENAI_API_KEY"
service: string; // e.g. "OpenAI"
dashboardUrl: string; // e.g. "https://platform.openai.com/api-keys" — empty if unknown
guidance: string[]; // numbered setup steps
formatHint: string; // e.g. "starts with sk-" — empty if unknown
status: SecretsManifestEntryStatus;
destination: string; // e.g. "dotenv", "vercel", "convex"
destination: string; // e.g. "dotenv", "vercel", "convex"
}
export interface SecretsManifest {
milestone: string; // e.g. "M001"
generatedAt: string; // ISO 8601 timestamp
milestone: string; // e.g. "M001"
generatedAt: string; // ISO 8601 timestamp
entries: SecretsManifestEntry[];
}
export interface ManifestStatus {
pending: string[]; // manifest status = pending AND not in env
collected: string[]; // manifest status = collected AND not in env
skipped: string[]; // manifest status = skipped
existing: string[]; // key present in .env or process.env (regardless of manifest status)
pending: string[]; // manifest status = pending AND not in env
collected: string[]; // manifest status = collected AND not in env
skipped: string[]; // manifest status = skipped
existing: string[]; // key present in .env or process.env (regardless of manifest status)
}
// ─── GSD State (Derived Dashboard) ────────────────────────────────────────
@ -196,7 +206,7 @@ export interface ActiveRef {
export interface MilestoneRegistryEntry {
id: string;
title: string;
status: 'complete' | 'active' | 'pending' | 'parked';
status: "complete" | "active" | "pending" | "parked";
/** Milestone IDs that must be complete before this milestone becomes active. Populated from CONTEXT.md YAML frontmatter. */
dependsOn?: string[];
}
@ -279,13 +289,13 @@ export interface HookDispatchResult {
// ─── Budget & Notification Types ──────────────────────────────────────────
export type BudgetEnforcementMode = 'warn' | 'pause' | 'halt';
export type BudgetEnforcementMode = "warn" | "pause" | "halt";
export type TokenProfile = 'budget' | 'balanced' | 'quality';
export type TokenProfile = "budget" | "balanced" | "quality";
export type InlineLevel = 'full' | 'standard' | 'minimal';
export type InlineLevel = "full" | "standard" | "minimal";
export type ComplexityTier = 'light' | 'standard' | 'heavy';
export type ComplexityTier = "light" | "standard" | "heavy";
export interface ClassificationResult {
tier: ComplexityTier;
@ -308,19 +318,18 @@ export interface PhaseSkipPreferences {
skip_reassess?: boolean;
skip_slice_research?: boolean;
skip_milestone_validation?: boolean;
/** When true, reassess-roadmap fires after each slice completion. Opt-in. */
reassess_after_slice?: boolean;
/** When true, auto-mode pauses before each slice for discussion (#789). */
require_slice_discussion?: boolean;
}
export interface NotificationPreferences {
enabled?: boolean; // default true
on_complete?: boolean; // notify on each unit completion
on_error?: boolean; // notify on errors
on_budget?: boolean; // notify on budget thresholds
on_milestone?: boolean; // notify when milestone finishes
on_attention?: boolean; // notify when manual attention needed
enabled?: boolean; // default true
on_complete?: boolean; // notify on each unit completion
on_error?: boolean; // notify on errors
on_budget?: boolean; // notify on budget thresholds
on_milestone?: boolean; // notify when milestone finishes
on_attention?: boolean; // notify when manual attention needed
}
// ─── Pre-Dispatch Hook Types ──────────────────────────────────────────────
@ -331,7 +340,7 @@ export interface PreDispatchHookConfig {
/** Unit types this hook intercepts before dispatch (e.g., ["execute-task"]). */
before: string[];
/** Action to take: "modify" mutates the prompt, "skip" skips the unit, "replace" swaps it. */
action: 'modify' | 'skip' | 'replace';
action: "modify" | "skip" | "replace";
/** For "modify": text prepended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
prepend?: string;
/** For "modify": text appended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
@ -350,7 +359,7 @@ export interface PreDispatchHookConfig {
export interface PreDispatchResult {
/** What happened: the unit proceeds with modifications, was skipped, or was replaced. */
action: 'proceed' | 'skip' | 'replace';
action: "proceed" | "skip" | "replace";
/** Modified/replacement prompt (for "proceed" and "replace"). */
prompt?: string;
/** Override unit type (for "replace"). */
@ -374,7 +383,7 @@ export interface HookStatusEntry {
/** Hook name. */
name: string;
/** Hook type: "post" or "pre". */
type: 'post' | 'pre';
type: "post" | "pre";
/** Whether hook is enabled. */
enabled: boolean;
/** What unit types it targets. */
@ -386,36 +395,36 @@ export interface HookStatusEntry {
// ─── Database Types (Decisions & Requirements) ────────────────────────────
export interface Decision {
seq: number; // auto-increment primary key
id: string; // e.g. "D001"
when_context: string; // when/context of the decision
scope: string; // scope (milestone, slice, global, etc.)
decision: string; // what was decided
choice: string; // the specific choice made
rationale: string; // why this choice
revisable: string; // whether/when revisable
superseded_by: string | null; // ID of superseding decision, or null
seq: number; // auto-increment primary key
id: string; // e.g. "D001"
when_context: string; // when/context of the decision
scope: string; // scope (milestone, slice, global, etc.)
decision: string; // what was decided
choice: string; // the specific choice made
rationale: string; // why this choice
revisable: string; // whether/when revisable
superseded_by: string | null; // ID of superseding decision, or null
}
export interface Requirement {
id: string; // e.g. "R001"
class: string; // requirement class (functional, non-functional, etc.)
status: string; // active, validated, deferred, etc.
description: string; // short description
why: string; // rationale
source: string; // origin (milestone, user, etc.)
primary_owner: string; // owning slice/milestone
id: string; // e.g. "R001"
class: string; // requirement class (functional, non-functional, etc.)
status: string; // active, validated, deferred, etc.
description: string; // short description
why: string; // rationale
source: string; // origin (milestone, user, etc.)
primary_owner: string; // owning slice/milestone
supporting_slices: string; // other slices that touch this
validation: string; // how to validate
notes: string; // additional notes
full_content: string; // full requirement text
superseded_by: string | null; // ID of superseding requirement, or null
validation: string; // how to validate
notes: string; // additional notes
full_content: string; // full requirement text
superseded_by: string | null; // ID of superseding requirement, or null
}
// ─── Parallel Orchestration Types ────────────────────────────────────────
export type CompressionStrategy = 'truncate' | 'compress';
export type ContextSelectionMode = 'full' | 'smart';
export type CompressionStrategy = "truncate" | "compress";
export type ContextSelectionMode = "full" | "smart";
export type MergeStrategy = "per-slice" | "per-milestone";
export type AutoMergeMode = "auto" | "confirm" | "manual";

View file

@ -9,46 +9,48 @@ import { deriveState } from "./state.js";
import { invalidateAllCaches } from "./cache.js";
import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js";
import { sendDesktopNotification } from "./notifications.js";
import { parseUnitId } from "./unit-id.js";
/**
* Undo the last completed unit: revert git commits, remove from completed-units,
* Undo the last completed unit: revert git commits,
* delete summary artifacts, and uncheck the task in PLAN.
* deriveState() handles re-derivation after revert.
*/
export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi: ExtensionAPI, basePath: string): Promise<void> {
const force = args.includes("--force");
// 1. Load completed-units.json
const completedKeysFile = join(gsdRoot(basePath), "completed-units.json");
if (!existsSync(completedKeysFile)) {
ctx.ui.notify("Nothing to undo — no completed units found.", "info");
// Find the last GSD-related commit from git activity logs
const activityDir = join(gsdRoot(basePath), "activity");
if (!existsSync(activityDir)) {
ctx.ui.notify("Nothing to undo — no activity logs found.", "info");
return;
}
let keys: string[];
try {
keys = JSON.parse(readFileSync(completedKeysFile, "utf-8"));
} catch {
ctx.ui.notify("Nothing to undo — completed-units.json is corrupt.", "warning");
// Parse activity logs to find the most recent unit
const files = readdirSync(activityDir)
.filter(f => f.endsWith(".jsonl"))
.sort()
.reverse();
if (files.length === 0) {
ctx.ui.notify("Nothing to undo — no activity logs found.", "info");
return;
}
if (keys.length === 0) {
ctx.ui.notify("Nothing to undo — no completed units.", "info");
// Extract unit type and ID from the most recent activity log filename
// Format: <seq>-<unitType>-<unitId>.jsonl
const match = files[0].match(/^\d+-(.+?)-(.+)\.jsonl$/);
if (!match) {
ctx.ui.notify("Nothing to undo — could not parse latest activity log.", "warning");
return;
}
// Get the last completed unit
const lastKey = keys[keys.length - 1];
const sepIdx = lastKey.indexOf("/");
const unitType = sepIdx >= 0 ? lastKey.slice(0, sepIdx) : lastKey;
const unitId = sepIdx >= 0 ? lastKey.slice(sepIdx + 1) : lastKey;
const unitType = match[1];
const unitId = match[2].replace(/-/g, "/");
if (!force) {
ctx.ui.notify(
`Will undo: ${unitType} (${unitId})\n` +
`This will:\n` +
` - Remove from completed-units.json\n` +
` - Delete summary artifacts\n` +
` - Uncheck task in PLAN (if execute-task)\n` +
` - Attempt to revert associated git commits\n\n` +
@ -58,15 +60,12 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
return;
}
// 2. Remove from completed-units.json
keys = keys.filter(k => k !== lastKey);
writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8");
// 3. Delete summary artifact
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
// 1. Delete summary artifact
const parts = unitId.split("/");
let summaryRemoved = false;
if (mid && sid && tid) {
if (parts.length === 3) {
// Task-level: M001/S01/T01
const [mid, sid, tid] = parts;
const tasksDir = resolveTasksDir(basePath, mid, sid);
if (tasksDir) {
const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY"));
@ -75,11 +74,11 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
summaryRemoved = true;
}
}
} else if (mid && sid) {
} else if (parts.length === 2) {
// Slice-level: M001/S01
const [mid, sid] = parts;
const slicePath = resolveSlicePath(basePath, mid, sid);
if (slicePath) {
// Try common summary filenames
for (const suffix of ["SUMMARY", "COMPLETE"]) {
const candidates = findFileWithPrefix(slicePath, sid, suffix);
for (const f of candidates) {
@ -90,40 +89,37 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
}
}
// 4. Uncheck task in PLAN if execute-task
// 2. Uncheck task in PLAN if execute-task
let planUpdated = false;
if (unitType === "execute-task" && mid && sid && tid) {
if (unitType === "execute-task" && parts.length === 3) {
const [mid, sid, tid] = parts;
planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid);
}
// 5. Try to revert git commits from activity log
// 3. Try to revert git commits from activity log
let commitsReverted = 0;
const activityDir = join(gsdRoot(basePath), "activity");
try {
if (existsSync(activityDir)) {
const commits = findCommitsForUnit(activityDir, unitType, unitId);
if (commits.length > 0) {
for (const sha of commits.reverse()) {
try {
nativeRevertCommit(basePath, sha);
commitsReverted++;
} catch {
// Revert conflict or already reverted — skip
try { nativeRevertAbort(basePath); } catch { /* no-op */ }
break;
}
const commits = findCommitsForUnit(activityDir, unitType, unitId);
if (commits.length > 0) {
for (const sha of commits.reverse()) {
try {
nativeRevertCommit(basePath, sha);
commitsReverted++;
} catch {
// Revert conflict or already reverted — skip
try { nativeRevertAbort(basePath); } catch { /* no-op */ }
break;
}
}
}
} finally {
// 6. Re-derive state — always invalidate caches even if git operations fail
// 4. Re-derive state — always invalidate caches even if git operations fail
invalidateAllCaches();
await deriveState(basePath);
}
// Build result message
const results: string[] = [`Undone: ${unitType} (${unitId})`];
results.push(` - Removed from completed-units.json`);
if (summaryRemoved) results.push(` - Deleted summary artifact`);
if (planUpdated) results.push(` - Unchecked task in PLAN`);
if (commitsReverted > 0) {

View file

@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import {
gsdRoot,
@ -8,8 +8,6 @@ import {
resolveTaskFile,
} from "./paths.js";
import { loadFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
import { parseUnitId } from "./unit-id.js";
export type UnitRuntimePhase =
| "dispatched"
@ -48,23 +46,13 @@ export interface AutoUnitRuntimeRecord {
lastRecoveryReason?: "idle" | "hard";
}
function isAutoUnitRuntimeRecord(data: unknown): data is AutoUnitRuntimeRecord {
return (
typeof data === "object" &&
data !== null &&
(data as AutoUnitRuntimeRecord).version === 1 &&
typeof (data as AutoUnitRuntimeRecord).unitType === "string" &&
typeof (data as AutoUnitRuntimeRecord).unitId === "string"
);
}
function runtimeDir(basePath: string): string {
return join(gsdRoot(basePath), "runtime", "units");
}
function runtimePath(basePath: string, unitType: string, unitId: string): string {
const sanitizedUnitType = unitType.replace(/[^a-zA-Z0-9._-]+/g, "-");
const sanitizedUnitId = unitId.replace(/[^a-zA-Z0-9._-]+/g, "-");
const sanitizedUnitType = unitType.replace(/[\/]/g, "-");
const sanitizedUnitId = unitId.replace(/[\/]/g, "-");
return join(runtimeDir(basePath), `${sanitizedUnitType}-${sanitizedUnitId}.json`);
}
@ -75,6 +63,8 @@ export function writeUnitRuntimeRecord(
startedAt: number,
updates: Partial<AutoUnitRuntimeRecord> = {},
): AutoUnitRuntimeRecord {
const dir = runtimeDir(basePath);
mkdirSync(dir, { recursive: true });
const path = runtimePath(basePath, unitType, unitId);
const prev = readUnitRuntimeRecord(basePath, unitType, unitId);
const next: AutoUnitRuntimeRecord = {
@ -94,12 +84,18 @@ export function writeUnitRuntimeRecord(
recoveryAttempts: updates.recoveryAttempts ?? prev?.recoveryAttempts ?? 0,
lastRecoveryReason: updates.lastRecoveryReason ?? prev?.lastRecoveryReason,
};
saveJsonFile(path, next);
writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8");
return next;
}
export function readUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): AutoUnitRuntimeRecord | null {
return loadJsonFileOrNull(runtimePath(basePath, unitType, unitId), isAutoUnitRuntimeRecord);
const path = runtimePath(basePath, unitType, unitId);
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, "utf-8")) as AutoUnitRuntimeRecord;
} catch {
return null;
}
}
export function clearUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): void {
@ -132,7 +128,7 @@ export async function inspectExecuteTaskDurability(
basePath: string,
unitId: string,
): Promise<ExecuteTaskRecoveryStatus | null> {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
const [mid, sid, tid] = unitId.split("/");
if (!mid || !sid || !tid) return null;
const planAbs = resolveSliceFile(basePath, mid, sid, "PLAN");

View file

@ -11,7 +11,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { VerificationResult } from "./types.js";
import type { VerificationResult } from "./types.ts";
// ─── JSON Evidence Artifact ──────────────────────────────────────────────────
@ -20,7 +20,6 @@ export interface EvidenceCheckJSON {
exitCode: number;
durationMs: number;
verdict: "pass" | "fail";
blocking: boolean;
}
export interface RuntimeErrorJSON {
@ -81,7 +80,6 @@ export function writeVerificationJSON(
exitCode: check.exitCode,
durationMs: check.durationMs,
verdict: check.exitCode === 0 ? "pass" : "fail",
blocking: check.blocking,
})),
...(retryAttempt !== undefined ? { retryAttempt } : {}),
...(maxRetries !== undefined ? { maxRetries } : {}),

View file

@ -45,12 +45,11 @@ const PACKAGE_SCRIPT_KEYS = ["typecheck", "lint", "test"] as const;
* 4. None found
*/
export function discoverCommands(options: DiscoverCommandsOptions): DiscoveredCommands {
// 1. Preference commands (still sanitize — may contain prose from misconfiguration)
// 1. Preference commands
if (options.preferenceCommands && options.preferenceCommands.length > 0) {
const filtered = options.preferenceCommands
.map(c => c.trim())
.filter(Boolean)
.filter(c => isLikelyCommand(c));
.filter(Boolean);
if (filtered.length > 0) {
return { commands: filtered, source: "preference" };
}
@ -112,9 +111,7 @@ const MAX_FAILURE_CONTEXT_CHARS = 10_000;
* Returns an empty string when all checks pass or the checks array is empty.
*/
export function formatFailureContext(result: VerificationResult): string {
// Only include blocking failures in retry context — non-blocking (advisory) failures
// should not be injected into retry prompts to avoid noise pollution.
const failures = result.checks.filter((c) => c.exitCode !== 0 && c.blocking);
const failures = result.checks.filter((c) => c.exitCode !== 0);
if (failures.length === 0) return "";
const blocks: string[] = [];
@ -232,20 +229,13 @@ export interface RunVerificationGateOptions {
commandTimeoutMs?: number;
}
/** Error codes from spawnSync that indicate infrastructure/OS-level failures
* rather than the command itself failing. These are transient the agent
* cannot fix them, so they should not trigger auto-fix retries. */
const INFRA_ERROR_CODES = new Set(["ETIMEDOUT", "ENOENT", "ENOMEM", "EMFILE", "ENFILE", "EAGAIN"]);
/**
* Run the verification gate: discover commands, execute each via spawnSync,
* and return a structured result.
*
* - All commands run sequentially regardless of individual pass/fail.
* - `passed` is true when every blocking command exits 0 (or no commands are discovered).
* - `passed` is true when every command exits 0 (or no commands are discovered).
* - stdout/stderr per command are truncated to 10 KB.
* - Spawn/infra errors (ETIMEDOUT, ENOENT, etc.) are tagged with `infraError: true`
* so the retry logic can distinguish "the OS couldn't run this" from "the tests failed".
*/
export function runVerificationGate(options: RunVerificationGateOptions): VerificationResult {
const timestamp = Date.now();
@ -265,10 +255,6 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi
};
}
// Commands from preference and task-plan sources are blocking;
// package-json discovered commands are advisory (non-blocking).
const blocking = source === "preference" || source === "task-plan";
const checks: VerificationCheck[] = [];
for (const command of commands) {
@ -286,26 +272,12 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi
let stderr: string;
if (result.error) {
// Spawn infrastructure failure — OS-level, not a test failure.
// Tag with infraError so the retry logic can skip auto-fix attempts.
const errCode = (result.error as NodeJS.ErrnoException).code;
const isInfra = !!errCode && INFRA_ERROR_CODES.has(errCode);
// Command not found or spawn failure
exitCode = 127;
stderr = truncate(
(result.stderr || "") + "\n" + (result.error as Error).message,
MAX_OUTPUT_BYTES,
);
checks.push({
command,
exitCode,
stdout: truncate(result.stdout, MAX_OUTPUT_BYTES),
stderr,
durationMs,
blocking,
...(isInfra ? { infraError: true } : {}),
});
continue;
} else {
// status is null when killed by signal — treat as failure
exitCode = result.status ?? 1;
@ -318,16 +290,11 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi
stdout: truncate(result.stdout, MAX_OUTPUT_BYTES),
stderr,
durationMs,
blocking,
});
}
// Gate passes if all blocking checks pass (non-blocking failures are advisory)
const blockingChecks = checks.filter(c => c.blocking);
const passed = blockingChecks.length === 0 || blockingChecks.every(c => c.exitCode === 0);
return {
passed,
passed: checks.every(c => c.exitCode === 0),
checks,
discoverySource: source,
timestamp,

View file

@ -34,7 +34,6 @@ import type { FileLineStat } from "./worktree-manager.js";
import { existsSync, realpathSync, readdirSync, rmSync, unlinkSync } from "node:fs";
import { nativeMergeAbort } from "./native-git-bridge.js";
import { join, sep } from "node:path";
import { getErrorMessage } from "./error-utils.js";
/**
* Tracks the original project root so we can switch back.
@ -42,28 +41,16 @@ import { getErrorMessage } from "./error-utils.js";
*/
let originalCwd: string | null = null;
function ensureWorktreeStateInitialized(): void {
if (originalCwd) return;
const cwd = process.cwd();
const marker = `${sep}.gsd${sep}worktrees${sep}`;
const markerIdx = cwd.indexOf(marker);
if (markerIdx !== -1) {
originalCwd = cwd.slice(0, markerIdx);
}
}
/** Get the original project root if currently in a worktree, or null. */
export function getWorktreeOriginalCwd(): string | null {
ensureWorktreeStateInitialized();
return originalCwd;
}
/** Get the name of the active worktree, or null if not in one. */
export function getActiveWorktreeName(): string | null {
ensureWorktreeStateInitialized();
if (!originalCwd) return null;
const cwd = process.cwd();
const wtDir = join(gsdRoot(originalCwd), "worktrees");
const wtDir = join(originalCwd, ".gsd", "worktrees");
if (!cwd.startsWith(wtDir)) return null;
const rel = cwd.slice(wtDir.length + 1);
const name = rel.split("/")[0] ?? rel.split("\\")[0];
@ -116,13 +103,12 @@ function worktreeCompletions(prefix: string) {
return [];
}
export async function handleWorktreeCommand(
async function worktreeHandler(
args: string,
ctx: ExtensionCommandContext,
pi: ExtensionAPI,
alias: string,
): Promise<void> {
ensureWorktreeStateInitialized();
const trimmed = (typeof args === "string" ? args : "").trim();
const basePath = process.cwd();
@ -242,11 +228,27 @@ export async function handleWorktreeCommand(
}
}
export async function handleWorktreeCommand(
args: string,
ctx: ExtensionCommandContext,
pi: ExtensionAPI,
alias: string,
): Promise<void> {
await worktreeHandler(args, ctx, pi, alias);
}
export function registerWorktreeCommand(pi: ExtensionAPI): void {
// Restore worktree state after /reload.
// The module-level originalCwd resets to null when extensions are re-loaded,
// but process.cwd() is still inside the worktree. Detect this and recover.
ensureWorktreeStateInitialized();
if (!originalCwd) {
const cwd = process.cwd();
const marker = `${sep}.gsd${sep}worktrees${sep}`;
const markerIdx = cwd.indexOf(marker);
if (markerIdx !== -1) {
originalCwd = cwd.slice(0, markerIdx);
}
}
pi.registerCommand("worktree", {
description: "Git worktrees (also /wt): /worktree <name> | list | merge | remove",
@ -377,7 +379,7 @@ async function handleCreate(
"info",
);
} catch (error) {
const msg = getErrorMessage(error);
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to create worktree: ${msg}`, "error");
}
}
@ -425,7 +427,7 @@ async function handleSwitch(
"info",
);
} catch (error) {
const msg = getErrorMessage(error);
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to switch to worktree: ${msg}`, "error");
}
}
@ -535,7 +537,7 @@ async function handleList(
ctx.ui.notify(lines.join("\n"), "info");
} catch (error) {
const msg = getErrorMessage(error);
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to list worktrees: ${msg}`, "error");
}
}
@ -640,6 +642,16 @@ async function handleMerge(
const commitType = inferCommitType(name);
const commitMessage = `${commitType}(${name}): merge worktree ${name}`;
// Reconcile worktree DB into main DB before squash merge
const wtDbPath = join(worktreePath(basePath, name), ".gsd", "gsd.db");
const mainDbPath = join(basePath, ".gsd", "gsd.db");
if (existsSync(wtDbPath) && existsSync(mainDbPath)) {
try {
const { reconcileWorktreeDb } = await import("./gsd-db.js");
reconcileWorktreeDb(mainDbPath, wtDbPath);
} catch { /* non-fatal */ }
}
try {
mergeWorktreeToMain(basePath, name, commitMessage);
ctx.ui.notify(
@ -653,7 +665,7 @@ async function handleMerge(
);
return;
} catch (mergeErr) {
const mergeMsg = getErrorMessage(mergeErr);
const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
const isConflict = /conflict/i.test(mergeMsg);
if (isConflict) {
@ -710,7 +722,7 @@ async function handleMerge(
"info",
);
} catch (error) {
const msg = getErrorMessage(error);
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to start merge: ${msg}`, "error");
}
}
@ -753,7 +765,7 @@ async function handleRemove(
ctx.ui.notify(`${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, "info");
} catch (error) {
const msg = getErrorMessage(error);
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error");
}
}
@ -807,7 +819,7 @@ async function handleRemoveAll(
if (failed.length > 0) lines.push(`${CLR.warn("✗")} Failed: ${failed.map(n => CLR.name(n)).join(", ")}`);
ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info");
} catch (error) {
const msg = getErrorMessage(error);
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to remove worktrees: ${msg}`, "error");
}
}

View file

@ -17,7 +17,6 @@
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { gsdRoot } from "./paths.js";
import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js";
import {
nativeBranchDelete,
@ -101,7 +100,7 @@ export function resolveGitDir(basePath: string): string {
}
export function worktreesDir(basePath: string): string {
return join(gsdRoot(basePath), "worktrees");
return join(basePath, ".gsd", "worktrees");
}
export function worktreePath(basePath: string, name: string): string {
@ -194,7 +193,7 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
const seenRoots = new Set<string>();
const worktreeRoots = baseVariants
.map(baseVariant => {
const path = join(gsdRoot(baseVariant), "worktrees");
const path = join(baseVariant, ".gsd", "worktrees");
return {
normalized: normalizePathForComparison(path),
};

View file

@ -0,0 +1,485 @@
/**
* WorktreeResolver encapsulates worktree path state and merge/exit lifecycle.
*
* Replaces scattered `s.basePath`/`s.originalBasePath` mutation and 3 duplicated
* merge-or-teardown blocks in auto-loop.ts with single method calls. All
* `s.basePath` mutations (except session.reset() and initial setup) happen
* through this class.
*
* Design: Option A mutates AutoSession fields directly so existing `s.basePath`
* reads continue to work everywhere without wiring changes.
*
* Key invariant: `createAutoWorktree()` and `enterAutoWorktree()` call
* `process.chdir()` internally this class MUST NOT double-chdir.
*/
import type { AutoSession } from "./auto/session.js";
import { debugLog } from "./debug-logger.js";
// ─── Dependency Interface ──────────────────────────────────────────────────
export interface WorktreeResolverDeps {
isInAutoWorktree: (basePath: string) => boolean;
shouldUseWorktreeIsolation: () => boolean;
getIsolationMode: () => "worktree" | "branch" | "none";
mergeMilestoneToMain: (
basePath: string,
milestoneId: string,
roadmapContent: string,
) => { pushed: boolean };
syncWorktreeStateBack: (
mainBasePath: string,
worktreePath: string,
milestoneId: string,
) => { synced: string[] };
teardownAutoWorktree: (
basePath: string,
milestoneId: string,
opts?: { preserveBranch?: boolean },
) => void;
createAutoWorktree: (basePath: string, milestoneId: string) => string;
enterAutoWorktree: (basePath: string, milestoneId: string) => string;
getAutoWorktreePath: (basePath: string, milestoneId: string) => string | null;
autoCommitCurrentBranch: (
basePath: string,
reason: string,
milestoneId: string,
) => void;
getCurrentBranch: (basePath: string) => string;
autoWorktreeBranch: (milestoneId: string) => string;
resolveMilestoneFile: (
basePath: string,
milestoneId: string,
fileType: string,
) => string | null;
readFileSync: (path: string, encoding: string) => string;
GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown;
loadEffectiveGSDPreferences: () =>
| { preferences?: { git?: Record<string, unknown> } }
| undefined;
invalidateAllCaches: () => void;
captureIntegrationBranch: (
basePath: string,
mid: string,
opts?: { commitDocs?: boolean },
) => void;
}
// ─── Notify Context ────────────────────────────────────────────────────────
export interface NotifyCtx {
notify: (
msg: string,
level?: "info" | "warning" | "error" | "success",
) => void;
}
// ─── WorktreeResolver ──────────────────────────────────────────────────────
export class WorktreeResolver {
private readonly s: AutoSession;
private readonly deps: WorktreeResolverDeps;
constructor(session: AutoSession, deps: WorktreeResolverDeps) {
this.s = session;
this.deps = deps;
}
// ── Getters ────────────────────────────────────────────────────────────
/** Current working path — may be worktree or project root. */
get workPath(): string {
return this.s.basePath;
}
/** Original project root — always the non-worktree path. */
get projectRoot(): string {
return this.s.originalBasePath || this.s.basePath;
}
/** Path for auto.lock file — same as the old lockBase(). */
get lockPath(): string {
return this.s.originalBasePath || this.s.basePath;
}
// ── Private Helpers ────────────────────────────────────────────────────
private rebuildGitService(): void {
const gitConfig =
this.deps.loadEffectiveGSDPreferences()?.preferences?.git ?? {};
this.s.gitService = new this.deps.GitServiceImpl(
this.s.basePath,
gitConfig,
) as AutoSession["gitService"];
}
/** Restore basePath to originalBasePath and rebuild GitService. */
private restoreToProjectRoot(): void {
if (!this.s.originalBasePath) return;
this.s.basePath = this.s.originalBasePath;
this.rebuildGitService();
this.deps.invalidateAllCaches();
}
// ── Validation ──────────────────────────────────────────────────────────
/** Validate milestoneId to prevent path traversal. */
private validateMilestoneId(milestoneId: string): void {
if (/[\/\\]|\.\./.test(milestoneId)) {
throw new Error(
`Invalid milestoneId: ${milestoneId} — contains path separators or traversal`,
);
}
}
// ── Enter Milestone ────────────────────────────────────────────────────
/**
* Enter or create a worktree for the given milestone.
*
* Only acts if `shouldUseWorktreeIsolation()` returns true.
* Delegates to `enterAutoWorktree` (existing) or `createAutoWorktree` (new).
* Those functions call `process.chdir()` internally we do NOT double-chdir.
*
* Updates `s.basePath` and rebuilds GitService on success.
* On failure: notifies a warning and does NOT update `s.basePath`.
*/
enterMilestone(milestoneId: string, ctx: NotifyCtx): void {
this.validateMilestoneId(milestoneId);
if (!this.deps.shouldUseWorktreeIsolation()) {
debugLog("WorktreeResolver", {
action: "enterMilestone",
milestoneId,
skipped: true,
reason: "isolation-disabled",
});
return;
}
const basePath = this.s.originalBasePath || this.s.basePath;
debugLog("WorktreeResolver", {
action: "enterMilestone",
milestoneId,
basePath,
});
try {
const existingPath = this.deps.getAutoWorktreePath(basePath, milestoneId);
let wtPath: string;
if (existingPath) {
wtPath = this.deps.enterAutoWorktree(basePath, milestoneId);
} else {
wtPath = this.deps.createAutoWorktree(basePath, milestoneId);
}
this.s.basePath = wtPath;
this.rebuildGitService();
debugLog("WorktreeResolver", {
action: "enterMilestone",
milestoneId,
result: "success",
wtPath,
});
ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
debugLog("WorktreeResolver", {
action: "enterMilestone",
milestoneId,
result: "error",
error: msg,
});
ctx.notify(
`Auto-worktree creation for ${milestoneId} failed: ${msg}. Continuing in project root.`,
"warning",
);
// Do NOT update s.basePath — stay in project root
}
}
// ── Exit Milestone ─────────────────────────────────────────────────────
/**
* Exit the current worktree: auto-commit, teardown, reset basePath.
*
* Only acts if currently in an auto-worktree (checked via `isInAutoWorktree`).
* Resets `s.basePath` to `s.originalBasePath` and rebuilds GitService.
*/
exitMilestone(
milestoneId: string,
ctx: NotifyCtx,
opts?: { preserveBranch?: boolean },
): void {
this.validateMilestoneId(milestoneId);
if (!this.deps.isInAutoWorktree(this.s.basePath)) {
debugLog("WorktreeResolver", {
action: "exitMilestone",
milestoneId,
skipped: true,
reason: "not-in-worktree",
});
return;
}
debugLog("WorktreeResolver", {
action: "exitMilestone",
milestoneId,
basePath: this.s.basePath,
});
try {
this.deps.autoCommitCurrentBranch(this.s.basePath, "stop", milestoneId);
} catch (err) {
debugLog("WorktreeResolver", {
action: "exitMilestone",
milestoneId,
phase: "auto-commit-failed",
error: err instanceof Error ? err.message : String(err),
});
}
try {
this.deps.teardownAutoWorktree(this.s.originalBasePath, milestoneId, {
preserveBranch: opts?.preserveBranch ?? false,
});
} catch (err) {
debugLog("WorktreeResolver", {
action: "exitMilestone",
milestoneId,
phase: "teardown-failed",
error: err instanceof Error ? err.message : String(err),
});
}
this.restoreToProjectRoot();
debugLog("WorktreeResolver", {
action: "exitMilestone",
milestoneId,
result: "done",
basePath: this.s.basePath,
});
ctx.notify(`Exited worktree for ${milestoneId}`, "info");
}
// ── Merge and Exit ─────────────────────────────────────────────────────
/**
* Merge the completed milestone branch back to main and exit the worktree.
*
* Handles all three isolation modes:
* - **worktree**: Read roadmap, merge, teardown worktree, reset paths.
* Falls back to bare teardown if no roadmap exists.
* - **branch**: Check if on milestone branch, merge if so (no chdir/teardown).
* - **none**: No-op.
*
* Error recovery: on merge failure, always restore `s.basePath` to
* `s.originalBasePath` and `process.chdir(s.originalBasePath)`.
*/
mergeAndExit(milestoneId: string, ctx: NotifyCtx): void {
this.validateMilestoneId(milestoneId);
const mode = this.deps.getIsolationMode();
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
mode,
basePath: this.s.basePath,
});
if (mode === "none") {
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
skipped: true,
reason: "mode-none",
});
return;
}
if (
mode === "worktree" ||
(this.deps.isInAutoWorktree(this.s.basePath) && this.s.originalBasePath)
) {
this._mergeWorktreeMode(milestoneId, ctx);
} else if (mode === "branch") {
this._mergeBranchMode(milestoneId, ctx);
}
}
/** Worktree-mode merge: read roadmap, merge, teardown, reset paths. */
private _mergeWorktreeMode(milestoneId: string, ctx: NotifyCtx): void {
const originalBase = this.s.originalBasePath;
if (!originalBase) {
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
mode: "worktree",
skipped: true,
reason: "missing-original-base",
});
return;
}
try {
const { synced } = this.deps.syncWorktreeStateBack(
originalBase,
this.s.basePath,
milestoneId,
);
if (synced.length > 0) {
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
phase: "reverse-sync",
synced: synced.length,
});
}
const roadmapPath = this.deps.resolveMilestoneFile(
originalBase,
milestoneId,
"ROADMAP",
);
if (roadmapPath) {
const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8");
const mergeResult = this.deps.mergeMilestoneToMain(
originalBase,
milestoneId,
roadmapContent,
);
ctx.notify(
`Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
"info",
);
} else {
// No roadmap — fall back to bare teardown
this.deps.teardownAutoWorktree(originalBase, milestoneId);
ctx.notify(
`Exited worktree for ${milestoneId} (no roadmap for merge).`,
"info",
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
result: "error",
error: msg,
fallback: "chdir-to-project-root",
});
ctx.notify(`Milestone merge failed: ${msg}`, "warning");
// Error recovery: always restore to project root
if (originalBase) {
try {
process.chdir(originalBase);
} catch {
/* best-effort */
}
}
}
// Always restore basePath and rebuild — whether merge succeeded or failed
this.restoreToProjectRoot();
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
result: "done",
basePath: this.s.basePath,
});
}
/** Branch-mode merge: check current branch, merge if on milestone branch. */
private _mergeBranchMode(milestoneId: string, ctx: NotifyCtx): void {
try {
const currentBranch = this.deps.getCurrentBranch(this.s.basePath);
const milestoneBranch = this.deps.autoWorktreeBranch(milestoneId);
if (currentBranch !== milestoneBranch) {
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
mode: "branch",
skipped: true,
reason: "not-on-milestone-branch",
currentBranch,
milestoneBranch,
});
return;
}
const roadmapPath = this.deps.resolveMilestoneFile(
this.s.basePath,
milestoneId,
"ROADMAP",
);
if (!roadmapPath) {
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
mode: "branch",
skipped: true,
reason: "no-roadmap",
});
return;
}
const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8");
const mergeResult = this.deps.mergeMilestoneToMain(
this.s.basePath,
milestoneId,
roadmapContent,
);
// Rebuild GitService after merge (branch HEAD changed)
this.rebuildGitService();
ctx.notify(
`Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
"info",
);
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
mode: "branch",
result: "success",
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
mode: "branch",
result: "error",
error: msg,
});
ctx.notify(`Milestone merge failed (branch mode): ${msg}`, "warning");
}
}
// ── Merge and Enter Next ───────────────────────────────────────────────
/**
* Milestone transition: merge the current milestone, then enter the next one.
*
* This is the pattern used when the loop detects that the active milestone
* has changed (e.g., current completed, next one is now active). The caller
* is responsible for re-deriving state between the merge and the enter.
*/
mergeAndEnterNext(
currentMilestoneId: string,
nextMilestoneId: string,
ctx: NotifyCtx,
): void {
debugLog("WorktreeResolver", {
action: "mergeAndEnterNext",
currentMilestoneId,
nextMilestoneId,
});
this.mergeAndExit(currentMilestoneId, ctx);
this.enterMilestone(nextMilestoneId, ctx);
}
}

View file

@ -12,7 +12,7 @@
* SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
*/
import { existsSync, lstatSync, readFileSync, utimesSync } from "node:fs";
import { existsSync, readFileSync, utimesSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js";
@ -56,13 +56,13 @@ export function setActiveMilestoneId(basePath: string, milestoneId: string | nul
* record when the user starts from a different branch (#300). Always a no-op
* if on a GSD slice branch.
*/
export function captureIntegrationBranch(basePath: string, milestoneId: string): void {
export function captureIntegrationBranch(basePath: string, milestoneId: string, options?: { commitDocs?: boolean }): void {
// In a worktree, the base branch is implicit (worktree/<name>).
// Writing it to META.json would leave stale metadata after merge back to main.
if (detectWorktreeName(basePath)) return;
const svc = getService(basePath);
const current = svc.getCurrentBranch();
writeIntegrationBranch(basePath, milestoneId, current);
writeIntegrationBranch(basePath, milestoneId, current, options);
}
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
@ -72,25 +72,6 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string):
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
*/
export function detectWorktreeName(basePath: string): string | null {
// Primary: use git metadata — .git file with gitdir: pointer
const gitPath = join(basePath, ".git");
try {
const stat = lstatSync(gitPath);
if (stat.isFile()) {
const content = readFileSync(gitPath, "utf-8").trim();
if (content.startsWith("gitdir:")) {
const gitdir = content.slice(7).trim();
// Git worktree gitdir format: <repo>/.git/worktrees/<name>
const parts = gitdir.replace(/\\/g, "/").split("/");
const wtIdx = parts.lastIndexOf("worktrees");
if (wtIdx !== -1 && wtIdx < parts.length - 1) {
return parts[wtIdx + 1] || null;
}
}
}
} catch { /* fall through */ }
// Fallback: path-based detection for legacy setups
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
@ -109,32 +90,14 @@ export function detectWorktreeName(basePath: string): string | null {
* operate against the real project root, not a worktree subdirectory.
*/
export function resolveProjectRoot(basePath: string): string {
// Primary: use git metadata to resolve the main worktree root
const gitPath = join(basePath, ".git");
try {
const stat = lstatSync(gitPath);
if (stat.isFile()) {
const content = readFileSync(gitPath, "utf-8").trim();
if (content.startsWith("gitdir:")) {
const gitdir = resolve(basePath, content.slice(7).trim());
// Git worktree gitdir: <repo>/.git/worktrees/<name>
// Walk up to <repo>
const parts = gitdir.replace(/\\/g, "/").split("/");
const wtIdx = parts.lastIndexOf("worktrees");
if (wtIdx >= 2 && parts[wtIdx - 1] === ".git") {
return parts.slice(0, wtIdx - 1).join("/");
}
}
}
} catch { /* fall through */ }
// Fallback: legacy path-based detection
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
if (idx === -1) return basePath;
const osSep = basePath.includes("\\") ? "\\" : "/";
const markerOs = `${osSep}.gsd${osSep}worktrees${osSep}`;
// Return the original path up to the .gsd/ marker (un-normalized)
// Account for potential OS-specific separators
const sep = basePath.includes("\\") ? "\\" : "/";
const markerOs = `${sep}.gsd${sep}worktrees${sep}`;
const idxOs = basePath.indexOf(markerOs);
if (idxOs !== -1) return basePath.slice(0, idxOs);
return basePath.slice(0, idx);

View file

@ -54,11 +54,10 @@ test("settings.json change emits settings-changed event", async () => {
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
await delay(200);
writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true }));
// Wait for debounce (300ms) + filesystem propagation
await delay(800);
await delay(600);
const matched = bus.events.filter((e) => e.channel === "settings-changed");
assert.ok(matched.length > 0, "should emit settings-changed event");
@ -69,10 +68,9 @@ test("auth.json change emits auth-changed event", async () => {
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
await delay(200);
writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" }));
await delay(800);
await delay(600);
const matched = bus.events.filter((e) => e.channel === "auth-changed");
assert.ok(matched.length > 0, "should emit auth-changed event");
@ -83,10 +81,9 @@ test("models.json change emits models-changed event", async () => {
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
await delay(200);
writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" }));
await delay(800);
await delay(600);
const matched = bus.events.filter((e) => e.channel === "models-changed");
assert.ok(matched.length > 0, "should emit models-changed event");
@ -136,7 +133,7 @@ test("debouncing coalesces rapid changes into one event", async () => {
for (let i = 0; i < 5; i++) {
writeFileSync(join(dir, "settings.json"), JSON.stringify({ i }));
}
await delay(800);
await delay(600);
const matched = bus.events.filter((e) => e.channel === "settings-changed");
assert.strictEqual(

View file

@ -17,9 +17,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
const projectRoot = process.cwd();
const loaderPath = join(projectRoot, "dist", "loader.js");
@ -88,6 +89,14 @@ function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
}
function createTempGitRepo(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, stdio: "pipe" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir, stdio: "pipe" });
return dir;
}
// ---------------------------------------------------------------------------
// 1. gsd --version outputs a semver string and exits 0
// ---------------------------------------------------------------------------
@ -503,6 +512,47 @@ test("gsd headless --timeout with negative value exits 1", async () => {
}
});
test("gsd headless query returns JSON from the built CLI", async () => {
const tmpDir = createTempGitRepo("gsd-e2e-query-");
try {
mkdirSync(join(tmpDir, ".gsd", "milestones"), { recursive: true });
const result = await runGsd(["headless", "query"], 10_000, {}, tmpDir);
assert.ok(!result.timedOut, "process should not hang");
assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`);
const combined = stripAnsi(result.stdout + result.stderr);
assertNoCrashMarkers(combined);
const snapshot = JSON.parse(result.stdout);
assert.equal(typeof snapshot.state?.phase, "string", "query output should include state.phase");
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
test("gsd worktree list loads the built worktree CLI without module errors", async () => {
const tmpDir = createTempGitRepo("gsd-e2e-worktree-");
try {
const result = await runGsd(["worktree", "list"], 10_000, {}, tmpDir);
assert.ok(!result.timedOut, "process should not hang");
assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`);
const combined = stripAnsi(result.stdout + result.stderr);
assertNoCrashMarkers(combined);
assert.ok(
combined.includes("No worktrees") || combined.includes("Worktrees"),
`expected worktree CLI output, got:\n${combined.slice(0, 500)}`,
);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
// ===========================================================================
// SUBCOMMAND HELP COMPLETENESS
// ===========================================================================

View file

@ -1,4 +1,4 @@
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, statSync, symlinkSync, unlinkSync } from "node:fs";
import { delimiter, join } from "node:path";
type ManagedTool = "fd" | "rg";
@ -40,6 +40,43 @@ function isRegularFile(path: string): boolean {
}
}
function pathExistsIncludingBrokenSymlink(path: string): boolean {
try {
lstatSync(path);
return true;
} catch {
return false;
}
}
function isBrokenSymlink(path: string): boolean {
try {
const stat = lstatSync(path);
if (!stat.isSymbolicLink()) return false;
try {
statSync(path);
return false;
} catch {
return true;
}
} catch {
return false;
}
}
function removeTargetPath(path: string): void {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
unlinkSync(path);
return;
}
rmSync(path, { force: true });
} catch {
// Path already absent.
}
}
export function resolveToolFromPath(tool: ManagedTool, pathValue: string | undefined = process.env.PATH): string | null {
const spec = TOOL_SPECS[tool];
for (const dir of splitPath(pathValue)) {
@ -57,18 +94,27 @@ export function resolveToolFromPath(tool: ManagedTool, pathValue: string | undef
function provisionTool(targetDir: string, tool: ManagedTool, sourcePath: string): string {
const targetPath = join(targetDir, TOOL_SPECS[tool].targetName);
if (existsSync(targetPath)) return targetPath;
const brokenTarget = isBrokenSymlink(targetPath);
if (pathExistsIncludingBrokenSymlink(targetPath)) {
if (!brokenTarget) return targetPath;
removeTargetPath(targetPath);
}
mkdirSync(targetDir, { recursive: true });
try {
symlinkSync(sourcePath, targetPath);
} catch {
rmSync(targetPath, { force: true });
copyFileSync(sourcePath, targetPath);
chmodSync(targetPath, 0o755);
if (!brokenTarget) {
try {
symlinkSync(sourcePath, targetPath);
return targetPath;
} catch {
// Fall back to copying below.
}
}
removeTargetPath(targetPath);
copyFileSync(sourcePath, targetPath);
chmodSync(targetPath, 0o755);
return targetPath;
}
@ -76,7 +122,8 @@ export function ensureManagedTools(targetDir: string, pathValue: string | undefi
const provisioned: string[] = [];
for (const tool of Object.keys(TOOL_SPECS) as ManagedTool[]) {
if (existsSync(join(targetDir, TOOL_SPECS[tool].targetName))) continue;
const targetPath = join(targetDir, TOOL_SPECS[tool].targetName);
if (pathExistsIncludingBrokenSymlink(targetPath) && !isBrokenSymlink(targetPath)) continue;
const sourcePath = resolveToolFromPath(tool, pathValue);
if (!sourcePath) continue;
provisioned.push(provisionTool(targetDir, tool, sourcePath));

View file

@ -21,12 +21,13 @@
import chalk from 'chalk'
import { createJiti } from '@mariozechner/jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { generateWorktreeName } from './worktree-name-gen.js'
import { existsSync } from 'node:fs'
import { resolveBundledSourceResource } from './bundled-resource-path.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false })
const gsdExtensionPath = (...segments: string[]) =>
resolveBundledSourceResource(import.meta.url, 'extensions', 'gsd', ...segments)
// Lazily-loaded extension modules (loaded once on first use via jiti)
let _ext: ExtensionModules | null = null
@ -51,11 +52,11 @@ interface ExtensionModules {
async function loadExtensionModules(): Promise<ExtensionModules> {
if (_ext) return _ext
const [wtMgr, autoWt, gitBridge, gitSvc, wt] = await Promise.all([
jiti.import(join(__dirname, 'resources/extensions/gsd/worktree-manager.ts'), {}) as Promise<any>,
jiti.import(join(__dirname, 'resources/extensions/gsd/auto-worktree.ts'), {}) as Promise<any>,
jiti.import(join(__dirname, 'resources/extensions/gsd/native-git-bridge.ts'), {}) as Promise<any>,
jiti.import(join(__dirname, 'resources/extensions/gsd/git-service.ts'), {}) as Promise<any>,
jiti.import(join(__dirname, 'resources/extensions/gsd/worktree.ts'), {}) as Promise<any>,
jiti.import(gsdExtensionPath('worktree-manager.ts'), {}) as Promise<any>,
jiti.import(gsdExtensionPath('auto-worktree.ts'), {}) as Promise<any>,
jiti.import(gsdExtensionPath('native-git-bridge.ts'), {}) as Promise<any>,
jiti.import(gsdExtensionPath('git-service.ts'), {}) as Promise<any>,
jiti.import(gsdExtensionPath('worktree.ts'), {}) as Promise<any>,
])
_ext = {
createWorktree: wtMgr.createWorktree,