From ccfed633551a7687ef3edb1774d6bd43ea34577b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 20 Sep 2026 11:45:50 -0400 Subject: [PATCH] fix(#4823): the Current Plan reset is scoped to the Current Position section (#4898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4823): failing-first — the Current Plan reset must not rewrite prose outside Current Position * fix(#4823): the Current Plan reset is scoped to the Current Position section — the whole-body 'Plan' fallback matched hard-wrapped prose lines starting with plan: * chore(#4823): changeset fragment * chore(#4823): backfill changeset PR number (4898) --------- Co-authored-by: sim --- .changeset/noble-jays-rest.md | 5 +++ src/state-transition.cts | 24 +++++++++++-- tests/state.test.cjs | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 .changeset/noble-jays-rest.md diff --git a/.changeset/noble-jays-rest.md b/.changeset/noble-jays-rest.md new file mode 100644 index 000000000..b98ed995e --- /dev/null +++ b/.changeset/noble-jays-rest.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4898 +--- +**phase complete no longer rewrites prose outside the Current Position section** — the Current Plan reset's fallback matched any hard-wrapped line starting with "plan:" anywhere in STATE.md (case-insensitive, first match wins), silently replacing narrative text with "Not started"; the reset is now scoped to the Current Position section, while legacy sectionless layouts keep their recorded whole-body behavior. (#4823) diff --git a/src/state-transition.cts b/src/state-transition.cts index 54da5f01a..6cff54fe1 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -18,7 +18,7 @@ import frontmatter = require('./frontmatter.cjs'); import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback, stateReplaceFieldInSession, stateCurrentPositionSlice } from './state-document.cjs'; import { KNOWN_TEMPLATE_DEFAULTS, toFiniteNumber, computeProgressPercent } from './state-document.cjs'; -import { tokenizeHeadings } from './markdown-sectionizer.cjs'; +import { tokenizeHeadings, withSection } from './markdown-sectionizer.cjs'; import type { HeadingToken } from './markdown-sectionizer.cjs'; import { deriveProgressFromRoadmap, clampPercent, clampPercentFromFraction, renderProgressBar } from './phase-lifecycle.cjs'; import { escapeRegex } from './pattern.cjs'; @@ -2145,8 +2145,26 @@ function completePhaseCore( updated.push('Status'); } - // Current Plan — reset for the next phase. - const planAfter = stateReplaceFieldWithFallback(body, 'Current Plan', 'Plan', 'Not started'); + // Current Plan — reset for the next phase. #4823: when a Current Position + // section exists (the canonical layout, #2956 locator), the reset is scoped + // to it — the whole-body fallback 'Plan' run matched any hard-wrapped prose + // line starting with `plan:` anywhere in the document and rewrote it to + // 'Not started', silently destroying narrative. Legacy sectionless layouts + // (fields at top level, no section) keep the whole-body behavior unchanged: + // there is no section to scope to, and their fields are the intended + // targets. + let planAfter; + const positionScope = stateCurrentPositionSlice(body); + if (positionScope !== null) { + planAfter = withSection( + body, + (h) => (h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position', + (sectionBody) => stateReplaceFieldWithFallback(sectionBody, 'Current Plan', 'Plan', 'Not started'), + { levelBounded: true }, + ); + } else { + planAfter = stateReplaceFieldWithFallback(body, 'Current Plan', 'Plan', 'Not started'); + } if (planAfter !== body) { body = planAfter; updated.push('Current Plan'); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index ec2a1173b..bac8724da 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -21512,3 +21512,66 @@ describe('#4138: state begin-phase guards its required --phase argument', () => ); }); }); + +// ─── #4823: the Current Plan reset must not rewrite prose outside the section ── + +describe('#4823: Current Plan reset is scoped to the Current Position section', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createFixture(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('a hard-wrapped prose line starting with plan: is never rewritten by phase complete', () => { + // The issue's measured damage: a hard-wrapped bullet in an unrelated + // notes section began with `plan:**` — the whole-body plain branch + // matched it (case-insensitive, first match wins) and rewrote the rest + // of the line to 'Not started'. The reset must only touch the Current + // Position section. + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, [ + '# Project State', + '', + '**Current Phase:** 2', + '**Status:** Executing Phase 2', + '', + '## Current Position', + '', + 'Phase: 2 — Two', + 'Plan: **2 of 3 complete**', + '', + '## Accumulated Context', + '', + '- **One deviation beyond', + 'plan:** `foo()` was found and fixed as a Rule 1 bug.', + '', + '## Phase 2 notes', + '', + 'Plan: **3 of 3 complete**', + '', + ].join('\n')); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n'); + fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary 1\n'); + writePassedVerification(tmpDir, '02-api', '02'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n## Phase 2: API\n\n- [ ] Phase 2: API Layer\n' + ); + + const result = runGsdTools('phase complete 2', tmpDir); + assert.ok(result.success, `phase complete failed: ${result.error}`); + + const stateAfter = fs.readFileSync(statePath, 'utf-8'); + assert.ok( + stateAfter.includes('plan:** `foo()` was found and fixed as a Rule 1 bug.'), + 'the hard-wrapped prose line must be byte-identical — the plain-branch reset must never cross into narrative', + ); + }); +});