diff --git a/.changeset/noble-mice-squeak.md b/.changeset/noble-mice-squeak.md new file mode 100644 index 000000000..beaac514e --- /dev/null +++ b/.changeset/noble-mice-squeak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3478 +--- +**`gsd-sdk query init.progress` now respects terminal phase labels in roadmap headings** — phases marked COMPLETE/SHIPPED/DEFERRED/SUPERSEDED/MERGED/FOLDED are counted complete and skipped as next pending phase. diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts index 925151710..bf2d21ee1 100644 --- a/sdk/src/query/init-complex.test.ts +++ b/sdk/src/query/init-complex.test.ts @@ -295,6 +295,70 @@ describe('initProgress', () => { await rm(tmp, { recursive: true, force: true }); } }); + + it('treats terminal heading labels as complete when selecting next_phase (#3472)', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'gsd-init-complex-3472-')); + try { + await mkdir(join(tmp, '.planning'), { recursive: true }); + await writeFile(join(tmp, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + commit_docs: false, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, + })); + await writeFile(join(tmp, '.planning', 'STATE.md'), [ + '---', + 'milestone: v1.0', + '---', + ].join('\n')); + await writeFile(join(tmp, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## v1.0: Current', + '', + '## Phase 4.12: Old Work (COMPLETE)', + '', + '## Phase 4.13: Another old one (SHIPPED 2026-05-12)', + '', + '## Phase 4.17: Human Auth (DEFERRED)', + '', + '## Phase 4.23: New pending work', + '', + '## Phase 4.24: Follow-up item (PROMOTED)', + '', + '## Phase 4.25: Another follow-up (REGISTERED)', + '', + '## Phase 4.26: Triage marker (INSERTED)', + '', + ].join('\n')); + + const result = await initProgress([], tmp); + const data = result.data as Record; + const phases = data.phases as Record[]; + const phase412 = phases.find(p => p.number === '4.12'); + const phase413 = phases.find(p => p.number === '4.13'); + const phase417 = phases.find(p => p.number === '4.17'); + const phase424 = phases.find(p => p.number === '4.24'); + const phase425 = phases.find(p => p.number === '4.25'); + const phase426 = phases.find(p => p.number === '4.26'); + + expect(phase412?.status).toBe('complete'); + expect(phase413?.status).toBe('complete'); + expect(phase417?.status).toBe('complete'); + expect(phase424?.status).not.toBe('complete'); + expect(phase425?.status).not.toBe('complete'); + expect(phase426?.status).not.toBe('complete'); + expect(data.completed_count).toBe(3); + expect((data.next_phase as Record).number).toBe('4.23'); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); }); describe('initManager', () => { diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index a9e66e43a..6e60b1071 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -101,6 +101,25 @@ function extractCheckboxStates(content: string): Map { return states; } +/** + * Extract terminal phase markers from ROADMAP phase headings, e.g. + * `(COMPLETE)`, `(SHIPPED ...)`, `(DEFERRED)`, `(SUPERSEDED ...)`. + * These labels mean the phase should not be selected as next pending work. + */ +function extractTerminalStatusLabels(content: string): Set { + const terminal = new Set(); + const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + const terminalRe = /(?:\(|\*\*)\s*(SHIPPED|COMPLETE|DEFERRED|SUPERSEDED|MERGED\s+INTO|FOLDED\s+INTO)\b/i; + let m: RegExpExecArray | null; + while ((m = headingPattern.exec(content)) !== null) { + if (terminalRe.test(m[2])) { + terminal.add(m[1]); + terminal.add(m[1].replace(/^0+/, '') || '0'); + } + } + return terminal; +} + /** * Derive progress-level status from a ROADMAP checkbox when the phase has * no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise. @@ -291,6 +310,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream) const roadmapPhaseNames = new Map(); const seenPhaseNums = new Set(); let checkboxStates = new Map(); + let terminalLabels = new Set(); try { const rawRoadmap = await readFile(paths.roadmap, 'utf-8'); @@ -303,6 +323,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream) roadmapPhaseNames.set(pNum, pName); } checkboxStates = extractCheckboxStates(roadmapContent); + terminalLabels = extractTerminalStatusLabels(roadmapContent); } catch { /* intentionally empty */ } // Scan phase directories @@ -345,6 +366,9 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream) if (roadmapComplete && status !== 'complete') { status = 'complete'; } + if (terminalLabels.has(phaseNumber) || terminalLabels.has(strippedNum)) { + status = 'complete'; + } const phaseInfo: Record = { number: phaseNumber, @@ -373,17 +397,18 @@ export const initProgress: QueryHandler = async (_args, projectDir, workstream) const stripped = num.replace(/^0+/, '') || '0'; if (!seenPhaseNums.has(stripped)) { const status = deriveStatusFromCheckbox(num, checkboxStates); + const terminalComplete = terminalLabels.has(num) || terminalLabels.has(stripped); const phaseInfo: Record = { number: num, name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), directory: null, - status, + status: terminalComplete ? 'complete' : status, plan_count: 0, summary_count: 0, has_research: false, }; phases.push(phaseInfo); - if (!nextPhase && !currentPhase && status !== 'complete') { + if (!nextPhase && !currentPhase && phaseInfo.status !== 'complete') { nextPhase = phaseInfo; } }