From 1e0e974792e3dea3578c1a92bc4c385b27ff66a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sat, 21 Mar 2026 12:23:54 -0600 Subject: [PATCH] fix: detect and skip ghost milestone directories in deriveState() (#1817) A milestone directory containing only META.json (no CONTEXT, ROADMAP, or SUMMARY) is an uninitialized "ghost" that causes auto-mode to stall or falsely report all milestones complete. Add isGhostMilestone() check in both getActiveMilestoneId() and deriveState() to skip these directories. Also handle the edge case where all milestones are ghosts by returning pre-planning instead of complete. Closes #1662 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/state.ts | 35 +++++++++++ .../gsd/tests/derive-state-draft.test.ts | 18 +++--- .../extensions/gsd/tests/derive-state.test.ts | 58 ++++++++++++++++++- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 382addc35..738d13a88 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -38,6 +38,20 @@ import { join, resolve } from 'path'; import { existsSync, readdirSync } from 'node:fs'; import { debugCount, debugTime } from './debug-logger.js'; +/** + * A "ghost" milestone directory contains only META.json (and no substantive + * files like CONTEXT, CONTEXT-DRAFT, ROADMAP, or SUMMARY). These appear when + * a milestone is created but never initialised. Treating them as active causes + * auto-mode to stall or falsely declare completion. + */ +export function isGhostMilestone(basePath: string, mid: string): boolean { + const context = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draft = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const roadmap = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const summary = resolveMilestoneFile(basePath, mid, "SUMMARY"); + return !context && !draft && !roadmap && !summary; +} + // ─── Query Functions ─────────────────────────────────────────────────────── /** @@ -121,6 +135,7 @@ export async function getActiveMilestoneId(basePath: string): Promise { completeMilestoneIds.add(mid); continue; } + // Ghost milestone (only META.json, no CONTEXT/ROADMAP/SUMMARY) — skip entirely + if (isGhostMilestone(basePath, mid)) continue; + // No roadmap and no summary — treat as incomplete/active if (!activeMilestoneFound) { // Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones. @@ -469,6 +487,23 @@ async function _deriveStateImpl(basePath: string): Promise { }, }; } + // All real milestones were ghosts (empty registry) → treat as pre-planning + if (registry.length === 0) { + return { + activeMilestone: null, + activeSlice: null, + activeTask: null, + phase: 'pre-planning', + recentDecisions: [], + blockers: [], + nextAction: 'No milestones found. Run /gsd to create one.', + registry: [], + requirements, + progress: { + milestones: { done: 0, total: 0 }, + }, + }; + } // All milestones complete const lastEntry = registry[registry.length - 1]; const activeReqs = requirements.active ?? 0; diff --git a/src/resources/extensions/gsd/tests/derive-state-draft.test.ts b/src/resources/extensions/gsd/tests/derive-state-draft.test.ts index 5b17f1b40..7dc596ad6 100644 --- a/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +++ b/src/resources/extensions/gsd/tests/derive-state-draft.test.ts @@ -192,8 +192,9 @@ async function main(): Promise { // M002: draft only — should become active with needs-discussion writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.'); - // M003: blank milestone directory — should be pending + // M003: milestone directory with CONTEXT — should be pending mkdirSync(join(base, '.gsd', 'milestones', 'M003'), { recursive: true }); + writeFileSync(join(base, '.gsd', 'milestones', 'M003', 'M003-CONTEXT.md'), '# M003\n\nPending milestone.'); const state = await deriveState(base); @@ -247,19 +248,19 @@ async function main(): Promise { } } - // ─── Test 7: Empty milestone dir (no files at all) → pre-planning ───── - console.log('\n=== empty milestone dir (no files) → pre-planning ==='); + // ─── Test 7: Empty milestone dir (ghost — no files at all) → skipped ─── + console.log('\n=== empty milestone dir (ghost) → skipped, pre-planning ==='); { const base = createFixtureBase(); try { - // M001: just a directory, no files at all + // M001: just a directory, no files at all — ghost milestone, skipped mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); const state = await deriveState(base); - assertEq(state.phase, 'pre-planning', 'phase is pre-planning for blank milestone'); - assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001'); - assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active'); + assertEq(state.phase, 'pre-planning', 'phase is pre-planning for ghost milestone'); + assertEq(state.activeMilestone, null, 'activeMilestone is null (ghost skipped)'); + assertEq(state.registry.length, 0, 'registry is empty (ghost skipped)'); } finally { cleanup(base); } @@ -272,8 +273,9 @@ async function main(): Promise { { const base = createFixtureBase(); try { - // M001: blank (no roadmap, no summary) → becomes active first + // M001: has CONTEXT but no roadmap/summary → becomes active first mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); + writeFileSync(join(base, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'), '# M001\n\nFirst milestone.'); // M002: has CONTEXT-DRAFT but isn't active (M001 is first) writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.'); diff --git a/src/resources/extensions/gsd/tests/derive-state.test.ts b/src/resources/extensions/gsd/tests/derive-state.test.ts index 55d213419..550cb567f 100644 --- a/src/resources/extensions/gsd/tests/derive-state.test.ts +++ b/src/resources/extensions/gsd/tests/derive-state.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { deriveState, isSliceComplete, isMilestoneComplete } from '../state.ts'; +import { deriveState, isSliceComplete, isMilestoneComplete, isGhostMilestone } from '../state.ts'; import { createTestContext } from './test-helpers.ts'; const { assertEq, assertTrue, report } = createTestContext(); @@ -91,8 +91,9 @@ async function main(): Promise { { const base = createFixtureBase(); try { - // Create M001 directory but no roadmap file + // Create M001 directory with CONTEXT but no roadmap file mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); + writeFileSync(join(base, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'), '# First Milestone\n\nContext for M001.'); const state = await deriveState(base); @@ -494,8 +495,9 @@ Continue from step 2. > After this: Done. `); - // M003: just a dir (no roadmap → pending since M002 is already active) + // M003: dir with CONTEXT but no roadmap → pending since M002 is already active mkdirSync(join(base, '.gsd', 'milestones', 'M003'), { recursive: true }); + writeFileSync(join(base, '.gsd', 'milestones', 'M003', 'M003-CONTEXT.md'), '# Third Milestone\n\nContext for M003.'); const state = await deriveState(base); @@ -905,6 +907,56 @@ slice: S01 } } + // ─── Test: ghost milestone (only META.json) is skipped ─────────────── + console.log('\n=== ghost milestone (only META.json) is skipped ==='); + { + const base = createFixtureBase(); + try { + // Create a ghost milestone directory with only META.json + const ghostDir = join(base, '.gsd', 'milestones', 'M001'); + mkdirSync(ghostDir, { recursive: true }); + writeFileSync(join(ghostDir, 'META.json'), JSON.stringify({ id: 'M001' })); + + // isGhostMilestone should detect it + assertTrue(isGhostMilestone(base, 'M001'), 'M001 is a ghost milestone'); + + // deriveState should treat this as pre-planning (no real milestones) + const state = await deriveState(base); + assertEq(state.phase, 'pre-planning', 'ghost-only: phase is pre-planning'); + assertEq(state.activeMilestone, null, 'ghost-only: no active milestone'); + assertEq(state.registry.length, 0, 'ghost-only: registry is empty'); + } finally { + cleanup(base); + } + } + + // ─── Test: ghost milestone skipped when real milestones exist ────────── + console.log('\n=== ghost milestone skipped alongside real milestones ==='); + { + const base = createFixtureBase(); + try { + // M001: ghost (only META.json) + const ghostDir = join(base, '.gsd', 'milestones', 'M001'); + mkdirSync(ghostDir, { recursive: true }); + writeFileSync(join(ghostDir, 'META.json'), JSON.stringify({ id: 'M001' })); + + // M002: real milestone with a CONTEXT file + const realDir = join(base, '.gsd', 'milestones', 'M002'); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, 'M002-CONTEXT.md'), '# Real Milestone\n\nThis has content.'); + + const state = await deriveState(base); + assertEq(state.activeMilestone?.id, 'M002', 'ghost+real: active milestone is M002'); + // Ghost M001 should not appear in the registry + const m001Entry = state.registry.find(e => e.id === 'M001'); + assertEq(m001Entry, undefined, 'ghost+real: M001 not in registry'); + assertEq(state.registry.length, 1, 'ghost+real: registry has 1 entry'); + assertEq(state.registry[0]?.status, 'active', 'ghost+real: M002 is active'); + } finally { + cleanup(base); + } + } + report(); }