diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index ad38c8a27..d29ba0b48 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -2102,7 +2102,7 @@ async function dispatchNextUnit( // Lightweight check for critical issues that would cause the next unit // to fail or corrupt state. Auto-heals what it can, blocks on the rest. try { - const healthGate = preDispatchHealthGate(basePath); + const healthGate = await preDispatchHealthGate(basePath); if (healthGate.fixesApplied.length > 0) { ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info"); } diff --git a/src/resources/extensions/gsd/doctor-proactive.ts b/src/resources/extensions/gsd/doctor-proactive.ts index 77fbf5a26..810cd46aa 100644 --- a/src/resources/extensions/gsd/doctor-proactive.ts +++ b/src/resources/extensions/gsd/doctor-proactive.ts @@ -19,6 +19,7 @@ import { join } from "node:path"; import { gsdRoot, resolveGsdRootFile } from "./paths.js"; import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js"; import { abortAndReset } from "./git-self-heal.js"; +import { rebuildState } from "./doctor.js"; // ── Health Score Tracking ────────────────────────────────────────────────── @@ -131,7 +132,7 @@ export interface PreDispatchHealthResult { * * Returns { proceed: true } if dispatch should continue. */ -export function preDispatchHealthGate(basePath: string): PreDispatchHealthResult { +export async function preDispatchHealthGate(basePath: string): Promise { const issues: string[] = []; const fixesApplied: string[] = []; @@ -172,17 +173,17 @@ export function preDispatchHealthGate(basePath: string): PreDispatchHealthResult } // ── STATE.md existence check ── - // If STATE.md is missing, deriveState will still work but the LLM - // may get confused. Rebuild it silently. + // If STATE.md is missing, rebuild it now so the next unit has accurate + // context. Non-blocking — if the rebuild throws, dispatch continues anyway. try { const stateFile = resolveGsdRootFile(basePath, "STATE"); const milestonesDir = join(gsdRoot(basePath), "milestones"); if (existsSync(milestonesDir) && !existsSync(stateFile)) { - issues.push("STATE.md missing — will rebuild after this unit"); - // Don't block dispatch — rebuilding happens in post-hook + await rebuildState(basePath); + fixesApplied.push("rebuilt missing STATE.md before dispatch"); } } catch { - // Non-fatal + // Non-fatal — dispatch continues without STATE.md if rebuild fails } // If we had critical issues that couldn't be auto-healed, block dispatch diff --git a/src/resources/extensions/gsd/tests/doctor-proactive.test.ts b/src/resources/extensions/gsd/tests/doctor-proactive.test.ts index b532edc5f..4e4d86bb8 100644 --- a/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-proactive.test.ts @@ -188,7 +188,7 @@ async function main(): Promise { cleanups.push(dir); mkdirSync(join(dir, ".gsd"), { recursive: true }); - const result = preDispatchHealthGate(dir); + const result = await preDispatchHealthGate(dir); assertTrue(result.proceed, "gate passes on clean state"); assertEq(result.issues.length, 0, "no issues on clean state"); } @@ -206,7 +206,7 @@ async function main(): Promise { unitStartedAt: "2026-03-10T00:01:00Z", completedUnits: 3, })); - const result = preDispatchHealthGate(dir); + const result = await preDispatchHealthGate(dir); assertTrue(result.proceed, "gate passes after auto-clearing stale lock"); assertTrue(result.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "reports lock cleared"); assertTrue(!existsSync(join(dir, ".gsd", "auto.lock")), "lock file removed"); @@ -222,7 +222,7 @@ async function main(): Promise { const headHash = run("git rev-parse HEAD", dir); writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n"); - const result = preDispatchHealthGate(dir); + const result = await preDispatchHealthGate(dir); assertTrue(result.proceed, "gate passes after auto-healing merge state"); assertTrue(result.fixesApplied.some(f => f.includes("cleaned merge state")), "reports merge state cleaned"); assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed"); @@ -231,6 +231,26 @@ async function main(): Promise { console.log(" (skipped on Windows)"); } + console.log("\n=== health gate: STATE.md missing — auto-healed ==="); + { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-"))); + cleanups.push(dir); + // Minimal .gsd structure: milestones dir exists but no STATE.md + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + + const stateFile = join(dir, ".gsd", "STATE.md"); + assertTrue(!existsSync(stateFile), "STATE.md does not exist before gate"); + + const result = await preDispatchHealthGate(dir); + assertTrue(result.proceed, "gate passes after rebuilding STATE.md"); + assertTrue( + result.fixesApplied.some(f => f.includes("rebuilt missing STATE.md")), + "reports STATE.md rebuilt", + ); + assertTrue(existsSync(stateFile), "STATE.md created by auto-heal"); + assertTrue(result.issues.length === 0, "no blocking issues after heal"); + } + } finally { resetProactiveHealing(); for (const dir of cleanups) { diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index 6e36ae2b2..dceb25fc2 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -13,7 +13,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -235,3 +235,159 @@ test("loadStoredEnvKeys does not overwrite existing env vars", async () => { rmSync(tmp, { recursive: true, force: true }); } }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. State derivation — Gap 2 +// ═══════════════════════════════════════════════════════════════════════════ + +test("deriveState returns pre-planning phase for empty .gsd/ directory", async () => { + const { deriveState } = await import("../resources/extensions/gsd/state.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-state-smoke-")); + + // Create minimal .gsd/ structure with no milestones + mkdirSync(join(tmp, ".gsd"), { recursive: true }); + + try { + const state = await deriveState(tmp); + + assert.equal(state.phase, "pre-planning", + `expected pre-planning phase for empty .gsd/, got: ${state.phase}`); + assert.equal(state.activeMilestone, null, "no active milestone"); + assert.equal(state.activeSlice, null, "no active slice"); + assert.equal(state.activeTask, null, "no active task"); + assert.ok(Array.isArray(state.blockers), "blockers is an array"); + assert.ok(Array.isArray(state.registry), "registry is an array"); + assert.equal(state.registry.length, 0, "empty registry"); + assert.ok(typeof state.nextAction === "string", "nextAction is a string"); + assert.ok(state.nextAction.length > 0, "nextAction is non-empty"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState returns pre-planning phase when no .gsd/ directory exists", async () => { + const { deriveState } = await import("../resources/extensions/gsd/state.ts"); + // Use a temp dir with no .gsd/ subdirectory at all + const tmp = mkdtempSync(join(tmpdir(), "gsd-state-nogsd-")); + + try { + // Should not throw — missing .gsd/ is a valid "no project" state + const state = await deriveState(tmp); + + assert.equal(state.phase, "pre-planning", + `expected pre-planning phase when .gsd/ absent, got: ${state.phase}`); + assert.equal(state.activeMilestone, null, "no active milestone"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState shape is structurally complete", async () => { + const { deriveState } = await import("../resources/extensions/gsd/state.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-state-shape-")); + mkdirSync(join(tmp, ".gsd"), { recursive: true }); + + try { + const state = await deriveState(tmp); + + // All required fields present + const requiredFields = [ + "phase", "activeMilestone", "activeSlice", "activeTask", + "recentDecisions", "blockers", "nextAction", "registry", + ] as const; + for (const field of requiredFields) { + assert.ok(field in state, `state.${field} should be present`); + } + + // phase is a known string value + const validPhases = [ + "pre-planning", "needs-discussion", "researching", "planning", + "executing", "summarizing", "replanning-slice", "validating-milestone", + "completing-milestone", "complete", "blocked", + ]; + assert.ok(validPhases.includes(state.phase), + `state.phase '${state.phase}' should be a known phase`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Doctor health checks — Gap 3 +// ═══════════════════════════════════════════════════════════════════════════ + +test("runGSDDoctor completes without throwing on empty .gsd/ directory", async () => { + const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-smoke-")); + mkdirSync(join(tmp, ".gsd"), { recursive: true }); + + try { + // audit-only mode (fix: false) — should never throw + const report = await runGSDDoctor(tmp, { fix: false }); + + // Structural assertions on the DoctorReport + assert.ok(typeof report === "object" && report !== null, "report is an object"); + assert.ok("ok" in report, "report has ok field"); + assert.ok("issues" in report, "report has issues field"); + assert.ok("fixesApplied" in report, "report has fixesApplied field"); + assert.ok("basePath" in report, "report has basePath field"); + assert.ok(Array.isArray(report.issues), "report.issues is an array"); + assert.ok(Array.isArray(report.fixesApplied), "report.fixesApplied is an array"); + assert.equal(typeof report.ok, "boolean", "report.ok is a boolean"); + assert.equal(report.fixesApplied.length, 0, "no fixes applied in audit mode"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("runGSDDoctor issue objects have required fields", async () => { + const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-fields-")); + mkdirSync(join(tmp, ".gsd"), { recursive: true }); + + // Create a milestone dir with no ROADMAP.md to force a missing_roadmap issue + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# Context\n"); + + try { + const report = await runGSDDoctor(tmp, { fix: false }); + + // Should find at least one issue (missing roadmap for M001) + assert.ok(report.issues.length > 0, "expected at least one issue for milestone missing ROADMAP.md"); + + // Verify structure of each issue + for (const issue of report.issues) { + assert.ok(typeof issue.severity === "string", "issue.severity is a string"); + assert.ok(["info", "warning", "error"].includes(issue.severity), + `issue.severity '${issue.severity}' should be info|warning|error`); + assert.ok(typeof issue.code === "string", "issue.code is a string"); + assert.ok(typeof issue.message === "string", "issue.message is a string"); + assert.ok(issue.message.length > 0, "issue.message is non-empty"); + assert.ok(typeof issue.fixable === "boolean", "issue.fixable is a boolean"); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("runGSDDoctor with fix:false never modifies the filesystem", async () => { + const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-readonly-")); + const gsdDir = join(tmp, ".gsd"); + mkdirSync(gsdDir, { recursive: true }); + + // Write a sentinel file — doctor must not delete or modify it + const sentinelPath = join(gsdDir, "SENTINEL.md"); + writeFileSync(sentinelPath, "# sentinel\n"); + + try { + await runGSDDoctor(tmp, { fix: false }); + + assert.ok(existsSync(sentinelPath), "sentinel file still exists after audit-only run"); + const content = readFileSync(sentinelPath, "utf-8"); + assert.equal(content, "# sentinel\n", "sentinel file content unchanged"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +});