diff --git a/CHANGELOG.md b/CHANGELOG.md index 6876974d9..f76300eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Scope phase counting in `buildStateFrontmatter` and `cmdPhaseComplete` to current milestone — multi-milestone projects no longer report inflated total/completed phases +- Use ROADMAP phase count for `total_phases` when phases lack directories — prevents premature milestone completion detection ## [1.22.1] - 2026-03-02 diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 4a1edbeeb..08a88e44f 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -425,17 +425,23 @@ function getMilestonePhaseFilter(cwd) { } } catch {} - if (milestonePhaseNums.size === 0) return () => true; + if (milestonePhaseNums.size === 0) { + const passAll = () => true; + passAll.phaseCount = 0; + return passAll; + } const normalized = new Set( [...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase()) ); - return function isDirInMilestone(dirName) { + function isDirInMilestone(dirName) { const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); if (!m) return false; return normalized.has(m[1].toLowerCase()); - }; + } + isDirInMilestone.phaseCount = milestonePhaseNums.size; + return isDirInMilestone; } module.exports = { diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index c07e2afca..18a4bc760 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -565,7 +565,9 @@ function buildStateFrontmatter(bodyContent, cwd) { diskTotalSummaries += summaries; if (plans > 0 && summaries >= plans) diskCompletedPhases++; } - totalPhases = phaseDirs.length; + totalPhases = isDirInMilestone.phaseCount > 0 + ? Math.max(phaseDirs.length, isDirInMilestone.phaseCount) + : phaseDirs.length; completedPhases = diskCompletedPhases; totalPlans = diskTotalPlans; completedPlans = diskTotalSummaries; diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 2dd5383a7..9f51d7d8c 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -775,4 +775,29 @@ describe('getMilestonePhaseFilter', () => { assert.strictEqual(filter('not-a-phase'), false); assert.strictEqual(filter('.gitkeep'), false); }); + + test('phaseCount reflects ROADMAP phase count', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n' + ); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter.phaseCount, 3); + }); + + test('phaseCount is 0 when ROADMAP is missing', () => { + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter.phaseCount, 0); + }); + + test('phaseCount is 0 when ROADMAP has no phase headings', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\nSome content.\n' + ); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter.phaseCount, 0); + }); }); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 264401a98..14ea38982 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -1307,6 +1307,47 @@ describe('milestone-scoped phase counting in frontmatter', () => { assert.strictEqual(Number(output.progress.completed_phases), 2, 'both milestone phases have summaries'); }); + test('total_phases includes ROADMAP phases without directories', () => { + // ROADMAP lists 6 phases (5-10), but only 4 have directories on disk + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '## Roadmap v3.0', + '', + '### Phase 5: Auth', + '### Phase 6: Dashboard', + '### Phase 7: API', + '### Phase 8: Notifications', + '### Phase 9: Analytics', + '### Phase 10: Polish', + ].join('\n') + ); + + // Only phases 5-8 have directories (9 and 10 not yet planned) + for (let i = 5; i <= 8; i++) { + const padded = String(i).padStart(2, '0'); + const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan'); + fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary'); + } + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Project State\n\n**Current Phase:** 08\n**Status:** In progress\n' + ); + + const result = runGsdTools('state update Status "Executing"', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + + const output = JSON.parse(jsonResult.output); + assert.strictEqual(Number(output.progress.total_phases), 6, 'should count all 6 ROADMAP phases, not just 4 with directories'); + assert.strictEqual(Number(output.progress.completed_phases), 4, 'only 4 phases have summaries'); + }); + test('without ROADMAP counts all phases (pass-all filter)', () => { // No ROADMAP.md — all phases should be counted for (let i = 1; i <= 4; i++) {