diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 08c63026b..4655aab80 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -79,10 +79,20 @@ import { * as ghosts, causing auto-mode to skip them entirely. */ export function isGhostMilestone(basePath: string, mid: string): boolean { - // If the milestone has a DB row, it's a known milestone — not a ghost. + // If the milestone has a DB row, it's usually a known milestone — not a ghost. + // Exception: a "queued" row with no disk artifacts is a phantom from + // gsd_milestone_generate_id that was never planned (#3645). if (isDbAvailable()) { const dbRow = getMilestone(mid); - if (dbRow) return false; + if (dbRow) { + if (dbRow.status === 'queued') { + const hasContent = resolveMilestoneFile(basePath, mid, "CONTEXT") + || resolveMilestoneFile(basePath, mid, "ROADMAP") + || resolveMilestoneFile(basePath, mid, "SUMMARY"); + return !hasContent; + } + return false; + } } // If a worktree exists for this milestone, it was legitimately created. diff --git a/src/resources/extensions/gsd/tests/derive-state-db.test.ts b/src/resources/extensions/gsd/tests/derive-state-db.test.ts index 81466f16a..08ea28f8a 100644 --- a/src/resources/extensions/gsd/tests/derive-state-db.test.ts +++ b/src/resources/extensions/gsd/tests/derive-state-db.test.ts @@ -1098,16 +1098,17 @@ describe('derive-state-db', async () => { // M001: complete milestone with summary writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.'); - // M002: queued milestone — directory exists, no content files, but has DB row + // M002: queued milestone — directory exists with CONTEXT file and DB row mkdirSync(join(base, '.gsd', 'milestones', 'M002', 'slices'), { recursive: true }); + writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002 Context\n\nPlanned milestone.'); // DB has both M001 complete and M002 queued openDatabase(':memory:'); insertMilestone({ id: 'M001', title: 'First', status: 'complete' }); insertMilestone({ id: 'M002', title: 'Second', status: 'queued' }); - // isGhostMilestone should NOT treat M002 as ghost when DB row exists - assert.ok(!isGhostMilestone(base, 'M002'), 'ghost-dbrow: M002 with DB row is NOT a ghost'); + // isGhostMilestone should NOT treat M002 as ghost when DB row + content files exist + assert.ok(!isGhostMilestone(base, 'M002'), 'ghost-dbrow: M002 with DB row and content is NOT a ghost'); invalidateStateCache(); const dbState = await deriveStateFromDb(base); diff --git a/src/resources/extensions/gsd/tests/phantom-ghost-detection.test.ts b/src/resources/extensions/gsd/tests/phantom-ghost-detection.test.ts new file mode 100644 index 000000000..06878f25a --- /dev/null +++ b/src/resources/extensions/gsd/tests/phantom-ghost-detection.test.ts @@ -0,0 +1,55 @@ +/** + * Regression test for #3671 — isGhostMilestone detects phantom queued rows + * + * gsd_milestone_generate_id inserts a DB row with status "queued" as a side + * effect. If the milestone is never planned, isGhostMilestone previously + * returned false for any milestone with a DB row, blocking the state machine. + * + * The fix makes isGhostMilestone treat a "queued" DB row with no disk + * artifacts (CONTEXT, ROADMAP, SUMMARY) as a ghost. + * + * This structural test verifies the dbRow.status === 'queued' guard exists. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const source = readFileSync(join(__dirname, '..', 'state.ts'), 'utf-8'); + +describe('isGhostMilestone phantom queued detection (#3671)', () => { + test('isGhostMilestone function exists', () => { + assert.match(source, /export function isGhostMilestone\(/, + 'isGhostMilestone should be exported'); + }); + + test('checks dbRow.status === queued', () => { + assert.match(source, /dbRow\.status\s*===\s*['"]queued['"]/, + 'isGhostMilestone should check dbRow.status === "queued"'); + }); + + test('checks for CONTEXT disk artifact', () => { + assert.match(source, /resolveMilestoneFile\(basePath,\s*mid,\s*["']CONTEXT["']\)/, + 'should check for CONTEXT file'); + }); + + test('checks for ROADMAP disk artifact', () => { + assert.match(source, /resolveMilestoneFile\(basePath,\s*mid,\s*["']ROADMAP["']\)/, + 'should check for ROADMAP file'); + }); + + test('checks for SUMMARY disk artifact', () => { + assert.match(source, /resolveMilestoneFile\(basePath,\s*mid,\s*["']SUMMARY["']\)/, + 'should check for SUMMARY file'); + }); + + test('returns !hasContent for queued rows (ghost if no artifacts)', () => { + assert.match(source, /return !hasContent/, + 'should return !hasContent for queued phantom milestones'); + }); +});