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

@@ -9,7 +9,7 @@
import { splitTableRow } from './markdown-table.cjs';
import { clampPercentFromFraction } from './phase-lifecycle.cjs';
import { collectSection } from './markdown-sectionizer.cjs';
import { collectSection, withSection } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
import { escapeRegex } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
@@ -373,6 +373,34 @@ export function stateReplaceFieldWithFallback(content: string, primary: string,
return content;
}
/**
* #3374: session-scoped variant of stateReplaceFieldWithFallback for the
* `## Session` continuity fields. The post-sync harvest (state.cts's
* matchSessionSection → buildStateFrontmatter) reads these fields ONLY from
* the session section, so a writer that refreshes one must target the same
* scope — a whole-body replace lets a decoy `**Stopped at:**` line in an
* unrelated (e.g. archive) section absorb the refresh while the harvested
* session value stays stale.
*
* Section preference mirrors the reader exactly: the normalized `## Session`
* block wins over the bootstrap `## Session Continuity` heading when both
* exist (legacy duplicate files); the continuity heading is only consulted
* when no canonical `## Session` section exists. `levelBounded` heading
* matching also excludes `## Session Continuity Archive` (the #2444 scoping).
*
* Replace-only (no insertion): returns `content` unchanged when no session
* section exists or the field is absent from it, so a STATE.md layout without
* the line keeps its shape and the post-sync preservation pass decides the
* frontmatter value (see #3374).
*/
export function stateReplaceFieldInSession(content: string, primary: string, fallback: string | null | undefined, value: string): string {
const isSession = (h: HeadingToken): boolean => h.level === 2 && h.text.trim().toLowerCase() === 'session';
const isSessionContinuity = (h: HeadingToken): boolean => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
const hasCanonicalSession = collectSection(content, isSession, { levelBounded: true }) !== null;
const target = hasCanonicalSession ? isSession : isSessionContinuity;
return withSection(content, target, (sectionBody) => stateReplaceFieldWithFallback(sectionBody, primary, fallback, value));
}
export function normalizeStateStatus(status: string | null | undefined, pausedAt: unknown): string {
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();