From 5fff839713e0f9e3987925fc6eab2472efd1dd4d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 14 Aug 2026 01:45:38 -0400 Subject: [PATCH] fix(#3258): honor all field-classification preservation rows (#3447) * fix(#3258): honor all field-classification preservation rows * chore(#3258): set changeset pr to 3447 --------- Co-authored-by: sim --- .changeset/quick-lynx-wander.md | 5 + src/state-transition.cts | 88 +++++++++++++ src/state.cts | 63 +++++++-- tests/state-transition.test.cjs | 222 ++++++++++++++++++++++++++++++++ tests/state.test.cjs | 114 ++++++++++++++++ 5 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 .changeset/quick-lynx-wander.md diff --git a/.changeset/quick-lynx-wander.md b/.changeset/quick-lynx-wander.md new file mode 100644 index 000000000..d2e7e2191 --- /dev/null +++ b/.changeset/quick-lynx-wander.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3447 +--- +Six STATE.md frontmatter fields whose preservation policy is declared in the field-classification table were not honored by the table-driven preservation pass — `last_activity_desc`, `paused_at`, `current_phase`, `current_plan` (preserve-when-unchanged) and `milestone`, `milestone_name` (preserve-if-placeholder). The pass now implements every declared row, so editing a preservation row is a one-row table edit as the table's own contract documents. Curated frontmatter values for `paused_at` / `current_phase` / `current_plan` now survive a body-only write (e.g. `state update`) even when the body carries a stale-but-present derived value — previously only an absent derived value triggered the fallback, so a stale body value silently overwrote the curated frontmatter value. `last_activity_desc` is now governed by a single rule (the table row) rather than a separate date-comparison guard that could disagree with it. (#3258) diff --git a/src/state-transition.cts b/src/state-transition.cts index 8f370e414..330c7ebda 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -158,6 +158,19 @@ export type StatePreservationInput = { * leave this false — the #3242 wholesale protection stays in force. */ deriveProgressKeys?: boolean; + /** + * #3258: pre/post body-source values for the preserve-when-unchanged (#1230 + * delta heuristic) family, keyed by frontmatter field. The caller snapshots + * each field's body source before/after the transform (mirroring how + * buildStateFrontmatter derives it); applyStatePreservation consults + * FIELD_CLASSIFICATION and, for every row whose policy is preserve-when- + * unchanged that is NOT already on a dedicated channel (status, stopped_at), + * restores the pre-write frontmatter value when that body source was left + * unchanged by this write. Adding a preserve-when-unchanged row is a one-row + * table edit PLUS a bodyDeltas entry from the caller; the invariant test in + * tests/state-transition.test.cjs fails if either is missing. + */ + bodyDeltas?: Record; }; export type StatePreservationResult = { @@ -271,6 +284,81 @@ export function applyStatePreservation(input: StatePreservationInput): StatePres mutated = true; } + // ─── #3258: the remaining preserve-when-unchanged rows ──────────────────── + // + // status and stopped_at (above) carry field-specific guards (the 'unknown' + // sentinel; ## Session scoping done by the caller) and stay on their + // dedicated blocks. The other preserve-when-unchanged rows — + // last_activity_desc, paused_at, current_phase, current_plan — share a + // uniform #1230 delta heuristic with no extra sentinel, so they are driven + // from the table in one loop. This is the reconciliation the maintainer + // rescope to #3258 asked for: the row is honored by the table-consuming pass + // itself, not only by a weaker absent-value fallback elsewhere. + // + // For last_activity_desc this replaces preferNewerLastActivity's date-based + // desc writes (removed from syncStateFrontmatter + cmdStateJson): the field + // is now governed by exactly one rule, this one. For paused_at / current_phase + // / current_plan the declared delta heuristic is strictly stronger than the + // #905 absent-fallback that syncStateFrontmatter still applies on the + // writeStateMd / cmdStateJson paths this pass does not reach — same policy, + // two enforcement points, they agree by construction (both restore the + // pre-write snapshot value when they fire). + const bodyDeltas = input.bodyDeltas; + if (bodyDeltas) { + for (const field of Object.keys(FIELD_CLASSIFICATION)) { + const cls = getFieldClassification(field); + if (!cls || cls.preservation !== 'preserve-when-unchanged') continue; + // status / stopped_at stay on their dedicated blocks above. + if (field === 'status' || field === 'stopped_at') continue; + const delta = bodyDeltas[field]; + if (!delta) continue; // caller did not wire this field's body source → cannot decide + const snapshot = preFmSnapshot[field]; + if (typeof snapshot !== 'string' || snapshot.length === 0) continue; + if (delta.pre !== delta.post) continue; // body source changed this write → derived wins + if (postFm[field] === snapshot) continue; // already correct, no-op + postFm[field] = snapshot; + mutated = true; + } + } + + // ─── #3258: preserve-if-placeholder — milestone / milestone_name ────────── + // + // The derived milestone_name (from ROADMAP.md via buildStateFrontmatter) must + // not clobber a curated name when it resolves to the template placeholder + // 'milestone' or a punctuation-led fragment. Mirrors the #948/#2135 guard in + // syncStateFrontmatter so the table pass enforces the same policy on the RMW + // path; syncStateFrontmatter remains the enforcement point on the writeStateMd + // / cmdStateJson paths this pass does not reach (same policy, two points). + // The milestone VERSION is restored alongside the name, exactly as the sync + // guard does, so the two stay consistent. + const milestoneNameCls = getFieldClassification('milestone_name'); + if (milestoneNameCls !== null && milestoneNameCls.preservation === 'preserve-if-placeholder') { + const MILESTONE_PLACEHOLDER = 'milestone'; + const derivedName = postFm['milestone_name']; + const derivedLooksLikeName = typeof derivedName === 'string' + && derivedName.length > 0 + && derivedName !== MILESTONE_PLACEHOLDER + && !/^[\s—–:-]/.test(derivedName); + const snapshotName = preFmSnapshot['milestone_name']; + const snapshotNameIsReal = typeof snapshotName === 'string' + && snapshotName.length > 0 + && snapshotName !== MILESTONE_PLACEHOLDER; + if (!derivedLooksLikeName && snapshotNameIsReal) { + if (postFm['milestone_name'] !== snapshotName) { + postFm['milestone_name'] = snapshotName; + mutated = true; + } + const snapshotVersion = preFmSnapshot['milestone']; + if ( + typeof snapshotVersion === 'string' && snapshotVersion.length > 0 && + postFm['milestone'] !== snapshotVersion + ) { + postFm['milestone'] = snapshotVersion; + mutated = true; + } + } + } + return { postFm, mutated }; } diff --git a/src/state.cts b/src/state.cts index 7f52f562b..6bab9a712 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1466,18 +1466,18 @@ function preferNewerLastActivity( const exDate = exRaw.slice(0, 10); const derDate = derRaw.slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate)) return; + // #3258: this guard now protects only `last_activity` (a `derive` row) against + // the stale-archive regression (#2567). `last_activity_desc` used to be + // restored here too (both the older-date and the #3052 same-date branches), + // but that was a date-comparison rule — a DIFFERENT policy from the + // `preserve-when-unchanged` row its FIELD_CLASSIFICATION entry declares. + // Keeping both was two rules that could disagree. last_activity_desc is now + // governed by exactly one rule: its table row, enforced by + // applyStatePreservation's #1230 delta heuristic on the RMW path (where every + // desc-preserving transition — planned-phase / advance / complete / milestone + // — runs). The #3052 same-date contract still holds via that delta rule. if (derDate < exDate) { derivedFm['last_activity'] = exRaw; - if (existingFm['last_activity_desc'] !== undefined) { - derivedFm['last_activity_desc'] = existingFm['last_activity_desc']; - } - } else if (derDate === exDate) { - // #3052: same-date — frontmatter is authoritative for this date, so - // preserve its last_activity_desc rather than letting the derived body - // prose (which may be stale) overwrite it. - if (existingFm['last_activity_desc'] !== undefined) { - derivedFm['last_activity_desc'] = existingFm['last_activity_desc']; - } } } @@ -2687,6 +2687,24 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string // table's preserve-always row so the rule lives in one place. const preBodyPhaseSource = stateExtractField(preBody, 'Phase'); + // #3258: snapshot the body sources for the additional preserve-when-unchanged + // rows applyStatePreservation now honors (last_activity_desc, paused_at, + // current_phase, current_plan). Each mirrors buildStateFrontmatter's + // derivation so the #1230 delta ("did THIS write change the source?") is + // accurate: current_phase combines `Current Phase` with the prose `Phase:` + // fallback (parseProsePhaseField, scoped to ## Current Position); paused_at + // is session-scoped (mirrors stopped_at); last_activity_desc combines the + // `Last Activity Description` field with the prose desc fallback. + const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody; + const preBodyCurrentPlan = stateExtractField(preBody, 'Current Plan'); + const preBodyCurrentPhase = stateExtractField(preBody, 'Current Phase') + ?? parseProsePhaseField(stateExtractField(preCurrentPositionScope, 'Phase')).phase; + const preBodyPausedAt = stateExtractField(preSessionScope, 'Paused At'); + const preBodyLastActivityRaw = stateExtractField(preBody, 'Last Activity') + ?? stateExtractField(preBody, 'Last activity'); + const preBodyLastActivityDesc = stateExtractField(preBody, 'Last Activity Description') + ?? parseProseLastActivityField(preBodyLastActivityRaw).description; + const modified = transformFn(content); // Bug #948: no-op guard — if the transform produced no change, do NOT write @@ -2715,18 +2733,39 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the // current_phase_name delta comparison. const postBodyPhaseSource = stateExtractField(postBody, 'Phase'); + // #3258: post-transform body sources for the preserve-when-unchanged rows + // added in #3258 (mirrors the pre-transform block above). + const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody; + const postBodyCurrentPlan = stateExtractField(postBody, 'Current Plan'); + const postBodyCurrentPhase = stateExtractField(postBody, 'Current Phase') + ?? parseProsePhaseField(stateExtractField(postCurrentPositionScope, 'Phase')).phase; + const postBodyPausedAt = stateExtractField(postSessionScope, 'Paused At'); + const postBodyLastActivityRaw = stateExtractField(postBody, 'Last Activity') + ?? stateExtractField(postBody, 'Last activity'); + const postBodyLastActivityDesc = stateExtractField(postBody, 'Last Activity Description') + ?? parseProseLastActivityField(postBodyLastActivityRaw).description; + const bodyDeltas = { + last_activity_desc: { pre: preBodyLastActivityDesc, post: postBodyLastActivityDesc }, + paused_at: { pre: preBodyPausedAt, post: postBodyPausedAt }, + current_phase: { pre: preBodyCurrentPhase, post: postBodyCurrentPhase }, + current_plan: { pre: preBodyCurrentPlan, post: postBodyCurrentPlan }, + }; // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync // preservation block is now the pure, table-driven `applyStatePreservation` // in the STATE.md Transition Module. progress / status / stopped_at / // current_phase_name are all governed by their FIELD_CLASSIFICATION row — - // one policy source, not three drifting encodings. Behavior-identical to - // the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md + // one policy source, not three drifting encodings. #3258 extends the same + // pass to last_activity_desc / paused_at / current_phase / current_plan + // (preserve-when-unchanged) and milestone / milestone_name (preserve-if- + // placeholder). Behavior-identical to the pre-#1796 inline block for the + // original four fields; this is the absorption ADR-1769 / CONTEXT.md // already claimed shipped. const postFm = extractFrontmatter(synced, statePath) as Record; const preservation = applyStatePreservation({ preFm, postFm, preFmSnapshot, resync, deriveProgressKeys: options?.deriveProgressKeys === true, + bodyDeltas, preBodyStatus, postBodyStatus, preBodyStoppedAt, postBodyStoppedAt, preBodyPhaseSource, postBodyPhaseSource, diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index d69e263f2..0ee9f6143 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -1476,6 +1476,228 @@ describe('ADR-1769 #1796: applyStatePreservation — table-driven post-sync cons }); }); +// ───────────────────────────────────────────────────────────────────────────── +// #3258: every FIELD_CLASSIFICATION row declaring a preservation policy must be +// honored by applyStatePreservation (the table-consuming pass). The table's own +// docstring promises "a policy change is a one-row table edit" — this invariant +// proves it: for every non-`derive`/non-`clear` row, a minimal input where the +// declared policy would restore the snapshot value DOES restore it. Adding a +// new preservation row without an implementation branch makes this fail. +// +// Written FIRST and RED before the fix. Before the fix this fails for six rows: +// last_activity_desc, paused_at, current_phase, current_plan (preserve-when- +// unchanged, only approximated by the weaker #905 absent-fallback) and +// milestone, milestone_name (preserve-if-placeholder, enforced only by the +// #948/#2135 guard in syncStateFrontmatter). See issue #3258. +// ───────────────────────────────────────────────────────────────────────────── + +describe('#3258: applyStatePreservation honors every declared preservation row', () => { + const GOOD_PLACEHOLDER = 'preserved-by-table'; + // Universal "body source unchanged this write" deltas. Every + // preserve-when-unchanged probe reuses these so the delta condition (pre === + // post) is satisfied and the only variable is whether the branch exists. + const SAME = { pre: 'unchanged-source', post: 'unchanged-source' }; + const unchangedBodyDeltas = { + status: SAME, + stopped_at: SAME, + paused_at: SAME, + current_phase: SAME, + current_plan: SAME, + last_activity_desc: SAME, + }; + // Dedicated-channel deltas (the four pre-#3258 rows use these, unchanged). + const sameStatus = { preBodyStatus: 'x', postBodyStatus: 'x' }; + const sameStoppedAt = { preBodyStoppedAt: 'x', postBodyStoppedAt: 'x' }; + const samePhaseSource = { preBodyPhaseSource: 'x', postBodyPhaseSource: 'x' }; + + // Per-policy probe. Returns whether applyStatePreservation restored the + // field's snapshot value under an input crafted so the declared policy fires. + function honored(field) { + const cls = getFieldClassification(field); + if (!cls) return false; + const policy = cls.preservation; + if (policy === 'derive' || policy === 'clear') return true; // not a preservation policy + + const GOOD = 'preserved-by-table'; + const BAD = 'clobbered-by-derive'; + + if (policy === 'preserve-always') { + if (field === 'progress') { + const curated = { progress: { total_phases: 4, completed_phases: 3, percent: 75 } }; + const r = applyStatePreservation({ + preFm: curated, preFmSnapshot: curated, + postFm: { progress: { total_phases: 5, completed_phases: 0, percent: 0 } }, + resync: false, bodyDeltas: unchangedBodyDeltas, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + return JSON.stringify(r.postFm.progress) === JSON.stringify(curated.progress); + } + // current_phase_name: preserve-always, restored when body Phase source unchanged. + const r = applyStatePreservation({ + preFm: null, preFmSnapshot: { [field]: GOOD }, + postFm: { [field]: BAD }, resync: false, bodyDeltas: unchangedBodyDeltas, + ...sameStatus, ...sameStoppedAt, preBodyPhaseSource: '3', postBodyPhaseSource: '3', + }); + return r.postFm[field] === GOOD; + } + + if (policy === 'preserve-when-unchanged') { + const r = applyStatePreservation({ + preFm: null, preFmSnapshot: { [field]: GOOD }, + postFm: { [field]: BAD }, resync: true, bodyDeltas: unchangedBodyDeltas, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + return r.postFm[field] === GOOD; + } + + if (policy === 'preserve-if-placeholder') { + // Derived name is the placeholder 'milestone'; snapshot holds a real + // name+version. Mirrors the #948/#2135 contract: name restored to the + // curated snapshot, version restored alongside it. + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { milestone: GOOD, milestone_name: GOOD }, + postFm: { milestone: 'derived-version', milestone_name: 'milestone' }, + resync: true, bodyDeltas: unchangedBodyDeltas, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + return r.postFm[field] === GOOD; + } + + return false; + } + + test('every non-derive/non-clear preservation row is honored (the one-row-table-edit contract)', () => { + const expected = []; + for (const [field, cls] of Object.entries(FIELD_CLASSIFICATION)) { + if (cls.preservation !== 'derive' && cls.preservation !== 'clear') { + expected.push(field); + } + } + const missing = expected.filter((f) => !honored(f)); + assert.deepEqual( + missing, + [], + `applyStatePreservation does not honor these declared preservation rows (expected every ` + + `non-derive/non-clear row to restore its snapshot value): ${JSON.stringify(missing)}. ` + + `Add a branch per ADR-1769 §4 so the table is the single policy source (#3258).`, + ); + }); + + const unchchangedChanged = { + status: { pre: 'x', post: 'x' }, + stopped_at: { pre: 'x', post: 'x' }, + paused_at: { pre: 'x', post: 'x' }, + current_phase: { pre: 'x', post: 'x' }, + current_plan: { pre: 'x', post: 'x' }, + last_activity_desc: { pre: 'old description', post: 'new description from transition' }, // changed + }; + + test('a row declaring a preservation policy with no implementation would be caught (sentinel)', () => { + // Proves the invariant above actually catches a missing implementation: a + // field with a preservation policy but no body-delta wiring / branch does + // not get restored. We simulate this by probing a field whose body-source + // delta the caller forgot to supply (the realistic regression shape). + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { current_plan: GOOD_PLACEHOLDER }, + postFm: { current_plan: 'derived' }, + resync: true, + bodyDeltas: {}, // caller forgot to wire current_plan's body-source delta + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + assert.notEqual(r.postFm.current_plan, GOOD_PLACEHOLDER, + 'a preserve-when-unchanged row with no body-source delta wiring must NOT be restored ' + + '(proves the invariant would catch a new unimplemented row)'); + }); + + // Per-field restore tests (clearer failure messages than the set-equality + // invariant alone, and they document each row's declared semantics). + + test('last_activity_desc: preserve-when-unchanged restores snapshot when body source unchanged', () => { + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { last_activity_desc: 'authoritative description' }, + postFm: { last_activity_desc: 'stale derived description' }, + resync: true, + bodyDeltas: { ...unchangedBodyDeltas }, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + assert.equal(r.postFm.last_activity_desc, 'authoritative description'); + assert.equal(r.mutated, true); + }); + + test('last_activity_desc: derived wins when the body source changed this write (no over-preservation)', () => { + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { last_activity_desc: 'old description' }, + postFm: { last_activity_desc: 'new description from transition' }, + resync: true, + bodyDeltas: { ...unchchangedChanged }, // body 'Last Activity Description' moved + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + assert.equal(r.postFm.last_activity_desc, 'new description from transition'); + assert.equal(r.mutated, false); + }); + + test('paused_at: preserve-when-unchanged restores curated value over a stale-but-present derived value', () => { + // Group 2: the declared #1230 delta heuristic beats the weaker #905 + // absent-fallback. Derived is PRESENT but stale; body source unchanged → + // curated frontmatter value wins. + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { paused_at: '2026-02-02' }, + postFm: { paused_at: '2026-01-01' }, + resync: true, + bodyDeltas: { ...unchangedBodyDeltas }, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + assert.equal(r.postFm.paused_at, '2026-02-02'); + assert.equal(r.mutated, true); + }); + + test('current_phase: preserve-when-unchanged restores curated value over a stale derived value', () => { + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { current_phase: '4' }, + postFm: { current_phase: '2' }, + resync: true, + bodyDeltas: { ...unchangedBodyDeltas }, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + assert.equal(r.postFm.current_phase, '4'); + assert.equal(r.mutated, true); + }); + + test('current_plan: preserve-when-unchanged restores curated value over a stale derived value', () => { + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { current_plan: '5' }, + postFm: { current_plan: '3' }, + resync: true, + bodyDeltas: { ...unchangedBodyDeltas }, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + assert.equal(r.postFm.current_plan, '5'); + assert.equal(r.mutated, true); + }); + + test('milestone / milestone_name: preserve-if-placeholder restores curated name when derived is placeholder', () => { + const r = applyStatePreservation({ + preFm: null, + preFmSnapshot: { milestone: '0.1', milestone_name: 'Real Curated Name' }, + postFm: { milestone: '0.x', milestone_name: 'milestone' }, // placeholder derive + resync: true, + bodyDeltas: { ...unchangedBodyDeltas }, + ...sameStatus, ...sameStoppedAt, ...samePhaseSource, + }); + assert.equal(r.postFm.milestone_name, 'Real Curated Name', + 'placeholder-derived milestone_name must yield to the curated snapshot (#948/#2135 contract)'); + assert.equal(r.postFm.milestone, '0.1', + 'milestone version must stay consistent with the preserved name'); + }); +}); + // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-21-state-md-template-frontmatter.test.cjs — consolidation epic #1969 (B8 #1977) diff --git a/tests/state.test.cjs b/tests/state.test.cjs index de0fd474f..b068ebe0c 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -4818,6 +4818,120 @@ describe('last_activity / paused_at frontmatter not overwritten by historical pr }); }); +// ───────────────────────────────────────────────────────────────────────────── +// #3258: the FIELD_CLASSIFICATION preserve-when-unchanged rows for the Group 2 +// fields (paused_at, current_phase, current_plan) are now honored by +// applyStatePreservation. Before the fix the only protection was the weaker +// #905 absent-fallback in syncStateFrontmatter, which restores a field ONLY when +// the derived value is falsy/absent — so a stale-but-present body value won +// over a curated frontmatter value on every body-only write. These pin the +// declared semantics end-to-end through the CLI: an unrelated `state update` +// (which does NOT touch the Group 2 body source) must leave the curated +// frontmatter values intact. +// ───────────────────────────────────────────────────────────────────────────── + +describe('#3258: Group 2 preserve-when-unchanged beats the weaker absent-fallback (paused_at / current_phase / current_plan)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createFixture(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function frontmatterBlock(stateContent) { + const m = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return m ? m[1] : ''; + } + + // STATE.md with curated frontmatter values and stale-but-present body values + // for all three Group 2 fields. A body-only write that does not touch any of + // them must NOT let the stale derived value win. + function writeGroup2Fixture() { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + [ + '---', + "gsd_state_version: '1.0'", + "current_phase: '4'", + "current_plan: '5'", + "paused_at: '2026-02-02'", + 'status: executing', + '---', + '', + '# Project State', + '', + '**Current Phase:** 2', + '**Current Plan:** 3', + '**Status:** In progress', + '', + '## Current Position', + 'Phase: 2', + 'Plan: 3', + 'Status: In progress', + '', + '## Session', + '', + 'Last Date: 2026-02-01', + 'Paused At: 2026-01-01', + '', + ].join('\n'), + ); + } + + test('paused_at: curated frontmatter value survives a body-only write that leaves the body source unchanged', () => { + writeGroup2Fixture(); + // `state update` on Status is a body-only RMW write (resync=false) that does + // NOT touch the Paused At body source. The stale body value (2026-01-01) + // must not overwrite the curated frontmatter value (2026-02-02). + const result = runGsdTools('state update Status "Executing"', tmpDir); + assert.ok(result.success, `state update failed: ${result.error}`); + + const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8')); + assert.ok(/paused_at:[^\n]*2026-02-02/.test(fm), + `paused_at must keep the curated value (2026-02-02); frontmatter was:\n${fm}`); + assert.ok(!/paused_at:[^\n]*2026-01-01/.test(fm), + `stale body-derived paused_at (2026-01-01) must not win; frontmatter was:\n${fm}`); + }); + + test('current_plan: curated frontmatter value survives a body-only write that leaves the body source unchanged', () => { + writeGroup2Fixture(); + runGsdTools('state update Status "Executing"', tmpDir); + + const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8')); + assert.ok(/current_plan:[^\n]*5/.test(fm), + `current_plan must keep the curated value (5); frontmatter was:\n${fm}`); + assert.ok(!/current_plan:[^\n]*3\b/.test(fm), + `stale body-derived current_plan (3) must not win; frontmatter was:\n${fm}`); + }); + + test('current_phase: curated frontmatter value survives a body-only write that leaves the body source unchanged', () => { + writeGroup2Fixture(); + runGsdTools('state update Status "Executing"', tmpDir); + + const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8')); + assert.ok(/current_phase:[^\n]*4/.test(fm), + `current_phase must keep the curated value (4); frontmatter was:\n${fm}`); + assert.ok(!/current_phase:[^\n]*2\b/.test(fm), + `stale body-derived current_phase (2) must not win; frontmatter was:\n${fm}`); + }); + + test('Group 2 fields still re-derive when the body source actually changes (no over-preservation)', () => { + // When the transform DOES change the body source, the derived value must + // win — preserve-when-unchanged must not freeze a field that a transition + // intentionally moved. Drive it via `state update "Current Plan"`. + writeGroup2Fixture(); + const result = runGsdTools('state update "Current Plan" "7"', tmpDir); + assert.ok(result.success, `state update failed: ${result.error}`); + + const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8')); + assert.ok(/current_plan:[^\n]*7/.test(fm), + `current_plan must take the new body value (7) when the body source changed; frontmatter was:\n${fm}`); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Bug #2445: stale phase dirs from closed milestone inflate phase counts // ─────────────────────────────────────────────────────────────────────────────