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:
parent
f2657e1ba0
commit
d761e45a41
87 changed files with 9779 additions and 11255 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
18
src/bundled-resource-path.ts
Normal file
18
src/bundled-resource-path.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
1665
src/resources/extensions/gsd/auto-loop.ts
Normal file
1665
src/resources/extensions/gsd/auto-loop.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
204
src/resources/extensions/gsd/auto-worktree-sync.ts
Normal file
204
src/resources/extensions/gsd/auto-worktree-sync.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ token_profile:
|
|||
phases:
|
||||
skip_research:
|
||||
skip_reassess:
|
||||
reassess_after_slice:
|
||||
skip_slice_research:
|
||||
dynamic_routing:
|
||||
enabled:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
1458
src/resources/extensions/gsd/tests/auto-loop.test.ts
Normal file
1458
src/resources/extensions/gsd/tests/auto-loop.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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)",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
181
src/resources/extensions/gsd/tests/sidecar-queue.test.ts
Normal file
181
src/resources/extensions/gsd/tests/sidecar-queue.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
442
src/resources/extensions/gsd/tests/worktree-db.test.ts
Normal file
442
src/resources/extensions/gsd/tests/worktree-db.test.ts
Normal 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();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
705
src/resources/extensions/gsd/tests/worktree-resolver.test.ts
Normal file
705
src/resources/extensions/gsd/tests/worktree-resolver.test.ts
Normal 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
|
||||
});
|
||||
|
|
@ -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 S01–S03
|
||||
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 S01–S02 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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
485
src/resources/extensions/gsd/worktree-resolver.ts
Normal file
485
src/resources/extensions/gsd/worktree-resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue