fix(#3374): phase.complete stops harvesting stale body stopped_at (#3491)

* 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:
Tom Boucher
2026-08-14 12:38:23 -04:00
committed by GitHub
parent 27cad971d0
commit be9329b10b
7 changed files with 551 additions and 134 deletions

View File

@@ -16,7 +16,7 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback } from './state-document.cjs';
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback, stateReplaceFieldInSession } from './state-document.cjs';
import { KNOWN_TEMPLATE_DEFAULTS } from './state-document.cjs';
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
@@ -1057,6 +1057,7 @@ function completePhaseCore(
'current_plan',
'last_activity',
'last_activity_desc',
'stopped_at',
'progress',
]) {
const cls = getFieldClassification(fmKey);
@@ -1157,6 +1158,29 @@ function completePhaseCore(
updated.push('Last Activity Description');
}
// Stopped At — #3374: write the continuity line this transition implies.
// The frontmatter `stopped_at` is a projection of this body line
// (source: 'body' in FIELD_CLASSIFICATION), and phase completion is exactly
// the event the line describes — leaving it stale made the post-sync harvest
// overwrite a fresher frontmatter value with pre-completion prose on every
// completion (#3374), and left the workflow's later prose refresh as a
// divergence source. Session-SCOPED replace (stateReplaceFieldInSession):
// the harvest reads only the session section, so the write must target the
// same scope — a whole-body replace let a decoy `**Stopped at:**` line in an
// unrelated section absorb the refresh. Replace-only (no insertion): a
// STATE.md with no session continuity line keeps its shape, and the
// unchanged body source then lets the preservation delta keep an existing
// frontmatter value. Last-phase wording reuses the ADR-2207 status phrase;
// milestone termination wording stays owned by milestoneCompleteCore.
const stoppedAtLine = intent.isLastPhase
? `Phase ${intent.phaseNum} complete — all phases complete`
: `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, ready to plan Phase ${intent.nextPhaseNum}` : ''}`;
const stoppedAfter = stateReplaceFieldInSession(body, 'Stopped At', 'Stopped at', stoppedAtLine);
if (stoppedAfter !== body) {
body = stoppedAfter;
updated.push('Stopped At');
}
// Progress block — re-derive completed/total phases from the roadmap when
// available (milestone-wide source of truth), then recompute the percent.
// Only runs when a Completed Phases field exists (the existing guard).