refactor(#3471): one enforcement point for the empty case, and reports that match the disk (#3519)

* refactor(#3471): one enforcement point for the empty case, and reports that match the disk

Implements ADR-3408 section 8.5 and section 8.4's residue (folded in when Phase
3 closed as subsumed). Four items, and two findings the design did not predict.

FINDING 1 — the guards could not simply be deleted, as the design instructed.
state sync and REGENERATE_STATE never run applyStatePreservation at all, so
those six conditions were their ONLY empty-field fallback. A baseline probe on
the unedited tree confirmed unconditional deletion drops current_phase,
current_phase_name, current_plan, stopped_at and paused_at from a blank-body
STATE.md on state sync — breaking the byte-identical requirement section 8.3
grants those two sanctioned-permanent exceptions. They are now GATED, not
deleted: on for the exceptions, off for the write seam, where an empty derived
value finally reaches the executor unmolested.

FINDING 2, the more serious one — there was a FOURTH encoding of this policy.
The pre-existing #2202 unknown-key carry-forward loop independently restored
the same six fields whenever derivedFm lacked the key, completely neutralizing
the fix. It is named nowhere in the ADR, the design, or three prior phases. It
was found only because a probe that should have passed did not: the first
attempt reported divergedFields: [] and silently restored both fields,
reproducing the exact bug this phase exists to close.

That is worth stating plainly. This epic's thesis is 'policy declared in one
table, enforcement hand-rolled per call site.' The final phase found one more
call site than anyone had counted — which is the fourth consecutive time a copy
count in this epic proved to be a lower bound.

Also: divergedFields could only observe fields the executor actively RESTORED,
by diffing postFm. A discard-to-empty is absent both before and after, so it
was invisible. A second pass now reports it, which is what makes section 8.5's
'preservation is visible' true for the delete-the-body-line case rather than
aspirational.

cmdPhaseComplete now reports what it preserved — #3374 was filed against that
command and its complaint was warnings: [], silence.

cmdStateJson's private third copy of the guards is routed onto the executor's
preserve-when-unchanged rule. A read is definitionally not a write, so the
#1230 delta is 'unchanged' and curated wins over a stale annotation.
shouldPreserveExistingProgress is a different rule and is untouched.

Report reconciliation is ONE shared helper across seven commands, not five
copies of fix(#3351)'s block. Five copies of a reconciliation is precisely the
shape this epic removes, and introducing it in the final phase would have been
a poor joke. Both untraced commands were traced rather than assumed:
cmdStatePlannedPhase matched cmdStateBeginPhase exactly; cmdStateCompletePhase
turned out to be a different legacy hand-rolled path reporting a mix of field
names AND a section name, where the naive helper would have dropped 'Current
Position' as a false negative every time.

* test(#3471): characterization coverage for one enforcement point and reconciled reports

Matrix sections A-E, asserted at the consumer's output per ADR-3180 Decision
4(b)/(c) — this phase owes Decision 5's outcome metric, the one the drift
guard's zero may never be reported without.

Three walls matter more than the new coverage:

  A2 is SIX separately named tests, one per gated guard, not one parameterised
  assertion over a list. A list is trivially shortened later; six named tests
  are not, and six guards is exactly where a field gets silently dropped.

  A6 pins what Phases 1-3 already fixed — non-empty stale body, delta
  unchanged, losing to fresher curated frontmatter, with the divergence
  reported. If A6 reddens, this phase broke the thing the epic was for.

  D1/D2 pin state sync byte-identical. The implementation had to GATE the six
  guards rather than delete them precisely because state sync has no executor,
  and a baseline probe showed unconditional deletion drops five fields.
  Nothing else in the suite would notice that regression.

E6 covers #3345's direction — a field preservation restored that the intent
never named IS reported. Nothing has ever tested that direction.

Assertions were empirically verified against the compiled lib and the real CLI
before being written, since the suite cannot be executed locally. That caught
two type bugs in the draft: fm.current_phase after a quoted-YAML round-trip is
the string '5', not the number 5.

E5 is recorded as structurally unreachable rather than weakened or faked. Those
four commands report body Title-Case labels, which cannot string-collide with a
frontmatter snake_case key the way cmdStatePatch's arbitrary field names can —
which is why fix(#3351) targeted only cmdStatePatch. Testing it directly would
need reconcileReportedFields exported from private scope; the helper is
exercised through E6 and all seven commands instead.

* docs(#3471): amend ADR-3408 section 8.5 — a fourth enforcement point, and guards that could not be deleted

Amendment 3. The contract held; two of section 8.5's own statements did not.

It said the six empty-only guards are DELETED. They cannot be. writeStateMd is
the sole path for both section 8.3 sanctioned-permanent exceptions and never
runs applyStatePreservation, so those guards were their only empty-field
fallback. A baseline probe on the unedited tree confirmed unconditional
deletion drops five fields from a blank-body STATE.md on state sync, breaking
the byte-identical guarantee section 8.3 grants it. They are gated instead.

It also mis-located cmdStateJson's guards, describing them as living in
syncStateFrontmatter. They were a separate private copy on the read path with
no delta check at all, so a stale body annotation always beat fresher curated
frontmatter in state.json — #3395's shape entirely outside the write seam.

THE FINDING: a fourth enforcement point nobody had counted. The pre-existing
#2202 unknown-key carry-forward loop independently restored the same six
fields, silently neutralizing the fix. It is named nowhere in this ADR, in the
phase design, or in three prior phases, and was found only because a probe that
should have passed did not.

Fourth consecutive time a copy count in this epic proved a lower bound: 2
write-seam bypasses became 4, three preservation encodings became four, and the
estimate was wrong every time. ADR-3180's standing rule has earned itself in
every phase — read the code, not the write-up.

Records the Row 2 decision (a discard-to-empty wins per the delta rule and is
reported, not silent — the sharpest Hyrum exposure in the epic), section 8.4's
residue landing as ONE shared reconcileReportedFields across seven commands
rather than five copies, and the parity assertion added because
FRONTMATTER_KEY_TO_BODY_LABEL was itself a second table that failed silently —
this epic's shape in miniature, in its final phase.

* fix(#3471): repair four regressions the checkpoint caught

Checkpoint returned 16 failures of 34389: six real regressions in pre-existing
tests, plus seven of my own test bugs.

My hypothesis was wrong and is recorded as such. I predicted the #2202
carry-forward skip was the cause, reasoning it had removed a load-bearing
fallback the way the six guards nearly were. It was not implicated in any of
the six. Three unrelated causes:

#2111 — current_phase came back undefined from milestone complete, which is
the epic's own defect class reintroduced by its final phase. Root cause is
Row 2 working exactly as designed: milestoneCompleteCore rewrites the body
Phase: line to a closure message, so current_phase's #1230 delta reads
CHANGED and the new rule correctly discards the curated value. The transition
never declared any intent to touch that field. Fixed by re-asserting
current_phase and current_phase_name through authoritativeFm — the existing
#2736 mechanism beginPhaseCore and completePhaseCore already use — rather
than by weakening Row 2, which A5 pins.

That interaction is worth naming: a rule that keys on 'did this write change
the body source' will fire on a transition that moves the body line for an
entirely unrelated reason. The design did not anticipate it.

#1264 / #3242 / the state.patch progress report — reconcileReportedFields
folded EVERY divergedFields entry into updated, including preserve-always
progress restores no caller asked about. Now scoped to preserve-when-unchanged
rows only.

#1162 / case-insensitive table fields — valueOf checked frontmatter before
body, so a lowercase table field name exact-matched the lowercase frontmatter
key sync always derives, comparing stale pre-sync body text against a
post-sync frontmatter enum. Flipped to body-first.

That last one is the SAME lesson as Phase 2's patchCore, recurring in a
different function two phases later: in this model the body is authoritative
and frontmatter is the projection, so a name that could mean either resolves
body-first. Twice now.

Test bugs: a stray unused parameter shifted every argument at six call sites,
so body arrived undefined; and A4 compared nested progress scalars against
numbers when extractFrontmatter returns raw YAML strings. The string-vs-number
YAML round-trip has now been caught three times in this phase alone.

* test(#3471): one helper for the progress coercion that bit four times

A2f failed on the string-vs-number YAML round-trip: extractFrontmatter returns
nested progress scalars as raw YAML strings, so a comparison against numeric
literals can never pass.

This is the FOURTH time this exact class has been caught in this phase — twice
during test authoring, once as A4 in the previous checkpoint, now as A2f.
Patching it a fourth time by hand would guarantee a fifth.

Added numericProgress() with a comment saying why it exists, and routed every
progress-reading assertion in the #3471 block through it. Swept the block:
C3 needed no change, because cmdStateJson's output already runs through
normalizeProgressNumbers.

Deliberately NOT shared with frontmatter.test.cjs's readPersistedProgress:
that one is path-based and re-reads from disk, while these assert on an
in-memory string that is never written. Sharing would have meant either a
disk round-trip these tests do not do, or duplicating half the helper — so
the coercion pattern is mirrored locally and the reason recorded, rather
than manufacturing a dependency to satisfy the letter of consolidation.

* chore(#3471): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 21:06:01 -04:00
committed by GitHub
parent fba7c90327
commit 411196bc3a
10 changed files with 1397 additions and 113 deletions

View File

@@ -902,13 +902,41 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
{ clock: realClock, sourcePath: statePath },
);
const divergedFields: string[] = [];
// #2111 (found by #3471 review): `milestoneCompleteCore` never declares
// `current_phase`/`current_phase_name` among the fields it touches — but
// its ## Current Position reset REWRITES the `Phase:` prose line to a
// closure message ("Milestone vX.Y complete"), which is not a number.
// That is an unavoidable side effect of the wholesale section reset
// `resetSectionVerbatim` performs, not an intent to change the phase.
// Downstream, `current_phase`/`current_phase_name` are
// `preserve-when-unchanged` rows: the #1230 delta heuristic sees the
// body source go from a real value to unparseable and — correctly, per
// ADR-3408 §8.5 Row 2 — lets the derived (empty) value win, discarding
// the curated phase entirely. §8.5 Row 2 governs a genuine mid-write
// body edit (e.g. `state.patch` deleting the Phase line); milestone
// closure is a different shape — the transition never intended to
// touch these fields at all. Re-assert them via `authoritativeFm` (the
// same #2736 intent-first mechanism `beginPhaseCore`/`completePhaseCore`
// already use to freeze a field the transition resolved out-of-band),
// so the closure-message side effect cannot clobber the last real
// phase. Scoped to non-empty strings only, mirroring #2736's own guard.
const authoritativeFm: Record<string, unknown> = {};
const preFm = extractFrontmatter(originalStateContent, statePath) as Record<string, unknown>;
const preCurrentPhase = preFm['current_phase'];
const preCurrentPhaseName = preFm['current_phase_name'];
if (typeof preCurrentPhase === 'string' && preCurrentPhase.trim().length > 0) {
authoritativeFm['current_phase'] = preCurrentPhase;
}
if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
authoritativeFm['current_phase_name'] = preCurrentPhaseName;
}
const finalContent = syncAndPreserveStateMd(
originalStateContent,
result.content,
statePath,
cwd,
true,
undefined,
Object.keys(authoritativeFm).length > 0 ? authoritativeFm : undefined,
undefined,
divergedFields,
);
@@ -916,6 +944,14 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
for (const field of divergedFields) {
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
}
// The authoritativeFm re-assert above (unlike a delta-based restore) is
// invisible to `divergedFields` — #2736's re-assert runs after that
// diff — so surface it explicitly here for "liberal but visible".
for (const field of Object.keys(authoritativeFm)) {
if (!divergedFields.includes(field)) {
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
}
}
});
}

View File

@@ -2097,6 +2097,18 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
let requirementsUpdated = false;
const warnings: string[] = [];
// ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
// composition's preservation stage restores a curated frontmatter value
// over a disagreeing derived one, that divergence is surfaced here rather
// than silently absorbed. Structured (field + reason), not prose, so a
// caller can assert on the value rather than regex a rendered message.
// Named `preservation_warnings`, NOT `warnings`: `warnings` above is
// already a prose `string[]` on this exact command — reusing it for a
// structured `{field, reason}[]` shape would be the "Generative Fix
// Divergence" anti-pattern (two sibling fields, same name, different
// element types). Mirrors `cmdMilestoneComplete`'s identical field
// (milestone.cts).
const preservationWarnings: Array<{ field: string; reason: string }> = [];
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
// below (inside withPlanningLock) alongside the warnings[] entry, so a
@@ -3037,6 +3049,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// apply). Fields the transition legitimately rewrote (Status, Phase,
// Stopped At via completePhaseCore's #3374 continuity line) have
// changed body sources, so their deltas do not fire.
// ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
// command reports what it preserved, following `cmdMilestoneComplete`'s
// shape (milestone.cts) — the same composition, the same out-param,
// the same visibility contract.
const divergedFields: string[] = [];
stateContent = syncAndPreserveStateMd(
originalStateContent,
stateContent,
@@ -3044,7 +3061,12 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
cwd,
true,
authoritativeFm,
undefined,
divergedFields,
);
for (const field of divergedFields) {
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
}
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
}
@@ -3121,6 +3143,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
has_warnings: warnings.length > 0,
verification_stale_check_indeterminate: staleCheckIndeterminate,
milestone_conflict: milestoneConflict,
preservation_warnings: preservationWarnings,
};
output(result, raw);

View File

@@ -209,7 +209,7 @@ export type StatePreservationResult = {
* executor below. `mutated` accumulates across the whole field loop (ADR-3408
* §8.1 — one executor per policy, sharing one result).
*/
type PreservationCtx = {
export type PreservationCtx = {
preFm: Record<string, unknown> | null;
postFm: Record<string, unknown>;
preFmSnapshot: Record<string, unknown>;
@@ -254,8 +254,17 @@ function throwUnwiredRow(field: string): never {
* current_phase, current_plan, paused_at, last_activity_desc — is honored by
* this ONE executor; `cls.guard` is the only field-specific variation (the
* closed vocabulary of ADR-3408 Decision 1).
*
* Exported (ADR-3408 §8.5 / D3) so `cmdStateJson` (state.cts) — a read-only
* path with no transform of its own — can route its stale-vs-fresh decision
* through the SAME executor the write path uses, rather than maintaining a
* third private copy of this policy. `cmdStateJson` calls this directly
* (not the full `applyStatePreservation` dispatch loop) so its read stays
* scoped to exactly the fields it has always governed and never touches
* `progress` or `milestone*`, which are different policies with their own
* read-path rules (`shouldPreserveExistingProgress`, `preserve-if-placeholder`).
*/
function applyPreserveWhenUnchanged(field: string, cls: FieldClassification, ctx: PreservationCtx): void {
export function applyPreserveWhenUnchanged(field: string, cls: FieldClassification, ctx: PreservationCtx): void {
// 1. A declared row with no wired delta is an internal invariant violation
// — throw (ADR-3408 §8.2). Never reached for a user-document defect: the
// production caller (readModifyWriteStateMd) wires every preserve-when-

View File

@@ -102,6 +102,16 @@ interface ReadModifyWriteOptions {
* destroy information the transition just resolved.
*/
authoritativeFm?: Record<string, unknown>;
/**
* ADR-3408 §8.5 (D4): out-param, forwarded straight through to
* `syncAndPreserveStateMd` — every frontmatter field name whose value
* preservation restored over a disagreeing freshly-derived one during THIS
* write. Callers that supply an array here can fold it into their own
* report (see `reconcileReportedFields`) so a preserved field the caller's
* transform never named is still visible (#3345's direction). Omit for
* callers that do not report per-field arrays; costs nothing extra.
*/
divergedFields?: string[];
}
interface StateRecordMetricOptions {
@@ -506,40 +516,34 @@ function cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolea
// #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
// delta (table-driven) that this phase adds. Field-name validation (security)
// and the resync-progress decision stay in this adapter.
let results: { updated: string[]; failed: string[] } = { updated: [], failed: [] };
let precomputed: { updated: string[]; failed: string[] } = { updated: [], failed: [] };
let preSyncContent = '';
const divergedFields: string[] = [];
readModifyWriteStateMd(statePath, (content) => {
const result = transitionCore(content, { kind: 'patch', patches }, { clock: realClock });
results = (result.data as { updated: string[]; failed: string[] }) ?? results;
precomputed = (result.data as { updated: string[]; failed: string[] }) ?? precomputed;
preSyncContent = result.content;
return result.content;
}, cwd, { resync: shouldResync });
}, cwd, { resync: shouldResync, divergedFields });
// #3351: reconcile the report against the bytes actually persisted.
// ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
// patchCore's bookkeeping says whether the stateReplaceField text-replace
// MATCHED — but its plain-line pattern (`m` flag over the full document)
// can match the YAML frontmatter line for a lower-cased key, and the write
// pipeline (syncStateFrontmatter re-derivation + the FIELD_CLASSIFICATION
// preservation rows) then discards or restores that text before the file is
// saved. A field is only reported `updated` when its post-write on-disk
// value equals the requested value: the frontmatter key when present,
// else the body field (the legitimate working case for state.patch is
// display-cased BODY fields — Status, Current Plan, Phase — which are
// never frontmatter keys).
const persisted = platformReadSync(statePath) || '';
const postFm = extractFrontmatter(persisted, statePath) as Record<string, unknown>;
const postBody = stripFrontmatter(persisted);
const updated: string[] = [];
const failed: string[] = [];
for (const [field, value] of Object.entries(patches)) {
const persistedValue = Object.prototype.hasOwnProperty.call(postFm, field)
? String(postFm[field])
: stateExtractField(postBody, field);
if (persistedValue !== null && persistedValue.trim() === String(value).trim()) {
updated.push(field);
} else {
failed.push(field);
}
}
results = { updated, failed };
// value equals what THIS transform actually wrote (the frontmatter key
// when present, else the body field — the legitimate working case for
// state.patch is display-cased BODY fields — Status, Current Plan, Phase —
// which are never frontmatter keys). Also folds in any field
// `applyStatePreservation` restored that this patch never named at all
// (#3345's direction), a case the pre-#3471 version of this command never
// covered.
const updated = reconcileReportedFields(statePath, preSyncContent, precomputed.updated, divergedFields);
const updatedSet = new Set(updated);
const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
const results = { updated, failed };
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
} catch {
@@ -563,6 +567,8 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
const statePath = planningPaths(cwd).state;
try {
let updated = false;
let preSyncContent = '';
const divergedFields: string[] = [];
const shouldResync = shouldResyncStateProgress([field as string]);
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
// body-strip/reassemble single-field update is the pure `updateCore` in
@@ -577,12 +583,25 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
{ clock: realClock },
);
updated = (result.data as { updated: boolean } | undefined)?.updated === true;
preSyncContent = result.content;
return result.content;
}, cwd, { resync: shouldResync });
}, cwd, { resync: shouldResync, divergedFields });
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
// `updateCore`'s own match does not know whether sync/preservation later
// discarded the value it wrote (#3351's direction, generalized from
// `cmdStatePatch`). `preserved` folds in any OTHER field preservation
// restored during this write that this command never touched at all
// (#3345's direction) — reported separately from `updated` because this
// command's contract is a single-field boolean, not a per-field array.
const reconciled = reconcileReportedFields(statePath, preSyncContent, updated ? [field as string] : [], divergedFields);
updated = reconciled.includes(field as string);
const preserved = reconciled.filter((f) => f !== field);
if (updated) {
output({ updated: true }, false, undefined);
output({ updated: true, preserved }, false, undefined);
} else {
output({ updated: false, reason: `Field "${field as string}" not found in STATE.md` }, false, undefined);
output({ updated: false, reason: `Field "${field as string}" not found in STATE.md`, preserved }, false, undefined);
}
} catch {
output({ updated: false, reason: 'STATE.md not found' }, false, undefined);
@@ -629,6 +648,9 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
};
let resultData: Record<string, unknown> | undefined;
let precomputedUpdated: string[] = [];
let preSyncContent = '';
const divergedFields: string[] = [];
// #3311: the milestone (phase + session) claim is consulted INSIDE the
// STATE.md lock, so the position read and the claim read cannot interleave
// with another session's Current Position write.
@@ -650,18 +672,28 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
}
const result = transitionCore(content, intent, deps);
resultData = result.data;
precomputedUpdated = result.updated;
preSyncContent = result.content;
return result.content;
}, cwd);
}, cwd, { divergedFields });
if (!resultData || resultData['error']) {
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
return;
}
// ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
// the bytes actually persisted — this command previously reported none of
// its per-field writes at all (`updated` never left `advancePlanCore`).
// Generalizes fix(#3351) (closes #3351's direction) and folds in any field
// preservation restored that this transform never touched (#3345's
// direction).
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
if (resultData['advanced'] === false) {
output({ ...resultData, milestone_conflict: milestoneConflict }, raw, 'false');
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
} else {
output({ ...resultData, milestone_conflict: milestoneConflict }, raw, 'true');
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
}
}
@@ -1245,6 +1277,8 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
const now = realClock.nowIso();
const updated: string[] = [];
let sessionCreated = false;
let preSyncContent = '';
const divergedFields: string[] = [];
readModifyWriteStateMd(statePath, (content) => {
// Update Last session / Last Date
@@ -1450,11 +1484,18 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
}
}
preSyncContent = content;
return content;
}, cwd);
}, cwd, { divergedFields });
if (updated.length > 0) {
const result: Record<string, unknown> = { recorded: true, updated };
// ADR-3408 §8.4 (D4): reconcile this command's own success list against the
// bytes actually persisted (fix(#3351) generalized) and fold in any field
// preservation restored that this transform never touched (#3345's
// direction).
const reconciledUpdated = reconcileReportedFields(statePath, preSyncContent, updated, divergedFields);
if (reconciledUpdated.length > 0) {
const result: Record<string, unknown> = { recorded: true, updated: reconciledUpdated };
if (sessionCreated) result['created'] = true;
output(result, raw, 'true');
} else {
@@ -2357,7 +2398,12 @@ function readStoredTotalPhases(existingFm: Record<string, unknown> | null | unde
return Number.isFinite(n) ? n : null;
}
function syncStateFrontmatter(content: string, cwd: string | undefined, authoritativeFm?: Record<string, unknown>): string {
function syncStateFrontmatter(
content: string,
cwd: string | undefined,
authoritativeFm?: Record<string, unknown>,
sanctionedPermanentEmptyFallback?: boolean,
): string {
// Read existing frontmatter BEFORE stripping — it may contain values
// that the body no longer has (e.g., Status field removed by an agent).
// `cwd` already identifies the workspace this content came from, so the STATE.md path is
@@ -2415,56 +2461,75 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit
}
}
// Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
// from body annotations (Current Phase:, Current Plan:, etc.). When those
// annotations are absent — e.g. after an agent or tool rewrites the body —
// buildStateFrontmatter returns no value for those keys. Mirror the same
// fallback pattern used in cmdStateJson so the existing frontmatter values
// survive every writeStateMd call.
// ADR-3408 §8.5 (D1): the six empty-only "#905" guards that used to live
// here UNCONDITIONALLY are deleted for the write-seam pipeline
// (`syncAndPreserveStateMd`, consumed by `readModifyWriteStateMd` and by
// `cmdPhaseComplete`'s atomic-commit adapter). An empty derived value now
// reaches `applyStatePreservation` unmolested, so the table-driven executor
// — not a private copy inside this function — decides whether a curated
// frontmatter value survives, and reports the decision via
// `divergedFields` when it does. That was the actual D1 bug: these guards
// ran BEFORE the executor ever saw the value, so a transform that
// deliberately emptied a body line (delta CHANGED) lost silently — the
// guard restored the stale frontmatter, the executor's own #1230 delta
// check then found "already restored, nothing to do", and
// `divergedFields` stayed empty even though a curated value had just won
// over a genuine derived-empty.
//
// `writeStateMd`'s two callers — `cmdStateSync` and `/gsd-health --repair`'s
// `REGENERATE_STATE` — are §8.3's closed, sanctioned-permanent exception
// list: NEITHER ever runs `applyStatePreservation` afterward, because their
// whole contract is "re-derive frontmatter FROM the body, body wins" (the
// opposite of preservation). For them, these six conditions are the ONLY
// mechanism that has ever kept a curated frontmatter value alive when the
// body simply carries no annotation for a field at all (most STATE.md
// files do not restate every field in body prose on every write) — losing
// that would blank `current_phase_name` / `stopped_at` / etc. on every
// `state sync`, which is a regression, not this phase's fix: `state sync`'s
// output must stay byte-identical (ADR-3408 §8.3 Amendment 2). So the same
// six conditions are kept, verbatim, but now gated behind the explicit
// `sanctionedPermanentEmptyFallback` parameter — threaded ONLY from
// `writeStateMd` — instead of running unconditionally or being duplicated
// as a second private copy. This is still ONE enforcement point: the six
// conditions exist in exactly one place in the source, selected by caller
// identity per the closed §8.3 exception list, never re-derived elsewhere.
//
// For stopped_at / paused_at: the original #905 "fall back when derived is
// absent" rule is preserved here — this block handles the EMPTY case only.
// The disagreeing case (a present-but-stale body value vs a fresher
// frontmatter value, #948/#3374) is NOT handled here: it is governed by
// applyStatePreservation's preserve-when-unchanged delta, applied post-sync
// by the shared applyPostSyncPreservation pass — run by
// readModifyWriteStateMd and by cmdPhaseComplete's adapter (the one caller
// that deliberately bypasses the RMW wrapper for the atomic
// ROADMAP/REQUIREMENTS/STATE commit; #3374). The writeStateMd path
// (state sync) intentionally derives from the body instead — its #905
// contract is body-beats-frontmatter. "Always prefer frontmatter" here
// would still be wrong: it would break transforms that legitimately write a
// new body value and expect this sync to project it — the #1230 delta
// ("did THIS write change the body source?") is what distinguishes those
// from a stale harvest.
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
derivedFm['stopped_at'] = existingFm['stopped_at'];
}
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
derivedFm['paused_at'] = existingFm['paused_at'];
}
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
derivedFm['current_phase'] = existingFm['current_phase'];
}
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
}
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
derivedFm['current_plan'] = existingFm['current_plan'];
}
// progress is a sub-object: fall back to existing only when the body+disk
// scan produced NO progress block at all. When buildStateFrontmatter did
// derive a progress block (even a lower one), that derived value wins — the
// shouldPreserveExistingProgress cross-milestone logic is applied later in
// cmdStateJson on the read path where it is appropriate.
if (!derivedFm['progress'] && existingFm['progress']) {
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
// frontmatter value, #948/#3374/§8.5) was never handled here even before
// this change: it is governed by applyStatePreservation's
// preserve-when-unchanged delta, applied post-sync by the shared
// applyPostSyncPreservation pass.
if (sanctionedPermanentEmptyFallback) {
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
derivedFm['stopped_at'] = existingFm['stopped_at'];
}
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
derivedFm['paused_at'] = existingFm['paused_at'];
}
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
derivedFm['current_phase'] = existingFm['current_phase'];
}
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
}
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
derivedFm['current_plan'] = existingFm['current_plan'];
}
// progress is a sub-object: fall back to existing only when the
// body+disk scan produced NO progress block at all. When
// buildStateFrontmatter did derive a progress block (even a lower one),
// that derived value wins — the shouldPreserveExistingProgress
// cross-milestone logic is applied later in cmdStateJson on the read
// path where it is appropriate.
if (!derivedFm['progress'] && existingFm['progress']) {
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
}
}
// #2202: carry forward any existing frontmatter key that the schema does not
// own, so custom/unknown keys are not silently dropped on every mutating verb.
// Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
// preserve guards above) still win.
// sanctioned-permanent guards above, when they ran) still win.
for (const key of Object.keys(existingFm)) {
if (key in derivedFm || existingFm[key] === undefined) continue;
@@ -2487,6 +2552,39 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit
const classification = stateTransitionMod.getFieldClassification(key);
if (classification && classification.source === 'free') continue;
// ADR-3408 §8.1/§8.5 (D1 follow-on — found by probe, not predicted by the
// design): a `preserve-when-unchanged` / `preserve-always` field must be
// decided ONLY by `applyStatePreservation` — the single enforcement point
// — never by this generic carry-forward, on the write-seam path. Before
// the six sanctioned-permanent guards above were gated behind
// `sanctionedPermanentEmptyFallback` (D1), this loop's `key in derivedFm`
// check was effectively always true for a field the guards had already
// restored, so this branch was unreachable for it and the distinction
// never mattered. With the guards now OFF on the write-seam path,
// `derivedFm` genuinely lacks the key when the body carries no
// annotation — and without this skip, this loop silently resurrects the
// exact stale value the executor's delta rule (§8.5 Row 2) just decided
// to discard, re-introducing the D1 bug through a second, unrelated code
// path (confirmed live: an A5-shaped probe restored `current_phase_name`
// via THIS loop even with the six guards deleted).
//
// Gated to the write-seam path ONLY (`!sanctionedPermanentEmptyFallback`)
// — `writeStateMd`'s two sanctioned-permanent callers never run
// `applyStatePreservation` at all, so unconditionally skipping here would
// blank fields this loop has always carried forward for them (e.g.
// `last_activity_desc`, which was never one of the six explicit guards
// above but relied on THIS loop for its empty-case fallback), breaking
// `state sync`'s required byte-identical output for a field D1 never
// named. On the write-seam path this executor-only rule genuinely widens
// beyond the original six fields (e.g. also covers `last_activity_desc`)
// — a deliberate, in-scope consequence of "one enforcement point", not a
// separate defect.
if (
!sanctionedPermanentEmptyFallback &&
classification &&
(classification.preservation === 'preserve-when-unchanged' || classification.preservation === 'preserve-always')
) continue;
derivedFm[key] = existingFm[key];
}
@@ -2763,7 +2861,12 @@ function writeStateMd(statePath: string, content: string, cwd?: string, clock?:
// Invalidate the disk scan cache first — the write may create new PLAN/SUMMARY
// files that buildStateFrontmatter must see (#1967).
if (cwd) _diskScanCache.delete(cwd);
const synced = syncStateFrontmatter(content, cwd);
// ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
// sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
// pass `sanctionedPermanentEmptyFallback: true` so their long-standing
// empty-field fallback behavior stays byte-identical (see
// `syncStateFrontmatter`'s docstring above the guard block).
const synced = syncStateFrontmatter(content, cwd, undefined, true);
platformWriteSync(statePath, synced);
} finally {
releaseStateLock(lockPath);
@@ -2943,6 +3046,30 @@ function applyPostSyncPreservation(
: before !== after;
if (changed) divergedFields.push(key);
}
// ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
// deleted rather than merely relocated): the loop above can only see a
// field that `applyStatePreservation` itself RESTORED — it diffs
// `postFm` before vs after the executor ran, and `preserve-when-unchanged`
// never adds an absent key back when the body source changed this write
// (the delta rule correctly lets the empty derived value win, so `postFm`
// never gains the key at all). That means a curated value can vanish —
// deliberately, per policy — with NOTHING in the loop above to report it.
// "Liberal but visible" requires the discard itself to be named, not just
// a restore. Scoped to exactly the fields `bodyDeltas` tracks
// (preserve-when-unchanged rows only — `preserve-always`/`progress` and
// `preserve-if-placeholder`/`milestone*` are unaffected by the delta rule
// and already fully covered by the restore-diff loop above).
for (const [field, delta] of Object.entries(bodyDeltas)) {
if (divergedFields.includes(field)) continue; // already reported as a restore above
const before = preFmSnapshot[field];
const beforeIsReal = typeof before === 'string' && before.trim().length > 0;
if (!beforeIsReal) continue; // nothing curated existed to discard
if (delta.pre === delta.post) continue; // body source unchanged — governed by the restore branch, not the discard rule
const after = preservation.postFm[field];
const afterIsEmpty = after === undefined || after === null
|| (typeof after === 'string' && after.trim().length === 0);
if (afterIsEmpty) divergedFields.push(field);
}
}
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
// layouts with no body `Phase:` line, both phase-source snapshots are null
@@ -3069,6 +3196,7 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
resync,
options?.authoritativeFm,
options?.deriveProgressKeys === true,
options?.divergedFields,
);
platformWriteSync(statePath, synced);
@@ -3078,6 +3206,170 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
}
}
/**
* ADR-3408 §8.4/§8.5 (D4): frontmatter field name → the body Title-Case
* label the `updated` arrays below use. Every `preserve-when-unchanged` row
* in `FIELD_CLASSIFICATION` MUST have an entry here (pinned by a parity test,
* #3471 review) — `reconcileReportedFields` consults this so a preservation
* event on `current_phase_name` folds into a report that otherwise only ever
* speaks in body labels like `Current Phase Name` (#3345's direction). A
* `preserve-when-unchanged` field missing here is a table drift bug and
* `bodyLabelFor` throws rather than silently degrading to the raw
* snake_case key (#3471 review — this is a second hand-maintained table
* parallel to `FIELD_CLASSIFICATION`, so an unwired row must fail as loudly
* as `throwUnwiredRow` in `state-transition.cts` does for the same shape of
* omission). `preserve-always`/`preserve-if-placeholder` fields (`progress`,
* `milestone`, `milestone_name`) are deliberately absent — `divergedFields`
* (ADR-3408 §8.5's out-param) is NOT scoped to `preserve-when-unchanged`
* rows alone (see `applyPostSyncPreservation`'s "regardless of which policy
* executor fired" diff), so those fields legitimately reach the lookup with
* no body-line label to report — `progress` is a structured sub-object and
* `milestone`/`milestone_name` version/name pairs, neither ever rendered as
* a body prose line — and `bodyLabelFor` falls through to the raw key for
* exactly that closed, tested set (`tests/state.test.cjs` A2f pins
* `divergedFields` reporting bare `'progress'`).
*/
const FRONTMATTER_KEY_TO_BODY_LABEL: Readonly<Record<string, string>> = Object.freeze({
current_phase: 'Current Phase',
current_phase_name: 'Current Phase Name',
current_plan: 'Current Plan',
stopped_at: 'Stopped At',
paused_at: 'Paused At',
status: 'Status',
last_activity_desc: 'Last Activity Description',
});
/**
* ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
* entry. Throws for a `preserve-when-unchanged` field with no
* `FRONTMATTER_KEY_TO_BODY_LABEL` row — that combination can only happen if
* a future row is added to `FIELD_CLASSIFICATION` without a matching label,
* an internal table-drift bug, never a user-document defect (mirrors
* `throwUnwiredRow`'s shape in `state-transition.cts`: an `Error` carrying
* `code` and `field` own-properties). Falls through to the raw field name
* for every other policy (`preserve-always`, `preserve-if-placeholder`) —
* those fields were never claimed to have a body-line label and reaching
* this lookup with one of them is the documented, tested, working case
* (e.g. `progress`), not a silent degrade.
*/
function bodyLabelFor(field: string): string {
const label = FRONTMATTER_KEY_TO_BODY_LABEL[field];
if (label !== undefined) return label;
const cls = stateTransitionMod.getFieldClassification(field);
if (cls && cls.preservation === 'preserve-when-unchanged') {
const err = new Error(
`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
'FRONTMATTER_KEY_TO_BODY_LABEL entry. This is an internal invariant violation (ADR-3408 ' +
'§8.4/D4) — add a label for this field to FRONTMATTER_KEY_TO_BODY_LABEL.',
) as Error & { code: string; field: string };
err.code = 'STATE_BODY_LABEL_UNWIRED_ROW';
err.field = field;
throw err;
}
return field;
}
/**
* ADR-3408 §8.4 (D4): shared persisted-bytes reconciliation, generalized
* from fix(#3351)'s `cmdStatePatch`-only version so every RMW-based command
* that reports a per-field `updated` array shares ONE comparison instead of
* re-deriving it per call site — duplicated policy is exactly what this
* epic exists to remove.
*
* Closes BOTH directions:
* - **#3351's** (reported-but-discarded): a field the transform's own
* return value held a value for, that sync/preservation then discarded
* or overwrote before the file was saved, must NOT be reported.
* - **#3345's** (persisted-but-unreported): a field `applyStatePreservation`
* restored that the transform never touched at all must still be
* reported — preservation can mutate a field the pre-sync intent never
* knew about.
*
* @param preSyncContent The transformFn's OWN return value — the content
* BEFORE `syncAndPreserveStateMd` ran this write — captured by the caller
* inside its own `readModifyWriteStateMd` callback. Comparing that against
* the actual bytes on disk after the full write pipeline settled is what
* makes the report reflect what POST-sync bytes hold (ADR-3408 §8.4),
* not what the pre-sync intent merely hoped for.
* @param reported The candidate field names — the transform's OWN
* success list (e.g. `beginPhaseCore`'s `updated`), never a raw intent
* list the transform might not have actually matched. Body Title-Case
* labels (`Status`, `Current Plan`) and frontmatter keys are both valid;
* each is looked up as a frontmatter key first, else as a body field —
* the same fallback chain `cmdStatePatch` used before this generalization.
* @param divergedFields Frontmatter field names `applyStatePreservation`
* actually restored during this write (ADR-3408 §8.5's out-param).
*/
function reconcileReportedFields(
statePath: string,
preSyncContent: string,
reported: string[],
divergedFields: string[],
): string[] {
const persisted = platformReadSync(statePath) || '';
const persistedFm = extractFrontmatter(persisted, statePath) as Record<string, unknown>;
const persistedBody = stripFrontmatter(persisted);
const preFm = extractFrontmatter(preSyncContent, statePath) as Record<string, unknown>;
const preBody = stripFrontmatter(preSyncContent);
// #3471 review: body-FIRST, frontmatter-fallback — mirrors the actual write
// precedence `patchCore`/`updateCore` apply (their own docstrings: "a key
// that resolves against the STRIPPED body ... wins deterministically even
// when the same key also happens to exist as a parsed frontmatter key").
// The prior frontmatter-first order was silently correct for every
// Title-Case body label (`Status`, `Current Plan`, ...) only because those
// never case-exact-match a frontmatter key (frontmatter keys are always
// lowercase snake_case) — so `hasOwnProperty` always missed and it fell
// through to the body anyway. It broke the one case where `field` IS
// lowercase and DOES exact-match a frontmatter key: a table-format
// STATE.md with a lowercase field name (e.g. `state update status ...`
// against `| status | ... |`). There, `preFm` (extracted from the
// transform's own pre-sync output) never has a `status` key yet — but
// `persistedFm` (extracted after `syncStateFrontmatter` ran) always does,
// since `status` is a schema-owned frontmatter key re-derived on every
// write. Reading frontmatter first made `intended` (body text) and
// `persistedValue` (frontmatter-derived enum) compare two different
// representations of the same field, and the write was never reconciled
// (regression: #1162's "state update is case-insensitive for table field
// names").
const valueOf = (fm: Record<string, unknown>, body: string, field: string): string | null => {
const bodyValue = stateExtractField(body, field);
if (bodyValue !== null) return bodyValue;
return Object.prototype.hasOwnProperty.call(fm, field) ? String(fm[field]) : null;
};
const reconciled: string[] = [];
for (const field of reported) {
const intended = valueOf(preFm, preBody, field);
const persistedValue = valueOf(persistedFm, persistedBody, field);
if (intended !== null && intended.trim() === (persistedValue ?? '').trim()) {
reconciled.push(field);
}
}
// #3471 review: only fold a `divergedFields` entry into the reported array
// when it is a `preserve-when-unchanged` row (has a genuine body-line
// label — Status, Current Plan, Current Phase, ...). #3345's direction
// ("preservation restored a field the intent never named") is about a
// caller-visible BODY field the transform could plausibly have named —
// never about `progress` (`preserve-always`) or `milestone`/`milestone_name`
// (`preserve-if-placeholder`), which are structured/paired fields no
// caller ever names via a per-field body label and whose restoration is
// the long-standing, silent #3242/#948 protection, not a caller-visible
// "update". Folding them in unconditionally reported `progress` as
// `updated` on every `state.patch`/`state.update` write that happened to
// preserve it — even when the call never touched Current Phase's
// curated-progress-preserving field at all (regression: #1264's
// `state.patch` of `Current Phase` reporting `updated: ['Current Phase',
// 'progress']` instead of `['Current Phase']`).
for (const field of divergedFields) {
const cls = stateTransitionMod.getFieldClassification(field);
if (!cls || cls.preservation !== 'preserve-when-unchanged') continue;
const label = bodyLabelFor(field);
if (!reconciled.includes(label)) reconciled.push(label);
}
return reconciled;
}
function cmdStateJson(cwd: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
@@ -3096,27 +3388,59 @@ function cmdStateJson(cwd: string, raw: boolean): void {
// report the preserved value instead of omitting the key.
const built = buildStateFrontmatter(body, cwd, undefined, readStoredTotalPhases(existingFm));
// Preserve frontmatter-only fields that cannot be recovered from the body.
if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
built['stopped_at'] = existingFm['stopped_at'];
}
if (existingFm && existingFm['paused_at'] && !built['paused_at']) {
built['paused_at'] = existingFm['paused_at'];
}
// Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
built['status'] = existingFm['status'];
}
// Bug #905: preserve scalar fields when body annotations are absent.
// Mirrors the same fallback pattern applied in syncStateFrontmatter.
if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
built['current_phase'] = existingFm['current_phase'];
}
if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
built['current_phase_name'] = existingFm['current_phase_name'];
}
if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
built['current_plan'] = existingFm['current_plan'];
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
// third private copy of the empty-only guards with no delta/staleness check
// at all — the shape that let a stale-but-present body annotation always
// beat a fresher curated frontmatter value in `state json` output (#3395's
// shape outside the write seam).
//
// `cmdStateJson` never writes — it is one snapshot read, not a
// before/after transform — so "did THIS write change the body source"
// (the #1230 delta the executor consults) is definitionally "no": every
// field's body source is passed as its own delta pre/post pair (the same
// value twice). That is what makes the executor's rule resolve to
// "restore the curated value whenever a real one exists" here — exactly
// §8.5's "same terms as an empty derived value" extended to a present
// one, i.e. the exact D3 fix. Deliberately scoped to only these six
// fields (not the full `applyStatePreservation` dispatch loop): `progress`
// (preserve-always) keeps its own `shouldPreserveExistingProgress`
// cross-milestone rule below — a DIFFERENT policy that must survive this
// change untouched — and `milestone`/`milestone_name`
// (preserve-if-placeholder) are out of D3's scope entirely.
if (existingFm) {
const sessionScope = matchSessionSection(body) ?? body;
const positionScope = matchCurrentPositionSection(body) ?? body;
const bodyStoppedAt = stateExtractField(sessionScope, 'Stopped At') || stateExtractField(sessionScope, 'Stopped at');
const bodyPausedAt = stateExtractField(sessionScope, 'Paused At');
const bodyPhaseSource = stateExtractField(body, 'Phase');
const bodyCurrentPhase = stateExtractField(body, 'Current Phase')
?? parseProsePhaseField(stateExtractField(positionScope, 'Phase')).phase;
const bodyCurrentPlan = stateExtractField(body, 'Current Plan');
const bodyStatus = stateExtractField(body, 'Status');
const unchanged = (v: string | null): { pre: string | null; post: string | null } => ({ pre: v, post: v });
const ctx = {
preFm: null,
postFm: built,
preFmSnapshot: existingFm,
resync: true,
deriveProgressKeys: false,
bodyDeltas: {
status: unchanged(bodyStatus),
stopped_at: unchanged(bodyStoppedAt),
paused_at: unchanged(bodyPausedAt),
current_phase: unchanged(bodyCurrentPhase),
current_plan: unchanged(bodyCurrentPlan),
current_phase_name: unchanged(bodyPhaseSource),
},
mutated: false,
};
for (const field of ['status', 'stopped_at', 'paused_at', 'current_phase', 'current_plan', 'current_phase_name']) {
const cls = stateTransitionMod.getFieldClassification(field);
if (cls) stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
}
}
// Preserve curated cross-milestone aggregates when local disk scanning sees
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
@@ -3169,10 +3493,13 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
// that itself contains a parenthetical. The #1695 delta-gate preservation
// still runs after the sync; the override is re-asserted after it inside
// readModifyWriteStateMd for layouts with no body `Phase:` line.
const divergedFields: string[] = [];
const rmwOptions: ReadModifyWriteOptions = {
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
divergedFields,
};
let updated: string[] = [];
let precomputedUpdated: string[] = [];
let preSyncContent = '';
// #3311: begin-phase is the claim point — it is the one Current Position
// transition that explicitly names its phase, so it both records this
// session's claim and detects a conflicting live claim for a different
@@ -3185,7 +3512,8 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
}
const result = transitionCore(content, intent, deps);
updated = result.updated;
precomputedUpdated = result.updated;
preSyncContent = result.content;
// #3127 resume: the core preserved the mid-flight Current Phase Name, so
// the intent-first override must not fire — it would drift frontmatter
// away from the preserved body value. Dropping it here is safe because
@@ -3196,6 +3524,12 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
return result.content;
}, cwd, rmwOptions);
// ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
// the bytes actually persisted (fix(#3351) generalized) and fold in any
// field preservation restored that this transform never touched (#3345's
// direction).
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
output(
{ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict },
raw,
@@ -3460,19 +3794,31 @@ function cmdStatePlannedPhase(cwd: string, phaseNumber: string | number, phaseNa
// line, and the prose re-derivation of current_phase_name truncates names
// that themselves contain a parenthetical — the authoritative override keeps
// the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
const divergedFields: string[] = [];
const rmwOptions: ReadModifyWriteOptions = {
resync: false,
deriveProgressKeys: true,
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
divergedFields,
};
let updated: string[] = [];
let precomputedUpdated: string[] = [];
let preSyncContent = '';
readModifyWriteStateMd(statePath, (content) => {
const result = transitionCore(content, intent, deps);
updated = result.updated;
precomputedUpdated = result.updated;
preSyncContent = result.content;
return result.content;
}, cwd, rmwOptions);
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
// against the bytes actually persisted (fix(#3351) generalized) and fold
// in any field preservation restored that this transform never touched
// (#3345's direction) — traced for this phase (design doc: "not traced in
// the analysis pass") and found to need exactly the same treatment as
// `cmdStateBeginPhase`.
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
const result = updated.length === 0
? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
: { updated, phase: phaseNumber, plan_count: planCount };
@@ -4326,6 +4672,8 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
const today = realClock.localToday();
const updated: string[] = [];
let preSyncContent = '';
const divergedFields: string[] = [];
readModifyWriteStateMd(statePath, (content) => {
const currentPhase = resolvedPhase;
@@ -4409,13 +4757,31 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
}
}
return reassemble(body);
}, cwd);
const out = reassemble(body);
preSyncContent = out;
return out;
}, cwd, { divergedFields });
// ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
// the analysis pass"). Unlike the transitionCore-based commands, this
// adapter's `updated` mixes FIELD names (Status, Last Activity, Last
// Activity Description — each reconcilable against the persisted bytes,
// same as every other command in this phase) with the SECTION name
// `Current Position` (the whole Current-Position block, not a single
// field `stateExtractField` can look up — reconciling it the same way as
// a field would always drop it as a false negative). Reconcile only the
// field-shaped entries (#3351's direction), pass the section entry
// through unconditionally, and fold in any field preservation restored
// that this transform never touched (#3345's direction).
const SECTION_ENTRIES = new Set(['Current Position']);
const sectionEntries = updated.filter((f) => SECTION_ENTRIES.has(f));
const fieldEntries = updated.filter((f) => !SECTION_ENTRIES.has(f));
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preSyncContent, fieldEntries, divergedFields)];
output(
{ updated, phase: resolvedPhase },
{ updated: reconciled, phase: resolvedPhase },
raw,
updated.length > 0 ? 'true' : 'false',
reconciled.length > 0 ? 'true' : 'false',
);
}
@@ -4468,6 +4834,10 @@ export = {
// Test seam (#1514): the pure retired/folded-phase parser, exposed so its
// strikethrough-detection logic can be property-tested directly.
_extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
// Test seam (#3471 review): the second hand-maintained table beside
// FIELD_CLASSIFICATION, exposed so a parity test can pin that every
// `preserve-when-unchanged` row has a label here.
_FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
_setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void {