From c4b6dbd486e9bb802132088098f24cd5d05db3ac Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 7 Sep 2026 02:20:47 -0400 Subject: [PATCH] fix(#4247): refuse update-plan-progress on a roadmap with no writable phase entry (#4468) * test(#4247): failing-first regressions for checklist-form update-plan-progress * fix(#4247): refuse update-plan-progress when the roadmap has no writable phase entry * fix(#4247): single local source for the phase-heading anchor grammar * docs(#4247): note the missing_phase_details refusal in cli-tools reference * docs(#4247): backfill pr number in changeset --------- Co-authored-by: sim --- .changeset/brave-finches-zip.md | 5 + docs/CLI-TOOLS.md | 6 + src/roadmap.cts | 86 +++++++- tests/roadmap.test.cjs | 358 ++++++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 .changeset/brave-finches-zip.md diff --git a/.changeset/brave-finches-zip.md b/.changeset/brave-finches-zip.md new file mode 100644 index 000000000..bcbc31548 --- /dev/null +++ b/.changeset/brave-finches-zip.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4468 +--- +**`roadmap update-plan-progress` no longer false-greens on checklist-form ROADMAPs** — a phase whose entry is a `- [ ] **Phase N: …**` checklist bullet with no writable Progress-table row or detail section now declines with `updated: false` and a typed `missing_phase_details` reason, leaving ROADMAP.md byte-identical, instead of reporting success off an unrelated checkbox mark while the phase row stayed untouched and blank lines were injected mid-sentence in other phases' entries. (#4247) diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index ff0925d1a..027867288 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -434,6 +434,12 @@ node gsd-tools.cjs roadmap analyze node gsd-tools.cjs roadmap update-plan-progress ``` +When the phase has no writable ROADMAP entry — no matching Progress-table row, +no `### Phase N` detail section, and no checklist bullet this command can update +(the checklist-only form) — the command declines with `updated: false` and a +`missing_phase_details` reason instead of claiming success, and leaves +`ROADMAP.md` byte-identical. + ### Milestone window scope (`roadmap analyze`) `roadmap analyze` scopes its phase list to the current milestone's section of diff --git a/src/roadmap.cts b/src/roadmap.cts index afea0625e..de59dae3e 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -1032,6 +1032,23 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // Wrap entire read-modify-write in lock to prevent concurrent corruption let updated = false; + // #4247: the refusal flag. The write/report decision below must be keyed to + // "a writable roadmap representation of THIS phase was found", never to "any + // byte moved". On a checklist-form ROADMAP (`- [ ] **Phase N: …**`, the + // roadmapper's own summary-checklist form) every phase-targeted grammar + // below requires an ATX `#{2,4} Phase N` heading and therefore finds + // nothing; the Progress-table row is the only other writable target, and + // when its Phase cell does not match `phaseCellRe` (e.g. a word-prefixed + // `Phase 68` cell — deliberately unrecognized on the read side too, + // `deriveProgressFromRoadmap`'s `/^\d/` data-row filter) the command used + // to fall through to unrelated byte deltas (an UN-scoped plan-checkbox mark + // anywhere in the document) and report `updated: true` while the phase's + // own row stayed untouched — with the file-global write then letting the + // platform write seam's markdown normalization inject blank lines around + // other phases' bullets, splitting hand-wrapped sentences mid-entry. The + // refusal below declines with the analyzer's own `missing_phase_details` + // vocabulary and leaves ROADMAP.md byte-identical. + let missingPhaseDetails = false; withPlanningLock(cwd, () => { // #3957 (B9.4): captured BEFORE any transform runs, so the write/report // decision below reflects whether the transforms actually changed @@ -1041,6 +1058,35 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und const originalContent = fs.readFileSync(roadmapPath, 'utf-8'); let roadmapContent = originalContent; const phasePattern = phaseMarkdownRegexSource(phaseNum); + // #4247: ONE local source for the ATX phase-heading anchor that every + // section-scoped writer below (`planCountPattern`, + // `insertRowsPatternA|B`) starts with — extracted so the target-detection + // gate below reads the SAME grammar the writers anchor on, and a future + // edit to one cannot drift from the other three copies. + const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`; + // #4247: target detection runs against the ORIGINAL content's active + // (post-) region — the same milestone scoping every writer + // below applies — so the gate asks "does the file carry a writable phase + // representation" rather than "did some regex fire mid-transform". + const gateDetailsClose = originalContent.lastIndexOf(''); + const gateActiveRegion = gateDetailsClose === -1 + ? originalContent + : originalContent.slice(gateDetailsClose + ''.length); + // Heading target: the exact grammar `planCountPattern` / + // `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase). + const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(gateActiveRegion); + // Checklist target: when the phase is complete, its own checklist bullet + // (`- [ ] **Phase N: …**`) IS a writable phase row — the completion + // checkbox stamp below updates it. Same grammar as that writer, widened + // one notch to `[ x]` so an ALREADY-checked bullet still counts as a + // found target: an idempotent re-run then takes the honest + // "no changes were needed" decline instead of this refusal. + const checklistTargetFound = isComplete && new RegExp( + `-\\s*\\[[ x]\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, + 'i', + ).test(gateActiveRegion); + // Table-row target: set by the row-scoped cell updates below. + let tableRowFound = false; // Progress table row: update Plans Complete/Status/Completed columns BY // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of @@ -1062,10 +1108,10 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und let text = scoped; const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `); - if (plansResult.ok) text = plansResult.value; + if (plansResult.ok) { text = plansResult.value; tableRowFound = true; } const statusResult = updateTableCell(text, rowMatch, 'Status', ` ${status.padEnd(11)}`); - if (statusResult.ok) text = statusResult.value; + if (statusResult.ok) { text = statusResult.value; tableRowFound = true; } // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage). // Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via @@ -1081,7 +1127,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und } return ' '; }); - if (completedResult.ok) text = completedResult.value; + if (completedResult.ok) { text = completedResult.value; tableRowFound = true; } return text; }); @@ -1125,7 +1171,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // continuation on the next line, since the pattern never spans past // `\n` in the first place. const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`, + `(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`, 'i' ); const planCountText = isComplete @@ -1218,11 +1264,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // Pattern A: anchor to bare `Plans:` header (preferred). // Pattern B: fallback to bold summary when no bare header exists. const insertRowsPatternA = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, + `(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i' ); const insertRowsPatternB = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, + `(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i' ); @@ -1268,10 +1314,24 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content` // gate. Previously this wrote and reported `updated: true` // unconditionally, even on an idempotent re-run that changed nothing. - if (roadmapContent !== originalContent) { + // + // #4247: ...but ONLY when a writable representation of THIS phase was + // found. Without the target gate, a byte delta from an unrelated + // transform (the un-scoped plan-checkbox mark) satisfied the #3957 gate + // and produced a success-shaped `updated: true` while the phase's own + // row stayed untouched — and the file-global write let the platform + // write seam's markdown normalization reflow unrelated entries. When no + // target exists the command refuses: no write at all, so ROADMAP.md is + // left byte-identical, and the caller gets a typed + // `missing_phase_details` decline instead of a false green. + const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound; + if (phaseRepresentationFound && roadmapContent !== originalContent) { platformWriteSync(roadmapPath, roadmapContent); updated = true; } + if (!phaseRepresentationFound) { + missingPhaseDetails = true; + } }); const computed = { @@ -1284,6 +1344,18 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und }; if (updated) { output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`); + } else if (missingPhaseDetails) { + // #4247: honest refusal — the reason names the real condition (the + // analyzer's `missing_phase_details` vocabulary), never "already + // reflects", which was false: the ROADMAP was never able to record this + // phase's progress in the first place. + declineNoOp( + raw, + 'updated', + 'missing_phase_details', + `roadmap update-plan-progress skipped — ROADMAP.md has no writable entry for phase ${formatDiagnosticToken(String(phaseNum))} (no matching Progress-table row, no phase detail section, and no checklist entry this command can update). ROADMAP.md was left unchanged.`, + computed, + ); } else { declineNoOp( raw, diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 1d4494bbe..de1ecf3fc 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -1269,6 +1269,364 @@ describe('#3057 B3: roadmap update-plan-progress — verification staleness-chec }); }); +// ───────────────────────────────────────────────────────────────────────────── +// regressions: #4247 — checklist-form ROADMAP.md must not produce a false +// `updated:true` (untouched phase row) nor blank-line corruption elsewhere +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The checklist house style the bundled roadmapper emits for the summary + * checklist (`agents/gsd-roadmapper.md` §"Summary Checklist"): long + * `- [ ] **Phase N: Title** - description` entries with column-0 continuation + * sentences (the shape issue #4247 was filed against), plus a Progress table. + * + * `tableCell` controls the Progress table's Phase cell for the target phase. + */ +function buildChecklistRoadmap4247(tableCell) { + return [ + '# Roadmap: Mango Tree', + '', + '## Phases', + '', + '- [ ] **Phase 65: Orchard Layout** - goal: design the canopy grid. Progress: 4/4 plans executed, verified 2026-08-30;', + 'grid survey closed the two-centimeter tolerance, terracing passed inspection, and the irrigation', + 'channels were flushed before the storm; soil probes re-zeroed afterwards.', + '- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler. Progress: 1/5 plans executed;', + '68-01 calendar model in review; 68-02 crew assignment summarized; 68-03 weather windows', + 'not started; 68-04 crate logistics blocked on warehouse slot; 68-05 billing hook pending.', + '- [ ] **Phase 69: Packhouse** - goal: automate the line. Progress: 0/2 executed.', + '', + '## Execution Waves', + '', + '- Wave 1: Phases 65-66 — complete.', + '- Wave 2: Phase 68 — in flight.', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 65 | 4/4 | Complete | 2026-08-30 |', + `| ${tableCell} | 0/5 | Planned | - |`, + '', + ].join('\n'); +} + +/** Phase 68 on disk: 5 plans, 1 summary → In Progress, 1/5. */ +function seedPhase68WithPlans(tmpDir, { roadmap } = {}) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + const p68 = path.join(tmpDir, '.planning', 'phases', '68-scheduler'); + fs.mkdirSync(p68, { recursive: true }); + for (const n of ['01', '02', '03', '04', '05']) { + fs.writeFileSync(path.join(p68, `68-${n}-PLAN.md`), `# Plan ${n}\n`); + } + fs.writeFileSync(path.join(p68, '68-02-SUMMARY.md'), '# Summary\n'); + return p68; +} + +describe('#4247: roadmap update-plan-progress — checklist-form ROADMAP refuses instead of false-green', () => { + let tmpDir; + let roadmapPath; + + beforeEach(() => { + tmpDir = createTempProject('gsd-4247-roadmap-'); + roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // Row 1 — the issue's exact repro shape. FAILING FIRST. + test('checklist form with non-matching table row and a stray plan row refuses with missing_phase_details and writes nothing', () => { + const roadmap = buildChecklistRoadmap4247('Phase 68').replace( + '- [ ] **Phase 69: Packhouse**', + ' - [ ] 68-01: calendar model\n - [ ] 68-02: crew assignment\n- [ ] **Phase 69: Packhouse**', + ); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, false, 'a phase with no writable roadmap representation must not claim updated'); + assert.strictEqual(output.reason, 'missing_phase_details', 'refusal carries the typed parse diagnostic'); + assert.strictEqual(output.plan_count, 5, 'computed counts are still reported'); + assert.strictEqual(output.summary_count, 1); + + // The issue's round-trip requirement: the file must be byte-identical — + // no plan-checkbox marks, no blank lines splitting other phases' sentences. + const written = fs.readFileSync(roadmapPath, 'utf-8'); + assert.strictEqual(written, roadmap, 'ROADMAP.md must be byte-identical on refusal'); + }); + + // Row 2 — no table at all. + test('checklist form with no Progress table refuses with missing_phase_details and writes nothing', () => { + const roadmap = [ + '# Roadmap: T', + '', + '## Phases', + '', + '- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler.', + '', + ].join('\n'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, false); + assert.strictEqual(output.reason, 'missing_phase_details'); + assert.strictEqual( + fs.readFileSync(roadmapPath, 'utf-8'), + roadmap, + 'ROADMAP.md must be byte-identical on refusal', + ); + }); + + // Row 3 — isolates the non-matching cell (word-prefixed) from the plan-row trigger. + test('checklist form whose table row does not match the phase-cell grammar refuses without writing', () => { + const roadmap = buildChecklistRoadmap4247('Phase 68'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, false); + assert.strictEqual(output.reason, 'missing_phase_details'); + assert.strictEqual( + fs.readFileSync(roadmapPath, 'utf-8'), + roadmap, + 'ROADMAP.md must be byte-identical on refusal', + ); + }); + + // Row 4 — table-form MUST keep updating exactly as today (full-file byte assertion). + test('table form with a bare-number cell still updates the row byte-exactly and touches nothing else', () => { + // Normal-form fixture (template spacing, single-line entries): the write + // seam's markdown normalization is a no-op here, so the byte-exact splice + // is observable. + const roadmap = [ + '# Roadmap: Mango Tree', + '', + '## Phases', + '', + '- [ ] **Phase 65: Orchard Layout** - goal: design the canopy grid. Progress: 4/4 plans executed, verified 2026-08-30.', + '', + '- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler. Progress: 1/5 plans executed.', + '', + '- [ ] **Phase 69: Packhouse** - goal: automate the line. Progress: 0/2 executed.', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 65 | 4/4 | Complete | 2026-08-30 |', + '| 68 | 0/5 | Planned | - |', + '', + ].join('\n'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, true, 'a matching table row is a writable target'); + + // Byte-exact expectation: only the Phase 68 row's three cells change + // (` 1/5 ` splice, ` In Progress` padEnd(11), Completed cleared to ` `), + // every other byte — including Phase 65/69 prose and the waves section — + // is untouched (no blank-line insertion anywhere outside the row). + const expected = roadmap.replace( + '| 68 | 0/5 | Planned | - |', + '| 68 | 1/5 | In Progress| |', + ); + assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), expected); + }); + + // Row 5 — 5-column milestone-grouped table, `68. [Scheduler]` cell form. + test('milestone-grouped table form with template cell still updates by column name', () => { + const roadmap = [ + '# Roadmap: T', + '', + '## Progress', + '', + '| Phase | Milestone | Plans Complete | Status | Completed |', + '|-------|-----------|----------------|--------|-----------|', + '| 67. [Waves] | v1.0 | 0/2 | Planned | - |', + '| 68. [Scheduler] | v1.0 | 0/5 | Planned | - |', + '| 69. [Packhouse] | v1.0 | 0/2 | Planned | - |', + '', + ].join('\n'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, true); + + const expected = roadmap.replace( + '| 68. [Scheduler] | v1.0 | 0/5 | Planned | - |', + '| 68. [Scheduler] | v1.0 | 1/5 | In Progress| |', + ); + assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), expected); + }); + + // Row 6/7 — heading-form targets keep every existing behavior. + test('heading-form detail section still updates counts, inserts rows, and marks checkboxes', () => { + const roadmap = [ + '# Roadmap: T', + '', + '## Phases', + '', + '- [ ] **Phase 68: Scheduler** - goal: ship scheduler.', + '', + '### Phase 68: Scheduler', + '**Goal**: ship it', + '**Plans**: 5 plans', + '', + 'Plans:', + '- [ ] 68-02: crew assignment', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| Phase 68 | 0/5 | Planned | - |', + '', + ].join('\n'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, true, 'a heading target is writable — no refusal'); + + const written = fs.readFileSync(roadmapPath, 'utf-8'); + assert.ok(written.includes('**Plans**: 1/5 plans executed'), 'count token rewritten'); + assert.ok(written.includes('- [x] 68-02: crew assignment'), 'summarized plan checkbox marked'); + assert.ok(written.includes('- [ ] 68-01-PLAN.md'), 'missing plan rows inserted'); + }); + + // Row 9/10/11 — boundaries inside a table must not trip the refusal. + test('first and last table rows still update; adjacent rows stay untouched', () => { + for (const cell of ['68', 'Phase 68']) { + const roadmap = [ + '# Roadmap: T', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 67 | 0/2 | Planned | - |', + `| ${cell === '68' ? '68' : 'Phase 68'} | 0/5 | Planned | - |`, + '| 69 | 0/2 | Planned | - |', + '', + ].join('\n'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + const written = fs.readFileSync(roadmapPath, 'utf-8'); + assert.ok(written.includes('| 67 | 0/2 | Planned | - |'), 'adjacent row untouched'); + assert.ok(written.includes('| 69 | 0/2 | Planned | - |'), 'adjacent row untouched'); + if (cell === '68') { + assert.strictEqual(output.updated, true, 'bare cell 68 between siblings updates'); + assert.ok(written.includes('| 68 | 1/5 |'), 'middle row updated'); + } else { + assert.strictEqual(output.updated, false, 'word-prefixed cell is not a writable target'); + assert.strictEqual(written, roadmap, 'nothing written on refusal'); + } + cleanup(tmpDir); + tmpDir = createTempProject('gsd-4247-roadmap-'); + roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + } + }); + + // Row 12 — phase exists only on disk; roadmap has no representation at all. + test('phase absent from the roadmap entirely refuses with missing_phase_details', () => { + const roadmap = [ + '# Roadmap: T', + '', + '## Phases', + '', + '- [ ] **Phase 65: Layout** - goal design.', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 65 | 4/4 | Complete | 2026-08-30 |', + '', + ].join('\n'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, false); + assert.strictEqual(output.reason, 'missing_phase_details'); + assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), roadmap); + }); + + // Row 13 — the #3957 honest no-op decline must survive for target-found reruns. + test('idempotent re-run on an up-to-date table row keeps the honest no-changes decline', () => { + const roadmap = [ + '# Roadmap: T', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 68 | 1/5 | In Progress| |', + '', + ].join('\n'); + seedPhase68WithPlans(tmpDir, { roadmap }); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, false); + assert.notStrictEqual(output.reason, 'missing_phase_details', 'target found — this is a genuine no-op'); + assert.ok(String(output.reason).includes('no changes were needed'), 'preserves the #3957 decline vocabulary'); + assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), roadmap); + }); + + // Row 8 — the completion arm: the checklist entry's own checkbox IS the + // writable phase row for a complete phase. + test('checklist form completing the phase still checks the phase bullet and marks its plan rows', () => { + const roadmap = buildChecklistRoadmap4247('Phase 68').replace( + '- [ ] **Phase 69: Packhouse**', + ' - [ ] 68-01: calendar model\n - [ ] 68-02: crew assignment\n- [ ] **Phase 69: Packhouse**', + ); + const p68 = seedPhase68WithPlans(tmpDir, { roadmap }); + for (const n of ['01', '03', '04', '05']) { + fs.writeFileSync(path.join(p68, `68-${n}-SUMMARY.md`), '# Summary\n'); + } + fs.writeFileSync(path.join(p68, '68-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); + + const result = runGsdTools('roadmap update-plan-progress 68', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.complete, true); + assert.strictEqual(output.updated, true, 'the phase bullet is a writable target when completing'); + + const written = fs.readFileSync(roadmapPath, 'utf-8'); + assert.match(written, /- \[x\] \*\*Phase 68: Scheduler\*\* - goal: ship the picking scheduler\. Progress: 1\/5 plans executed; \(completed \d{4}-\d{2}-\d{2}\)/, 'phase bullet checked with completion date'); + // Other phases' checklist bullets are not checked or annotated. + assert.match(written, /- \[ \] \*\*Phase 65: Orchard Layout\*\*/); + assert.match(written, /- \[ \] \*\*Phase 69: Packhouse\*\*/); + assert.ok(written.includes('- [x] 68-02: crew assignment'), 'summarized plan row marked'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // phase add command // ─────────────────────────────────────────────────────────────────────────────