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) <noreply@anthropic.com>
This commit is contained in:
parent
d93956ba4e
commit
1e0e974792
3 changed files with 100 additions and 11 deletions
|
|
@ -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<string | n
|
|||
// No roadmap — but if a summary exists, the milestone is already complete
|
||||
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
||||
if (summaryFile) continue; // completed milestone, skip
|
||||
if (isGhostMilestone(basePath, mid)) continue; // ghost dir — skip
|
||||
return mid; // No roadmap and no summary — milestone is incomplete
|
||||
// Note: draft-awareness (CONTEXT-DRAFT.md) is handled in deriveState(), not here.
|
||||
// A draft milestone is still "active" — this function only determines which milestone is current.
|
||||
|
|
@ -318,6 +333,9 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
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<GSDState> {
|
|||
},
|
||||
};
|
||||
}
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -192,8 +192,9 @@ async function main(): Promise<void> {
|
|||
// 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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 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<void> {
|
|||
{
|
||||
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.');
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
{
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue