* 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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user