diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 1736d774..973987dc 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -458,8 +458,11 @@ function cmdInitNewMilestone(cwd, raw) { try { if (fs.existsSync(phasesDir)) { + // Bug #2445: filter phase dirs to current milestone only so stale dirs + // from a prior milestone that were not archived don't inflate the count. + const isDirInMilestone = getMilestonePhaseFilter(cwd); phaseDirCount = fs.readdirSync(phasesDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) + .filter(entry => entry.isDirectory() && isDirInMilestone(entry.name)) .length; } } catch {} diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index d0d08dc7..77551aa7 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -720,7 +720,13 @@ function buildStateFrontmatter(bodyContent, cwd) { const status = stateExtractField(bodyContent, 'Status'); const progressRaw = stateExtractField(bodyContent, 'Progress'); const lastActivity = stateExtractField(bodyContent, 'Last Activity'); - const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at'); + // Bug #2444: scope Stopped At extraction to the ## Session section so that + // historical "Stopped at:" prose elsewhere in the body (e.g. in a + // Session Continuity Archive section) never overwrites the current value. + // Fall back to full-body search only when no ## Session section exists. + const sessionSectionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent; + const stoppedAt = stateExtractField(sessionBodyScope, 'Stopped At') || stateExtractField(sessionBodyScope, 'Stopped at'); const pausedAt = stateExtractField(bodyContent, 'Paused At'); let milestone = null; @@ -747,9 +753,33 @@ function buildStateFrontmatter(bodyContent, cwd) { let cached = _diskScanCache.get(cwd); if (!cached) { const isDirInMilestone = getMilestonePhaseFilter(cwd); - const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) + const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()).map(e => e.name) .filter(isDirInMilestone); + + // Bug #2445: when stale phase dirs from a prior milestone remain in + // .planning/phases/ alongside new dirs with the same phase number, + // de-duplicate by normalized phase number keeping the most recently + // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs). + const seenPhaseNums = new Map(); // normalizedNum -> dirName + for (const dir of allMatchingDirs) { + const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); + const key = m ? m[1].toLowerCase() : dir; + if (!seenPhaseNums.has(key)) { + seenPhaseNums.set(key, dir); + } else { + // Keep the dir that is newer on disk (more likely current milestone) + try { + const existing = path.join(phasesDir, seenPhaseNums.get(key)); + const candidate = path.join(phasesDir, dir); + if (fs.statSync(candidate).mtimeMs > fs.statSync(existing).mtimeMs) { + seenPhaseNums.set(key, dir); + } + } catch { /* keep existing on stat error */ } + } + } + const phaseDirs = [...seenPhaseNums.values()]; + let diskTotalPlans = 0; let diskTotalSummaries = 0; let diskCompletedPhases = 0; diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 3eeb30bc..92703bf3 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -2148,6 +2148,228 @@ describe('state sync command', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Bug #2444: stopped_at frontmatter must not be overwritten by historical body prose +// ───────────────────────────────────────────────────────────────────────────── + +describe('stopped_at frontmatter not overwritten by historical prose (bug #2444)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('state sync preserves correct stopped_at frontmatter when historical plain-text match appears before Session section', () => { + // The bug: body has plain "Stopped at:" in old notes (no bold) — stateExtractField + // uses a plain ^Stopped at:\s*(.+) pattern with /im which matches the first line, + // returning the stale historical value. syncStateFrontmatter has no preservation + // step for stopped_at like cmdStateJson does, so it overwrites the correct value. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `--- +gsd_state_version: '1.0' +status: executing +stopped_at: Phase 3, Plan 2 — current correct value +--- + +# Project State + +**Current Phase:** 03 +**Status:** In progress + +## Previous Session Notes + +Stopped at: Phase 5 complete — v1.0 shipped (OLD stale historical note) + +## Session + +Last Date: 2026-04-19 +Stopped At: Phase 3, Plan 2 — current correct value +` + ); + + const result = runGsdTools('state sync', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // The correct frontmatter value must survive the sync + assert.ok( + stateContent.includes('Phase 3, Plan 2 — current correct value'), + 'stopped_at must retain the correct value from the ## Session section' + ); + assert.ok( + !stateContent.includes('stopped_at: Phase 5 complete'), + 'stopped_at must NOT be overwritten with the old historical note' + ); + }); + + test('state sync does not promote stale body prose to stopped_at frontmatter when frontmatter has no stopped_at', () => { + // No existing stopped_at in frontmatter, body has plain Stopped at: in + // a historical notes section appearing BEFORE the real ## Session entry. + // buildStateFrontmatter should scope extraction to ## Session section, not + // match the first occurrence anywhere in the body. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `--- +gsd_state_version: '1.0' +status: executing +--- + +# Project State + +**Current Phase:** 03 +**Status:** In progress + +## Old Notes + +Stopped at: Phase 5 complete — v1.0 STALE (should never land in frontmatter) + +## Session + +Last Date: 2026-04-19 +Stopped At: Phase 3, Plan 1 — real current value +` + ); + + const syncResult = runGsdTools('state sync', tmpDir); + assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`); + + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + const output = JSON.parse(jsonResult.output); + + assert.strictEqual(output.stopped_at, 'Phase 3, Plan 1 — real current value', + 'stopped_at must be extracted from ## Session section, not the first plain-text match in the body'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Bug #2445: stale phase dirs from closed milestone inflate phase counts +// ───────────────────────────────────────────────────────────────────────────── + +describe('stale phase dirs do not corrupt phase counts (bug #2445)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('state json excludes stale prior-milestone phase dirs from phase count when ROADMAP scopes current milestone', () => { + // Old milestone had phases 1-5; new milestone starts fresh with phases 1-2. + // Stale dirs for old phases 3, 4, 5 remain in .planning/phases/ and must be + // excluded by getMilestonePhaseFilter (new ROADMAP only lists phases 1 and 2). + // Old phases 1 and 2 dirs are ambiguous (same number reused) but phase 3-5 dirs + // must not inflate total_phases beyond the ROADMAP's phaseCount of 2. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '
', + 'v1.0 — Old Milestone (Shipped)', + '', + '## Roadmap v1.0: Old Milestone', + '### Phase 1: Old Foundation', + '### Phase 2: Old API', + '### Phase 3: Old Deploy', + '### Phase 4: Old Polish', + '### Phase 5: Old Wrap', + '', + '
', + '', + '## Roadmap v2.0: New Milestone', + '### Phase 1: New Foundation', + '### Phase 2: New API', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '---\nmilestone: v2.0\n---\n\n# State\n\n**Current Phase:** 01\n**Status:** Planning\n' + ); + + // Create stale v1.0 phase dirs 3, 4, 5 — these are NOT in the new ROADMAP + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + for (const dir of ['03-old-deploy', '04-old-polish', '05-old-wrap']) { + const d = path.join(phasesDir, dir); + fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(d, `${dir.slice(0, 2)}-01-PLAN.md`), '# stale plan\n'); + } + + // New milestone has only Phase 1 started so far + const newPhaseDir = path.join(phasesDir, '01-new-foundation'); + fs.mkdirSync(newPhaseDir, { recursive: true }); + fs.writeFileSync(path.join(newPhaseDir, '01-01-PLAN.md'), '# new plan\n'); + + const result = runGsdTools('state json', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + // total_phases must be bounded by the ROADMAP's 2 phases, not 4 total dirs + // (the 3 stale dirs for phases 3-5 must be excluded by the milestone filter) + assert.ok( + output.progress && output.progress.total_phases <= 2, + `total_phases should be ≤ 2 (new milestone phases 1-2 only), got ${output.progress?.total_phases}` + ); + // total_plans must only count plans from current-milestone phase dirs + assert.ok( + output.progress && output.progress.total_plans <= 1, + `total_plans should be 1 (only new phase 1 dir), got ${output.progress?.total_plans}` + ); + }); + + test('init new-milestone phase_dir_count excludes stale prior-milestone dirs', () => { + // ROADMAP scoped to v2.0 with 2 phases + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '
', + 'v1.0 — Shipped', + '', + '## Roadmap v1.0: Old', + '### Phase 1: Old One', + '### Phase 2: Old Two', + '### Phase 3: Old Three', + '', + '
', + '', + '## Roadmap v2.0: New', + '### Phase 1: New One', + '### Phase 2: New Two', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '---\nmilestone: v2.0\n---\n\n# State\n\n**Status:** Planning\n' + ); + + // Three stale phase dirs from the old milestone + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + for (const dir of ['01-old-one', '02-old-two', '03-old-three']) { + fs.mkdirSync(path.join(phasesDir, dir), { recursive: true }); + } + + const result = runGsdTools('init new-milestone', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + // phase_dir_count should not include stale dirs from the old milestone + assert.ok( + output.phase_dir_count <= 2, + `phase_dir_count should be ≤ 2 (only new-milestone dirs), got ${output.phase_dir_count}` + ); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // summary-extract command // ─────────────────────────────────────────────────────────────────────────────