diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index f7c1ba785..601c9160e 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -92,7 +92,44 @@ function cmdMilestoneComplete(cwd, version, options, raw) { // Ensure archive directory exists fs.mkdirSync(archiveDir, { recursive: true }); - // Gather stats from phases + // Extract milestone phase numbers from ROADMAP.md to scope stats. + // Only phases listed in the current ROADMAP are counted — phases from + // prior milestones that remain on disk are excluded. + // + // Related upstream PRs (getMilestoneInfo, not milestone complete): + // #756 — fix(core): detect current milestone correctly in getMilestoneInfo + // #783 — fix: getMilestoneInfo() returns wrong version after completion + // Those PRs fix *which* milestone is detected; this fix scopes *stats* + // and *accomplishments* to only the phases belonging to that milestone. + const milestonePhaseNums = new Set(); + if (fs.existsSync(roadmapPath)) { + try { + const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + let phaseMatch; + while ((phaseMatch = phasePattern.exec(roadmapContent)) !== null) { + milestonePhaseNums.add(phaseMatch[1]); + } + } catch {} + } + + // Pre-normalize phase numbers for O(1) lookup — strip leading zeros + // and lowercase for case-insensitive matching of letter suffixes (e.g. 3A/3a). + const normalizedPhaseNums = new Set( + [...milestonePhaseNums].map(num => (num.replace(/^0+/, '') || '0').toLowerCase()) + ); + + // Match a phase directory name to the milestone's phase set. + // Handles: "01-foo" → "1", "3A-bar" → "3a", "3.1-baz" → "3.1" + // Returns false for non-phase directories (no leading digit). + function isDirInMilestone(dirName) { + if (normalizedPhaseNums.size === 0) return true; // no scoping + const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); + if (!m) return false; // not a phase directory + return normalizedPhaseNums.has(m[1].toLowerCase()); + } + + // Gather stats from phases (scoped to current milestone only) let phaseCount = 0; let totalPlans = 0; let totalTasks = 0; @@ -103,6 +140,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) { const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); for (const dir of dirs) { + if (!isDirInMilestone(dir)) continue; + phaseCount++; const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); @@ -182,10 +221,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) { const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true }); const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name); + let archivedCount = 0; for (const dir of phaseDirNames) { + if (!isDirInMilestone(dir)) continue; fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir)); + archivedCount++; } - phasesArchived = phaseDirNames.length > 0; + phasesArchived = archivedCount > 0; } catch {} } diff --git a/tests/milestone.test.cjs b/tests/milestone.test.cjs index 43f2b563e..fa0f1953a 100644 --- a/tests/milestone.test.cjs +++ b/tests/milestone.test.cjs @@ -200,6 +200,186 @@ describe('milestone complete command', () => { ); }); + test('scopes stats to current milestone phases only', () => { + // Set up ROADMAP.md that only references Phase 3 and Phase 4 + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.1\n\n### Phase 3: New Feature\n**Goal:** Build it\n\n### Phase 4: Polish\n**Goal:** Ship it\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + // Create phases from PREVIOUS milestone (should be excluded) + const p1 = path.join(tmpDir, '.planning', 'phases', '01-old-setup'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '---\none-liner: Old setup work\n---\n# Summary\n'); + const p2 = path.join(tmpDir, '.planning', 'phases', '02-old-core'); + fs.mkdirSync(p2, { recursive: true }); + fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '---\none-liner: Old core work\n---\n# Summary\n'); + + // Create phases for CURRENT milestone (should be included) + const p3 = path.join(tmpDir, '.planning', 'phases', '03-new-feature'); + fs.mkdirSync(p3, { recursive: true }); + fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(p3, '03-01-SUMMARY.md'), '---\none-liner: Built new feature\n---\n# Summary\n'); + const p4 = path.join(tmpDir, '.planning', 'phases', '04-polish'); + fs.mkdirSync(p4, { recursive: true }); + fs.writeFileSync(path.join(p4, '04-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(p4, '04-02-PLAN.md'), '# Plan 2\n'); + fs.writeFileSync(path.join(p4, '04-01-SUMMARY.md'), '---\none-liner: Polished UI\n---\n# Summary\n'); + + const result = runGsdTools('milestone complete v1.1 --name "Second Release"', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // Should only count phases 3 and 4, not 1 and 2 + assert.strictEqual(output.phases, 2, 'should count only milestone phases (3, 4)'); + assert.strictEqual(output.plans, 3, 'should count only plans from phases 3 and 4'); + // Accomplishments should only be from phases 3 and 4 + assert.ok(output.accomplishments.includes('Built new feature'), 'should include current milestone accomplishment'); + assert.ok(output.accomplishments.includes('Polished UI'), 'should include current milestone accomplishment'); + assert.ok(!output.accomplishments.includes('Old setup work'), 'should NOT include previous milestone accomplishment'); + assert.ok(!output.accomplishments.includes('Old core work'), 'should NOT include previous milestone accomplishment'); + }); + + test('archive-phases only archives current milestone phases', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.1\n\n### Phase 2: Current Work\n**Goal:** Do it\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + // Phase from previous milestone + const p1 = path.join(tmpDir, '.planning', 'phases', '01-old'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); + + // Phase from current milestone + const p2 = path.join(tmpDir, '.planning', 'phases', '02-current'); + fs.mkdirSync(p2, { recursive: true }); + fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan\n'); + + const result = runGsdTools('milestone complete v1.1 --name Test --archive-phases', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + // Phase 2 should be archived + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.1-phases', '02-current')), + 'current milestone phase should be archived' + ); + // Phase 1 should still be in place (not archived) + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-old')), + 'previous milestone phase should NOT be archived' + ); + }); + + test('phase 1 in roadmap does NOT match directory 10-something (no prefix collision)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync( + path.join(p1, '01-01-SUMMARY.md'), + '---\none-liner: Foundation work\n---\n' + ); + + const p10 = path.join(tmpDir, '.planning', 'phases', '10-scaling'); + fs.mkdirSync(p10, { recursive: true }); + fs.writeFileSync(path.join(p10, '10-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync( + path.join(p10, '10-01-SUMMARY.md'), + '---\none-liner: Scaling work\n---\n' + ); + + const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases, 1, 'should count only phase 1, not phase 10'); + assert.strictEqual(output.plans, 1, 'should count only plans from phase 1'); + assert.ok( + output.accomplishments.includes('Foundation work'), + 'should include phase 1 accomplishment' + ); + assert.ok( + !output.accomplishments.includes('Scaling work'), + 'should NOT include phase 10 accomplishment' + ); + }); + + test('non-numeric directory is excluded when milestone scoping is active', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n\n### Phase 1: Core\n**Goal:** Build core\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + const p1 = path.join(tmpDir, '.planning', 'phases', '01-core'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); + + // Non-phase directory — should be excluded + const misc = path.join(tmpDir, '.planning', 'phases', 'notes'); + fs.mkdirSync(misc, { recursive: true }); + fs.writeFileSync(path.join(misc, 'PLAN.md'), '# Not a phase\n'); + + const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases, 1, 'non-numeric dir should not be counted as a phase'); + assert.strictEqual(output.plans, 1, 'plans from non-numeric dir should not be counted'); + }); + + test('large phase numbers (456, 457) scope correctly', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.49\n\n### Phase 456: DACP\n**Goal:** Ship DACP\n\n### Phase 457: Integration\n**Goal:** Integrate\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + const p456 = path.join(tmpDir, '.planning', 'phases', '456-dacp'); + fs.mkdirSync(p456, { recursive: true }); + fs.writeFileSync(path.join(p456, '456-01-PLAN.md'), '# Plan\n'); + + const p457 = path.join(tmpDir, '.planning', 'phases', '457-integration'); + fs.mkdirSync(p457, { recursive: true }); + fs.writeFileSync(path.join(p457, '457-01-PLAN.md'), '# Plan\n'); + + // Phase 45 from prior milestone — should not match + const p45 = path.join(tmpDir, '.planning', 'phases', '45-old'); + fs.mkdirSync(p45, { recursive: true }); + fs.writeFileSync(path.join(p45, 'PLAN.md'), '# Plan\n'); + + const result = runGsdTools('milestone complete v1.49 --name DACP', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases, 2, 'should count only phases 456 and 457'); + }); + test('handles empty phases directory', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'),