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