* 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 <sim@local>
This commit is contained in:
5
.changeset/noble-jays-rest.md
Normal file
5
.changeset/noble-jays-rest.md
Normal file
@@ -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)
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user