diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index ecde4d5c8..e470eec08 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -340,7 +340,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) { if (!_newPhaseId) error('--id required when phase_naming is "custom"'); _dirName = `${prefix}${_newPhaseId}-${slug}`; } else { - // Sequential mode: find highest integer phase number (in current milestone only) + // Sequential mode: find highest integer phase number from two sources: + // 1. ROADMAP.md (current milestone only) + // 2. .planning/phases/ on disk (orphan directories not tracked in roadmap) // Skip 999.x backlog phases — they live outside the active sequence const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; let maxPhase = 0; @@ -351,6 +353,21 @@ function cmdPhaseAdd(cwd, description, raw, customId) { if (num > maxPhase) maxPhase = num; } + // Also scan .planning/phases/ for orphan directories not tracked in ROADMAP. + // Directory names follow: [PREFIX-]NN-slug (e.g. 03-api or CK-05-old-feature). + // Strip the optional project_code prefix before extracting the leading integer. + const phasesOnDisk = path.join(planningDir(cwd), 'phases'); + if (fs.existsSync(phasesOnDisk)) { + const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/; + for (const entry of fs.readdirSync(phasesOnDisk)) { + const match = entry.match(dirNumPattern); + if (!match) continue; + const num = parseInt(match[1], 10); + if (num >= 999) continue; // skip backlog orphans + if (num > maxPhase) maxPhase = num; + } + } + _newPhaseId = maxPhase + 1; const paddedNum = String(_newPhaseId).padStart(2, '0'); _dirName = `${prefix}${paddedNum}-${slug}`; diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index c06cf3d9c..338fb2596 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -694,6 +694,117 @@ describe('phase add command', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// phase add — orphan directory collision prevention (#2026) +// ───────────────────────────────────────────────────────────────────────────── + +describe('phase add — orphan directory collision prevention (#2026)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('orphan directory with higher number than ROADMAP pushes maxPhase up', () => { + // Orphan directory 05-orphan exists on disk but is NOT in ROADMAP.md + const orphanDir = path.join(tmpDir, '.planning', 'phases', '05-orphan'); + fs.mkdirSync(orphanDir, { recursive: true }); + fs.writeFileSync(path.join(orphanDir, 'SUMMARY.md'), 'existing work'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '## Milestone v1', + '### Phase 1: First phase', + '**Plans:** 0 plans', + '---', + ].join('\n') + ); + + const result = runGsdTools('phase add dashboard', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // ROADMAP max is 1, but orphan 05-orphan means disk max is 5 → new phase = 6 + assert.strictEqual(output.phase_number, 6, 'should be phase 6 (orphan 05 pushes max to 5)'); + + // The new directory must be 06-dashboard, not 02-dashboard + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard')), + 'new phase directory must be 06-dashboard, not collide with orphan 05-orphan' + ); + + // The orphan directory must be untouched + assert.ok( + fs.existsSync(path.join(orphanDir, 'SUMMARY.md')), + 'orphan directory content must be preserved (not overwritten)' + ); + }); + + test('orphan directories with 999.x prefix are skipped when calculating disk max', () => { + // 999.x backlog orphans must not inflate the next sequential phase number + const backlogOrphan = path.join(tmpDir, '.planning', 'phases', '999-backlog-stuff'); + fs.mkdirSync(backlogOrphan, { recursive: true }); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '### Phase 1: Foundation', + '**Plans:** 0 plans', + '---', + ].join('\n') + ); + + const result = runGsdTools('phase add new-feature', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // ROADMAP max is 1, disk orphan is 999 (backlog) → should be ignored → new phase = 2 + assert.strictEqual(output.phase_number, 2, 'backlog 999.x orphan must not inflate phase count'); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-new-feature')), + 'new phase directory should be 02-new-feature' + ); + }); + + test('project_code prefix in orphan directory name is stripped before comparing', () => { + // Orphan directory has project_code prefix e.g. CK-05-orphan + const orphanDir = path.join(tmpDir, '.planning', 'phases', 'CK-05-old-feature'); + fs.mkdirSync(orphanDir, { recursive: true }); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'CK' }) + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '### Phase 1: Foundation', + '**Plans:** 0 plans', + '---', + ].join('\n') + ); + + const result = runGsdTools('phase add new-feature', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // ROADMAP max is 1, disk has CK-05-old-feature → strip prefix → disk max is 5 → new phase = 6 + assert.strictEqual(output.phase_number, 6, 'project_code prefix must be stripped before disk max calculation'); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-06-new-feature')), + 'new phase directory must be CK-06-new-feature' + ); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // phase add with project_code prefix // ─────────────────────────────────────────────────────────────────────────────