From e3acdd89cbf5ff4e07c958369d8b80db509ea5c6 Mon Sep 17 00:00:00 2001 From: Behruz Nassre Esfahani Date: Sun, 21 Jun 2026 23:09:08 -0700 Subject: [PATCH] fix(#1514): exclude retired/folded phases from progress.total_phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A retired/folded phase (struck through in ROADMAP, marked [x], with a directory but no completion artifact) was counted in the total_phases denominator via max(phaseDirs.length, roadmapPhaseCount), yet could never satisfy the numerator (no SUMMARY → never "completed"), freezing shipped milestones below 100% (e.g. 5/6 = 83%). Both STATE counting paths now read the current-milestone ROADMAP scope and exclude retired phases from BOTH the disk phase-dir set and the heading count, so a retired phase counts toward neither denominator nor numerator: - buildStateFrontmatter (`state json`) - cmdStateSync (`state sync --verify` / rebuild) — previously re-derived the inflated denominator and reported "no drift", per the issue. Retired detection (extractRetiredPhaseNumbers) is scoped to the lines that canonically mark a phase retired — a checklist entry (`- [x] …`) or a phase heading — and within those, only a struck span whose SUBJECT is the phase (`~~**Phase 04: Delta**~~`). So struck prose, a struck goal line, and the fold target ("folded into Phase 05") are not misread as retired. Phase matching uses the canonical phase-id helpers (normalizePhaseName + extractPhaseToken), so numeric, decimal, and project-code IDs (PROJ-42) match consistently across ROADMAP tokens and on-disk dir names. Scope boundaries (separate, pre-existing concerns left unchanged): - `roadmap analyze` (src/roadmap.cts) intentionally trusts the [x] checkbox (incl. externally-completed phases) — a different reporting surface. - cmdStateSync does not apply the milestone phase-dir filter (so 999.x / other-milestone dirs can still affect its count); that is the #1445 / milestone-filter axis, independent of retired phases. Same counting family as #549 / #500 / #1445. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/state.cts | 135 ++++++-- ...-phase-excluded-from-total-phases.test.cjs | 289 ++++++++++++++++++ 2 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 tests/bug-1514-retired-phase-excluded-from-total-phases.test.cjs diff --git a/src/state.cts b/src/state.cts index 14d819ffa..cd8afbafa 100644 --- a/src/state.cts +++ b/src/state.cts @@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex } = phaseIdMod; +const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod; @@ -1294,6 +1294,63 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { // ─── State Frontmatter Sync ────────────────────────────────────────────────── +/** + * Canonical key for matching a ROADMAP phase token against an on-disk phase + * directory: normalizePhaseName collapses padding/case, strips the project-code + * prefix, and handles decimals/letter-suffixes/milestone-prefixed IDs, so + * "Phase 4"/"Phase 04"/dir "04-delta" and "Phase PROJ-42"/dir "PROJ-42-foo" + * each map to one key. For a directory, extract its phase token first. + * + * Stripping the project-code prefix is GSD's canonical phase identity (a + * project_code is a display prefix; normalizePhaseName / phaseTokenMatches treat + * `CK-01` and `01` as the same phase, which is what lets a prefixed dir match a + * bare ROADMAP token). A consistent project uses one scheme, so a bare numeric + * and a same-suffix project-code phase never coexist in one milestone. + */ +function phaseKeyFromToken(token: string): string { + return normalizePhaseName(token).toUpperCase(); +} +function phaseKeyFromDir(dir: string): string { + return phaseKeyFromToken(extractPhaseToken(dir)); +} + +/** + * Extract the set of retired/folded phase keys from a ROADMAP milestone scope + * (#1514). A retired phase is struck through with GFM strikethrough, + * e.g. `- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired`. + * Such a phase keeps a `[x]` mark and often a directory but ships no completion + * artifact, so it would otherwise inflate `total_phases` (the denominator) + * without ever satisfying the numerator, freezing a shipped milestone below + * 100%. + * + * Detection is scoped to the lines that canonically mark a phase retired — a + * checklist entry (`- [x] …`) or a phase heading (`#### Phase …`) — and within + * those, only a struck span whose SUBJECT is the phase counts: the phase + * reference must sit at the start of the `~~…~~` span (after optional markdown + * emphasis), as in `~~**Phase 04: Delta**~~`, `~~Phase 04~~`, or + * `~~Phase PROJ-42~~`. This ignores struck PROSE that merely mentions a phase + * (a goal line `~~folded into Phase 05~~`, or `~~Phase 04 was renamed~~`) and + * the fold target in `~~Phase 04~~ — folded into Phase 05` (outside the span). + * The phase token shape mirrors the heading counter's `[\w][\w.-]*` so numeric, + * decimal, and project-code IDs are detected alike. Returns canonical keys + * (see phaseKeyFromToken). + */ +function extractRetiredPhaseNumbers(scope: string): Set { + const retired = new Set(); + const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/; + for (const line of scope.split(/\r?\n/)) { + if (!isChecklistOrHeading.test(line)) continue; + const strikeSpan = /~~([^~]*?)~~/g; + let s: RegExpExecArray | null; + while ((s = strikeSpan.exec(line)) !== null) { + const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]); + // Require a digit so struck prose like ~~Phase Overview~~ is ignored. + if (phaseRef && /\d/.test(phaseRef[1])) retired.add(phaseKeyFromToken(phaseRef[1])); + } + } + return retired; +} + /** * Extract machine-readable fields from STATE.md markdown body and build * a YAML frontmatter object. Allows hooks and scripts to read state @@ -1346,6 +1403,21 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // on repeated buildStateFrontmatter invocations within the same process (#1967) let cached = _diskScanCache.get(cwd); if (!cached) { + // Read the current-milestone ROADMAP scope once: it feeds both the + // heading-based phase count below and the retired/folded-phase + // exclusion (#1514). Computed before the disk scan so retired phases + // can be dropped from the dir set too. + let roadmapScope: string | null = null; + let retiredPhaseNums = new Set(); + try { + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); + const roadmapRaw = platformReadSync(roadmapPath); + if (roadmapRaw !== null) { + roadmapScope = extractCurrentMilestone(roadmapRaw, cwd); + retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope); + } + } catch { /* fall through: no roadmap scope → no retired exclusion */ } + const isDirInMilestone = getMilestonePhaseFilter(cwd) as (dir: string) => boolean; const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()).map(e => e.name) @@ -1357,6 +1429,11 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs). const seenPhaseNums = new Map(); // normalizedNum -> dirName for (const dir of allMatchingDirs) { + // #1514: a retired/folded phase keeps a directory but no completion + // artifact; drop it from the disk phase set so it counts toward + // neither the denominator nor the numerator (mirrors the heading + // exclusion below). Project-code-aware via phaseKeyFromDir. + if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue; const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); const key = m ? m[1].toLowerCase() : dir; if (!seenPhaseNums.has(key)) { @@ -1391,22 +1468,21 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // `## Phase Overview:` or `## Phase Details:` — single source of // truth for total_phases (#549). let roadmapPhaseCount = 0; - try { - const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); - const roadmapRaw = platformReadSync(roadmapPath); - if (roadmapRaw !== null) { - const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd); - const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; - let m: RegExpExecArray | null; - while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) { - // Only count tokens that contain at least one digit — excludes - // pure-word section headings (Overview, Details) while keeping - // numeric phases (01, 05.1) and project-code IDs (PROJ-42). - // Also exclude 999.x backlog phases. Mirrors init.cts filter. - if (/\d/.test(m[1]) && !/^999\b/.test(m[1])) roadmapPhaseCount++; - } + if (roadmapScope !== null) { + const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; + let m: RegExpExecArray | null; + while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) { + // Only count tokens that contain at least one digit — excludes + // pure-word section headings (Overview, Details) while keeping + // numeric phases (01, 05.1) and project-code IDs (PROJ-42). + // Also exclude 999.x backlog phases. Mirrors init.cts filter. + if (!/\d/.test(m[1]) || /^999\b/.test(m[1])) continue; + // #1514: retired/folded phases are struck through in the ROADMAP; + // exclude them from the denominator (they can never be completed). + if (retiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue; + roadmapPhaseCount++; } - } catch { /* fall through: phaseDirs.length used as sole count */ } + } cached = { totalPhases: roadmapPhaseCount > 0 @@ -2400,12 +2476,27 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b return; } + // #1514: read the current-milestone ROADMAP scope once so retired/folded + // phases are excluded from BOTH the disk scan and the heading count here, + // exactly as buildStateFrontmatter does — otherwise `state sync --verify` + // would keep re-deriving the inflated denominator and report "no drift". + let syncRoadmapScope: string | null = null; + let syncRetiredPhaseNums = new Set(); + try { + const roadmapRaw = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md')); + if (roadmapRaw !== null) { + syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd); + syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope); + } + } catch { /* fall through: no roadmap scope → no retired exclusion */ } + // Scan all phases let entries: string[]; try { entries = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) + .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name)))) .sort(); } catch { output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined); @@ -2451,17 +2542,17 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b let syncTotalPhases: number | null = null; try { let roadmapPhaseCount = 0; - const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); - const roadmapRaw = platformReadSync(roadmapPath); - if (roadmapRaw !== null) { - const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd); + if (syncRoadmapScope !== null) { const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; let m: RegExpExecArray | null; - while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) { + while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) { // Only count tokens that contain at least one digit — excludes // pure-word section headings (Overview, Details) while keeping // numeric phases (01, 05.1) and project-code IDs (PROJ-42). - if (/\d/.test(m[1])) roadmapPhaseCount++; + if (!/\d/.test(m[1])) continue; + // #1514: retired/folded phases are struck through; exclude from total. + if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue; + roadmapPhaseCount++; } } if (roadmapPhaseCount > 0) { diff --git a/tests/bug-1514-retired-phase-excluded-from-total-phases.test.cjs b/tests/bug-1514-retired-phase-excluded-from-total-phases.test.cjs new file mode 100644 index 000000000..fcc80779e --- /dev/null +++ b/tests/bug-1514-retired-phase-excluded-from-total-phases.test.cjs @@ -0,0 +1,289 @@ +'use strict'; +/** + * Regression test for bug #1514: + * A retired/folded phase (struck through in ROADMAP, marked `[x]`, with a + * directory but no completion artifact) must NOT be counted in + * progress.total_phases. Otherwise it inflates the denominator without ever + * satisfying the numerator (no SUMMARY → never "completed"), freezing a + * fully-shipped milestone below 100%. + * + * Root cause: + * buildStateFrontmatter (state.cts) derived total_phases from + * max(phaseDirs.length, roadmapPhaseCount) — both of which counted the + * retired phase (its directory and its `### Phase NN:` heading) — while + * completed_phases came from a disk SUMMARY scan that the retired phase + * can never satisfy. Same counting family as #549 / #500 / #1445. + * + * Fix: + * buildStateFrontmatter now extracts retired phase numbers from the GFM + * strikethrough in the current-milestone ROADMAP scope and excludes them + * from BOTH the disk phase-dir set and the heading count, so a retired + * phase counts toward neither denominator nor numerator. + * + * Why integration (state json) not a unit test: the bug only manifests in the + * assembled progress block a shipped milestone actually writes to STATE.md, so + * the test reproduces that artifact rather than a helper in isolation. + */ + +const { describe, test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// Six phases, all shipped, except Phase 04 which is retired/folded into 05. +// Phases 01-03,05,06 have PLAN+SUMMARY (complete); Phase 04 keeps a directory +// but no work (retired). `complete` flags which dirs get PLAN+SUMMARY. +function seedProject(prefix, roadmap, completeDirs) { + const tmpDir = createTempProject(prefix); + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8'); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'status: executing', + '---', + '', + '# GSD State', + '', + '## Configuration', + 'Current Phase: 6', + 'Status: shipped', + 'Last Activity: 2026-06-01', + ].join('\n'), + 'utf-8', + ); + const allDirs = ['01-alpha', '02-beta', '03-gamma', '04-delta', '05-epsilon', '06-zeta']; + for (const d of allDirs) { + const dir = path.join(planning, 'phases', d); + fs.mkdirSync(dir, { recursive: true }); + if (completeDirs.includes(d)) { + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + } + } + return tmpDir; +} + +const PHASE_DETAILS = [ + '### Phase 01: Alpha', '**Goal:** a', '', + '### Phase 02: Beta', '**Goal:** b', '', + '### Phase 03: Gamma', '**Goal:** c', '', + '### Phase 04: Delta', '**Goal:** GOAL_04', '', + '### Phase 05: Epsilon', '**Goal:** e', '', + '### Phase 06: Zeta', '**Goal:** f', +]; + +function roadmap(checklist04, goal04) { + return [ + '## Milestone v1.0: Repro', + '', + '### Phases', + '- [x] **Phase 01: Alpha** — done', + '- [x] **Phase 02: Beta** — done', + '- [x] **Phase 03: Gamma** — done', + checklist04, + '- [x] **Phase 05: Epsilon** — done', + '- [x] **Phase 06: Zeta** — done', + '', + ...PHASE_DETAILS.map((l) => (l === '**Goal:** GOAL_04' ? `**Goal:** ${goal04}` : l)), + ].join('\n'); +} + +const ALL_COMPLETE = ['01-alpha', '02-beta', '03-gamma', '05-epsilon', '06-zeta']; + +describe('bug #1514 — retired/folded phase excluded from progress.total_phases', () => { + let tmpDir; + afterEach(() => { + if (tmpDir) cleanup(tmpDir); + tmpDir = undefined; + }); + + test('struck `[x] ~~Phase 04~~ — folded into Phase 05` → 5/5, percent 100 (not 5/6, 83)', () => { + const rm = roadmap( + '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', + 'folded into Phase 05', + ); + tmpDir = seedProject('bug-1514-a-', rm, ALL_COMPLETE); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 5, `total_phases must exclude the retired phase. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `shipped milestone must reach 100%. Got ${progress.percent}`); + }); + + test('fold TARGET is not retired: a struck goal line `~~folded into Phase 05~~` must not drop Phase 05', () => { + // Phase 04 retired via checklist; Phase 04 *goal* also struck and mentions + // the fold target. The target (Phase 05) must remain a counted phase. + const rm = roadmap( + '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', + '~~folded into Phase 05; retired~~', + ); + tmpDir = seedProject('bug-1514-b-', rm, ALL_COMPLETE); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 5, `only Phase 04 is retired; Phase 05 must still count. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `Got ${progress.percent}`); + }); + + test('regression: no strikethrough → all 6 phases counted (6/6, 100)', () => { + const rm = roadmap('- [x] **Phase 04: Delta** — done', 'd'); + tmpDir = seedProject('bug-1514-c-', rm, [...ALL_COMPLETE, '04-delta']); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 6, `no retired phase: all 6 counted. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 6, `Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `Got ${progress.percent}`); + }); + + // `state sync --verify` is the SECOND counting path (cmdStateSync). Before the + // fix it re-derived the same inflated denominator and reported "no drift", + // so a manual STATE edit was the only recourse (#1514). It must now agree + // with state json and drive the stuck 83% Progress field to 100%. + test('state sync --verify drives a stuck 83% Progress to 100% (cmdStateSync path)', () => { + const rm = roadmap( + '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', + 'folded into Phase 05', + ); + tmpDir = seedProject('bug-1514-sync-', rm, ALL_COMPLETE); + // Seed a stuck Progress line that the inflated denominator would "agree" with. + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.appendFileSync(statePath, '\nProgress: [████████░░] 83%\n', 'utf-8'); + const result = runGsdTools(['state', 'sync', '--verify'], tmpDir); + assert.ok(result.success, `state sync --verify failed: ${result.error}`); + const { changes } = JSON.parse(result.output); + const progressChange = (changes || []).find((c) => /Progress:/.test(c)); + assert.ok(progressChange, `expected a Progress drift, got changes: ${JSON.stringify(changes)}`); + assert.match(progressChange, /-> .*100%/, `sync must want 100%, got: ${progressChange}`); + }); +}); + +// ─── Generic seeder for non-canonical phase shapes ────────────────────────── + +/** + * Seed a project from explicit phase specs so project-code, decimal, + * no-directory, and shipped-then-retired shapes can be exercised. + * spec: { id, retired?, dir?, shipped? } + * id — ROADMAP phase id (e.g. '04', '05.1', 'PROJ-42') + * retired — strike the checklist entry (folded/retired) + * dir — directory name to create (omit → no directory) + * shipped — write PLAN+SUMMARY into the directory (complete) + */ +function seedFromSpecs(prefix, specs) { + const tmpDir = createTempProject(prefix); + const planning = path.join(tmpDir, '.planning'); + const checklist = specs.map((s) => + s.retired + ? `- [x] ~~**Phase ${s.id}: P${s.id}**~~ — retired` + : `- [x] **Phase ${s.id}: P${s.id}** — done`, + ); + const details = specs.flatMap((s) => [`### Phase ${s.id}: P${s.id}`, '**Goal:** g', '']); + const roadmapText = ['## Milestone v1.0: Specs', '', '### Phases', ...checklist, '', ...details].join('\n'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8'); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + ['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 1'].join('\n'), + 'utf-8', + ); + for (const s of specs) { + if (!s.dir) continue; + const dir = path.join(planning, 'phases', s.dir); + fs.mkdirSync(dir, { recursive: true }); + if (s.shipped) { + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + } + } + return tmpDir; +} + +describe('bug #1514 — retired exclusion across phase shapes', () => { + let tmpDir; + afterEach(() => { + if (tmpDir) cleanup(tmpDir); + tmpDir = undefined; + }); + + test('project-code retired phase is dropped from the denominator (Phase PROJ-42)', () => { + // Project-code dirs are not milestone-mapped for completion counts (a + // separate pre-existing limitation), so assert only the total_phases + // denominator, which #1514 governs: the struck PROJ-42 heading must not + // be counted, while PROJ-41 / PROJ-43 still are. + tmpDir = seedFromSpecs('bug-1514-pc-', [ + { id: 'PROJ-41', dir: 'PROJ-41-a', shipped: true }, + { id: 'PROJ-42', retired: true, dir: 'PROJ-42-d' }, + { id: 'PROJ-43', dir: 'PROJ-43-c', shipped: true }, + ]); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 2, `retired project-code phase must be excluded. Got ${progress.total_phases}`); + }); + + test('decimal, multiple, shipped-then-retired, and no-directory retired phases all excluded', () => { + // Retired: 02 (executed → has SUMMARY, then folded), 04 (no work), + // 05.1 (decimal, no directory at all). Live: 01, 03, 06. + tmpDir = seedFromSpecs('bug-1514-multi-', [ + { id: '01', dir: '01-a', shipped: true }, + { id: '02', retired: true, dir: '02-b', shipped: true }, + { id: '03', dir: '03-c', shipped: true }, + { id: '04', retired: true, dir: '04-d' }, + { id: '05.1', retired: true }, + { id: '06', dir: '06-f', shipped: true }, + ]); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 3, `3 retired of 6 → total 3. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 3, `live phases 01/03/06 complete. Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `Got ${progress.percent}`); + }); + + test('strikethrough in a non-checklist/heading line (a goal) does NOT retire that phase', () => { + // Detection is scoped to checklist/heading lines, so a struck GOAL line + // that begins with a phase reference must not retire it. + const tmp = createTempProject('bug-1514-prose-'); + const planning = path.join(tmp, '.planning'); + const roadmapText = [ + '## Milestone v1.0: Prose', + '', + '### Phases', + '- [x] **Phase 01: A** — done', + '- [x] **Phase 02: B** — done', + '- [x] **Phase 03: C** — done', + '', + '### Phase 01: A', '**Goal:** g', + '### Phase 02: B', '**Goal:** ~~Phase 02 was renamed from an earlier plan~~', + '### Phase 03: C', '**Goal:** g', + ].join('\n'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8'); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + ['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 3'].join('\n'), + 'utf-8', + ); + for (const d of ['01-a', '02-b', '03-c']) { + const dir = path.join(planning, 'phases', d); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + } + tmpDir = tmp; + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 3, `struck prose in a goal line must not retire Phase 02. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 3, `Got ${progress.completed_phases}`); + }); +});