* fix(#3374): phase.complete stops harvesting stale body stopped_at Variant A: cmdPhaseComplete's adapter calls syncStateFrontmatter directly (deliberately - STATE.md commits atomically with ROADMAP/REQUIREMENTS), which also bypassed the #948/#1230 preservation pass every RMW write gets. A stale body 'Stopped at:' line then silently clobbered a fresher frontmatter stopped_at on every phase completion, with warnings: []. Three layers close it without reversing #3517's refresh expectation: - completePhaseCore now refreshes the body continuity line it implies ('Phase N complete, ready to plan Phase N+1'; ADR-2207 phrasing on the last phase), session-scoped via the new stateReplaceFieldInSession seam so a decoy bold Stopped-at line in an unrelated section cannot absorb the refresh. Replace-only - a layout with no session line keeps its shape and its frontmatter value survives via the preservation delta. - the RMW post-sync preservation chunk (snapshots + table-driven applyStatePreservation + #2736 re-assert, full bodyDeltas wired) is extracted into the shared applyPostSyncPreservation helper; the phase.complete adapter and writeStateMd (milestone complete / state sync - the gap the closed PR #3442 review flagged) now run it too. - cmdStateRecordSession pushed 'Stopped At' onto updated[] on any label MATCH, including a value already on disk - reporting a write that never changed a byte. It now reports only on real change, and the match is tracked separately so an identical value does not arm the #944 DWIM section rewrite (which would reset an executor-authored resume file to None). * docs(#3374): backfill changeset pr field to 3491 * fix(#3374): drop the writeStateMd preservation pass - state sync's #905 contract is body-wins CI on this PR caught what the closed PR #3442 review's MAJOR remediation option (a) would have broken: state sync's #905 contract ('body annotation beats existing frontmatter when both are present') is the opposite by design - sync exists to re-derive frontmatter from the body. A blanket applyStatePreservation pass on writeStateMd re-locked stale frontmatter (current_phase 3 over the body's 5) on every sync. Take the review's sanctioned option (b) instead: the scope claim is accurate (phase.complete only) and the milestone complete / state sync exposure is tracked as follow-up issue #3492. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -2218,6 +2218,183 @@ test('extractFrontmatter handles large frontmatter blocks without body bleed', (
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// #3374 — `phase complete`'s adapter calls syncStateFrontmatter directly
|
||||
// (deliberately bypassing readModifyWriteStateMd for the atomic
|
||||
// ROADMAP/REQUIREMENTS/STATE commit), which also bypassed the #948/#1230
|
||||
// preservation pass every RMW write gets. A stale body `Stopped at:` line then
|
||||
// silently clobbered a fresher frontmatter `stopped_at` on every phase
|
||||
// completion. Placed beside the #2736 suite (the same defect family: the
|
||||
// adapter's post-sync policy diverging from the RMW path's). The fix is
|
||||
// two-layered: completePhaseCore now refreshes the body continuity line it
|
||||
// implies (`Phase N complete, ready to plan Phase N+1`) — session-scoped, so a
|
||||
// decoy `**Stopped at:**` line in an unrelated section cannot absorb the
|
||||
// refresh — so the harvest projects a value this very completion produced
|
||||
// (keeping #3517's refresh expectation), and the adapter runs the RMW post-sync
|
||||
// preservation pass (applyPostSyncPreservation) so a body source this write did
|
||||
// not refresh cannot beat a fresher frontmatter value.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __d3374, test: __t3374, beforeEach: __be3374, afterEach: __ae3374 } = require('node:test');
|
||||
const __assert3374 = require('node:assert/strict');
|
||||
const __fs3374 = require('node:fs');
|
||||
const __path3374 = require('node:path');
|
||||
const { runGsdTools: __run3374, createTempProject: __mk3374, cleanup: __rm3374 } = require('./helpers.cjs');
|
||||
const { extractFrontmatter: __extractFm3374 } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
const { stateExtractField: __extractField3374 } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
|
||||
const FRESH_3374 = 'Phase 2 gap closure executed — FRESH frontmatter value';
|
||||
const STALE_3374 = 'Phase 1 complete, ready to plan Phase 2';
|
||||
const COMPLETION_LINE_3374 = 'Phase 2 complete, ready to plan Phase 3';
|
||||
|
||||
// Mirrors the issue's repro: a 3-phase roadmap completing phase 2 (not-last),
|
||||
// body `## Session Continuity` holding a stale plain-label `Stopped at:` line
|
||||
// that phase.complete's transition previously never touched.
|
||||
function writeCompleteFixture3374(tmpDir, { fmStoppedAt = null, sessionStoppedAt = STALE_3374, decoy = false } = {}) {
|
||||
const planningDir = __path3374.join(tmpDir, '.planning');
|
||||
const phase2Dir = __path3374.join(planningDir, 'phases', '02-second-phase');
|
||||
__fs3374.mkdirSync(phase2Dir, { recursive: true });
|
||||
|
||||
__fs3374.writeFileSync(
|
||||
__path3374.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'### Phase 1: First phase',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'### Phase 2: Second phase',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'### Phase 3: Third phase',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'- [x] **Phase 1: First phase** - done',
|
||||
'- [ ] **Phase 2: Second phase** - pending',
|
||||
'- [ ] **Phase 3: Third phase** - pending',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const sessionLines = [
|
||||
'Last session: 2026-08-10',
|
||||
...(sessionStoppedAt === null ? [] : [`Stopped at: ${sessionStoppedAt}`]),
|
||||
'Resume file: None',
|
||||
];
|
||||
__fs3374.writeFileSync(
|
||||
__path3374.join(planningDir, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
"gsd_state_version: '1.0'",
|
||||
'milestone: v1.0',
|
||||
'current_phase: 2',
|
||||
'current_phase_name: Second phase',
|
||||
'status: executing',
|
||||
...(fmStoppedAt ? [`stopped_at: "${fmStoppedAt}"`] : []),
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
...(decoy ? ['', '## Archive notes', '', '**Stopped at:** old prose from June'] : []),
|
||||
'',
|
||||
'## Session Continuity',
|
||||
'',
|
||||
...sessionLines,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
__fs3374.writeFileSync(__path3374.join(phase2Dir, '02-01-PLAN.md'), '# Plan\n');
|
||||
__fs3374.writeFileSync(__path3374.join(phase2Dir, '02-01-SUMMARY.md'), '# Summary\n');
|
||||
__fs3374.writeFileSync(
|
||||
__path3374.join(phase2Dir, '02-VERIFICATION.md'),
|
||||
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
__d3374('#3374: phase complete must not harvest a stale body Stopped at over fresher frontmatter', () => {
|
||||
let tmpDir;
|
||||
const statePath = () => __path3374.join(tmpDir, '.planning', 'STATE.md');
|
||||
|
||||
__be3374(() => { tmpDir = __mk3374(); });
|
||||
__ae3374(() => { __rm3374(tmpDir); });
|
||||
|
||||
__t3374('AC1: the stale body Stopped at never reaches the frontmatter — the transition refreshes the line it implies', () => {
|
||||
writeCompleteFixture3374(tmpDir, { fmStoppedAt: FRESH_3374 });
|
||||
|
||||
const result = __run3374(['phase', 'complete', '2'], tmpDir);
|
||||
__assert3374.ok(result.success, `phase complete failed: ${result.error}`);
|
||||
|
||||
const stateContent = __fs3374.readFileSync(statePath(), 'utf-8');
|
||||
const fm = __extractFm3374(stateContent);
|
||||
__assert3374.notStrictEqual(
|
||||
fm.stopped_at,
|
||||
STALE_3374,
|
||||
'phase.complete harvested the stale pre-completion body value into the frontmatter (#3374 Variant A)',
|
||||
);
|
||||
__assert3374.strictEqual(
|
||||
fm.stopped_at,
|
||||
COMPLETION_LINE_3374,
|
||||
`the frontmatter must project the continuity line this completion wrote, never the stale value; got ${JSON.stringify(fm.stopped_at)}`,
|
||||
);
|
||||
__assert3374.strictEqual(
|
||||
__extractField3374(stateContent, 'Stopped at'),
|
||||
COMPLETION_LINE_3374,
|
||||
'the body continuity line must be refreshed by the transition itself, not left for a later prose step',
|
||||
);
|
||||
});
|
||||
|
||||
__t3374('AC1 scoping: a decoy Stopped at in a non-session section cannot absorb the continuity refresh', () => {
|
||||
writeCompleteFixture3374(tmpDir, { fmStoppedAt: FRESH_3374, decoy: true });
|
||||
|
||||
const result = __run3374(['phase', 'complete', '2'], tmpDir);
|
||||
__assert3374.ok(result.success, `phase complete failed: ${result.error}`);
|
||||
|
||||
// The harvest reads ONLY the session scope, so the projected frontmatter
|
||||
// value proves the session line (not the decoy) was the one refreshed.
|
||||
const fm = __extractFm3374(__fs3374.readFileSync(statePath(), 'utf-8'));
|
||||
__assert3374.strictEqual(
|
||||
fm.stopped_at,
|
||||
COMPLETION_LINE_3374,
|
||||
`the session-scoped continuity write must win over the whole-body decoy; got ${JSON.stringify(fm.stopped_at)}`,
|
||||
);
|
||||
});
|
||||
|
||||
__t3374('AC1 preservation leg: with no session Stopped at line to refresh, the fresher frontmatter value survives', () => {
|
||||
writeCompleteFixture3374(tmpDir, { fmStoppedAt: FRESH_3374, sessionStoppedAt: null });
|
||||
|
||||
const result = __run3374(['phase', 'complete', '2'], tmpDir);
|
||||
__assert3374.ok(result.success, `phase complete failed: ${result.error}`);
|
||||
|
||||
// Replace-only continuity write missed → nothing to harvest → the
|
||||
// pre-existing (fresher) frontmatter value must be preserved, not
|
||||
// dropped or replaced with pre-completion prose.
|
||||
const fm = __extractFm3374(__fs3374.readFileSync(statePath(), 'utf-8'));
|
||||
__assert3374.strictEqual(
|
||||
fm.stopped_at,
|
||||
FRESH_3374,
|
||||
`with no body source refreshed by this write, the existing frontmatter value must survive; got ${JSON.stringify(fm.stopped_at)}`,
|
||||
);
|
||||
});
|
||||
|
||||
__t3374('AC2 (no-regress): with no pre-existing frontmatter stopped_at, the body line populates it', () => {
|
||||
writeCompleteFixture3374(tmpDir, {});
|
||||
|
||||
const result = __run3374(['phase', 'complete', '2'], tmpDir);
|
||||
__assert3374.ok(result.success, `phase complete failed: ${result.error}`);
|
||||
|
||||
const fm = __extractFm3374(__fs3374.readFileSync(statePath(), 'utf-8'));
|
||||
__assert3374.strictEqual(
|
||||
fm.stopped_at,
|
||||
COMPLETION_LINE_3374,
|
||||
`first-write population from the (refreshed) body line must keep working; got ${JSON.stringify(fm.stopped_at)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/fix-2847-gap-closure-frontmatter.test.cjs — test-hygiene sweep #3335 (H3 Wave 3)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2207,6 +2207,86 @@ describe('cmdStateRecordSession (state record-session)', () => {
|
||||
assert.strictEqual(output.recorded, false, 'recorded should be false when no session fields found');
|
||||
assert.ok(output.reason !== undefined, 'should have a reason');
|
||||
});
|
||||
|
||||
// #3374 Variant B: stateReplaceField returns the replaced string on any label
|
||||
// MATCH, including when the value is already the target — so `updated` used
|
||||
// to report 'Stopped At' for a write that never changed a byte (and that the
|
||||
// #948 no-op guard then discarded entirely), leaving a stale frontmatter
|
||||
// stopped_at undetectable to the caller. The report must reflect real change,
|
||||
// and a matched-but-identical value must NOT arm the #944 DWIM insertion
|
||||
// branch (which wholesale-rewrites the session section and would reset an
|
||||
// executor-authored resume file to 'None').
|
||||
test('#3374: --stopped-at with the value already in the body is not reported updated and writes nothing', () => {
|
||||
const PINNED_MS = Date.parse('2020-09-01T09:00:00.000Z');
|
||||
const PINNED_ISO = '2020-09-01T09:00:00.000Z';
|
||||
const executorResume = '.planning/phases/02/02-01-PLAN.md';
|
||||
const fixture = [
|
||||
'# Project State',
|
||||
'',
|
||||
'## Session Continuity',
|
||||
'',
|
||||
`**Last session:** ${PINNED_ISO}`,
|
||||
'**Stopped at:** Phase 2, Plan 1',
|
||||
`**Resume file:** ${executorResume}`,
|
||||
].join('\n') + '\n';
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, fixture);
|
||||
|
||||
const result = runGsdTools('state record-session --stopped-at "Phase 2, Plan 1"', tmpDir, {
|
||||
GSD_TEST_MODE: '1',
|
||||
GSD_NOW_MS: String(PINNED_MS),
|
||||
});
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!((output.updated || []).includes('Stopped At')),
|
||||
`updated must not report a Stopped At write that changed nothing; got ${JSON.stringify(output.updated)} (#3374 Variant B)`,
|
||||
);
|
||||
|
||||
// The pinned clock makes the Last-session replacement an identity too, so
|
||||
// the whole transform is a no-op and the #948 no-op guard must skip the
|
||||
// write entirely — the file must be byte-identical.
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.strictEqual(
|
||||
after,
|
||||
fixture,
|
||||
'no field changed, so no write may occur (#3374 Variant B)',
|
||||
);
|
||||
assert.strictEqual(
|
||||
stateDocument.stateExtractField(after, 'Resume file'),
|
||||
executorResume,
|
||||
'an identical --stopped-at must not arm the #944 DWIM section rewrite (executor-authored resume file reset to None)',
|
||||
);
|
||||
});
|
||||
|
||||
test('#3374: --stopped-at with a new value still reports Stopped At and syncs the frontmatter', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
const newValue = 'Phase 3 complete, ready to plan Phase 4';
|
||||
|
||||
const result = runGsdTools(['state', 'record-session', '--stopped-at', newValue], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
(output.updated || []).includes('Stopped At'),
|
||||
`a real change must keep reporting Stopped At; got ${JSON.stringify(output.updated)}`,
|
||||
);
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.strictEqual(
|
||||
stateDocument.stateExtractField(after, 'Stopped at'),
|
||||
newValue,
|
||||
'the body Stopped at line should carry the new value',
|
||||
);
|
||||
const fm = frontmatterLib.extractFrontmatter(after);
|
||||
assert.strictEqual(
|
||||
fm.stopped_at,
|
||||
newValue,
|
||||
`the RMW sync must reflect the new value in frontmatter; got ${JSON.stringify(fm.stopped_at)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user