diff --git a/sdk/src/query/phase-lifecycle.test.ts b/sdk/src/query/phase-lifecycle.test.ts index 9a4a0200d..256e7cc2f 100644 --- a/sdk/src/query/phase-lifecycle.test.ts +++ b/sdk/src/query/phase-lifecycle.test.ts @@ -245,6 +245,77 @@ describe('phaseAdd', () => { expect(phaseIdx).toBeLessThan(sepIdx); expect(phaseIdx).toBeGreaterThan(0); }); + + it('detects max phase from bullet checklist format (regression #2726)', async () => { + const { phaseAdd } = await import('./phase-lifecycle.js'); + + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v5.0', + '', + '- [x] Phase 76: Data Import', + '- [x] Phase 77: Data Transform', + '- [ ] Phase 88: Final Cleanup', + '', + ].join('\n'); + + await setupTestProject(tmpDir, { + roadmap, + state: MINIMAL_STATE, + phases: [], + }); + + const result = await phaseAdd(['new-feature'], tmpDir); + const data = result.data as Record; + + expect(data.phase_number).toBe(89); + expect(data.padded).toBe('89'); + }); + + it('detects max phase from bold inline format (regression #2726)', async () => { + const { phaseAdd } = await import('./phase-lifecycle.js'); + + const roadmap = [ + '# Roadmap', + '', + '## Current Milestone: v5.0', + '', + '**Phase 50: Core Infrastructure**', + '**Phase 51: API Layer**', + '', + ].join('\n'); + + await setupTestProject(tmpDir, { + roadmap, + state: MINIMAL_STATE, + phases: [], + }); + + const result = await phaseAdd(['new-feature'], tmpDir); + const data = result.data as Record; + + expect(data.phase_number).toBe(52); + }); + + it('falls back to filesystem scan when no phase matches in ROADMAP (regression #2726)', async () => { + const { phaseAdd } = await import('./phase-lifecycle.js'); + + // ROADMAP with no recognizable phase entries + const roadmap = '# Roadmap\n\n## Current Milestone: v5.0\n\nSome content without phases\n'; + + await setupTestProject(tmpDir, { + roadmap, + state: MINIMAL_STATE, + phases: ['45-legacy-phase', '46-another-phase'], + }); + + const result = await phaseAdd(['new-feature'], tmpDir); + const data = result.data as Record; + + // Should detect phases 45 and 46 on disk, so new phase = 47 + expect(data.phase_number).toBe(47); + }); }); // ─── phaseAddBatch ───────────────────────────────────────────────────── diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index 5211c46a6..f3b4f4236 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -191,7 +191,8 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { } else { // Sequential mode: find highest integer phase number (in current milestone only) // Skip 999.x backlog phases — they live outside the active sequence - const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; + // Matches heading (## Phase N:), bullet checklist (- [x] Phase N:), and bold (**Phase N:**) + const phasePattern = /(?:^|\n)\s*(?:[-*]\s*(?:\[[x ]\]\s*)?|#{2,4}\s*|\*{1,2}\s*)Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; let maxPhase = 0; let m: RegExpExecArray | null; while ((m = phasePattern.exec(content)) !== null) { @@ -200,6 +201,26 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { if (num > maxPhase) maxPhase = num; } + // Belt-and-suspenders: if ROADMAP scan found nothing, fall back to scanning + // .planning/phases/ directory names as the canonical source of truth + if (maxPhase === 0) { + const phasesDir = planningPaths(projectDir, workstream).phases; + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dirMatch = /^(\d+)[A-Z]?(?:\.\d+)*-/.exec(entry.name); + if (dirMatch) { + const num = parseInt(dirMatch[1], 10); + if (num >= 999) continue; + if (num > maxPhase) maxPhase = num; + } + } + } catch { + // phases dir may not exist yet — leave maxPhase as 0 + } + } + newPhaseId = maxPhase + 1; const paddedNum = String(newPhaseId).padStart(2, '0'); dirName = `${prefix}${paddedNum}-${slug}`;