* test(#3871): failing-first regressions for the dropped curated progress block Pins ADR-3473 §8.6 / #3756 at the consumer's output: state record-session and state add-decision on an archived-milestone project drop the curated progress frontmatter entirely, exit 0, and report nothing. Reproduced against the real CLI before writing the tests, not inferred from the issue text. Also adds the unit-level probe that applyStatePreservation's preserve-always row is inert on a resyncing write, and an over-preservation guard that an empty project is never inflated. Refs #3871 * feat(#3871): make the STATE.md pre-write snapshot mandatory via open()/rebuild() ADR-3473 §8.6. StatePreservationInput's nullable preFm and the always-present preFmSnapshot were the same extractFrontmatter call, one of them nulled on resync — a policy flag baked into a snapshot. Both collapse into a single StateTransaction whose snapshot cannot be absent: openStateTransaction() applies preservation, rebuildStateTransaction() does not, and both carry the snapshot because the reporting phase needs it either way. An absent snapshot is now a construction failure; an empty one stays legal, because that is what a document with no parseable frontmatter honestly has. writeStateMd requires a rebuild transaction, which types ADR-3408 §8.3's closed exception list at both call sites (state sync, health --repair) instead of matching them as strings in a ratcheted baseline. Fixes the dropped curated progress block: an all-zero or absent derived total set is an unmeasured scan, not a measurement, so the curated block stands. Also fixes two defects surfaced while building — preserve-always reported a mutation even when it restored an identical value, and it re-entered the curated object by reference, which would alias the snapshot the next phase diffs against. Refs #3871 * fix(#3871): close the three remaining subsumed defects and restore the arm the type does not replace Review of the first two commits found four things. The guard shrink deleted the seam-bypass axis whole, but only its writeStateMd( arm became redundant. Its other arm catches a call site re-assembling syncStateFrontmatter + applyPostSyncPreservation instead of the owned composition, which the transaction type does not make unrepresentable and which #3469 found live. Restored as findCompositionBypasses, terminal rather than ratcheted. Three of the four issues this phase claims were untouched. All three are the epic's own shape and are fixed at the seam: current_phase_name is reasserted from the curated value when the caller names none, and cmdStateJson stops carrying a hand-maintained list parallel to FIELD_CLASSIFICATION and projects it instead. The construction failure that is the point of this phase had no test. Every enumerated matrix row now has one, including the measured-versus-unmeasured coercion boundary and a seeded property that no curated key is ever dropped. ADR-3473 §8.6 said the guard 'keeps only its raw-write check'. Verified against next: there was no raw-write check, and four other checks it does not name. Amended in place with the evidence. ARCHITECTURE.md separately advertised a preservation policy the code had deleted. Refs #3871 * fix(#3871): do not let the unmeasured-scan rule block an explicitly-requested resync The remote matrix caught over-preservation, the failure this phase's own negative space says must not happen. state update Progress re-derives the block from the body the caller just rewrote; on a project with no phase dirs the derivation yields zero totals, the unmeasured rule read that as 'the scan measured nothing', and the stale curated percent was restored over the resync the user asked for. preserve-always already said what the missing condition was: never overwrite unless the caller explicitly names this field. explicitProgressField carries it and is derived from shouldResyncStateProgress, not set by hand at a call site, so it cannot drift from what the caller asked for. Two defects found in the same mechanism and fixed with it. readModifyWriteStateMd enumerates its option keys, so a new option was silently dropped rather than rejected. And the raw-write axis captured its first argument up to the first comma, which lands inside a nested path.join, so a write to a STATE.md literal was invisible to it — the prove-it-can-fail test caught that one immediately. No test assertion was weakened; all three frontmatter rows encode #3242, #1969 B3 and #1972 and stand unchanged. Refs #3871 * docs(#3871): record why the raw-write check is kept, not why it was named The amendment justified findRawStateWrites as 'written because §8.6 requires it to exist', which is cargo-culting the contract and would have been the wrong reason to keep anything. The real reason is that writeStateMd acquires the STATE.md lockfile and a raw fs.writeFileSync acquires nothing, so this is a lock bypass and lost-update is the #500/#905/#1230 family — and after this phase it is the one reachable path into the file that nothing else covers. Also records why ADR-3408 §8.6's deletion of the 'clear' policy is not the precedent it looks like: 'clear' was dead vocabulary in a closed enum, this is coverage of a reachable path. Refs #3871 * chore(#3871): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -129,6 +129,12 @@ const { getMilestoneInfo } = roadmapParserMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateMod = require('./state.cjs');
|
||||
const { writeStateMd } = stateMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateTransitionMod = require('./state-transition.cjs');
|
||||
const { rebuildStateTransaction } = stateTransitionMod;
|
||||
import { realClock } from './clock.cjs';
|
||||
import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
@@ -337,7 +343,15 @@ function runRepairAction(cwd: string, action: RemedyAction, paths: RepairPaths):
|
||||
stateContent += `**Status:** Resuming\n\n`;
|
||||
stateContent += `## Session Log\n\n`;
|
||||
stateContent += `- ${realClock.localToday()}: STATE.md regenerated by ${slash('health')} --repair\n`;
|
||||
writeStateMd(statePath, stateContent, cwd);
|
||||
// ADR-3473 §8.6: `/gsd-health --repair` is a factory reset, so
|
||||
// preservation must not run; the snapshot of the file being replaced is
|
||||
// still captured, and `{}` is the correct, legal snapshot of a
|
||||
// STATE.md that had no parseable frontmatter — which is the usual
|
||||
// reason this repair fires.
|
||||
const priorState = fs.existsSync(statePath) ? (safeReadFile(statePath) ?? '') : '';
|
||||
writeStateMd(statePath, stateContent, rebuildStateTransaction({
|
||||
snapshot: extractFrontmatter(priorState, statePath),
|
||||
}), cwd);
|
||||
return { success: true, path: 'STATE.md', extraDetails };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,16 @@ import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
type Scope = planningScopeMod.Scope;
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
/**
|
||||
* Coerce an arbitrary frontmatter scalar to a finite number, or `null` if it
|
||||
* is not one. Exported per ADR-3473 §8.6: `state-transition.cts`'s
|
||||
* progress-ratchet unmeasured-scan check ("is this derived total a real
|
||||
* measurement?") must ask through the SAME coercion this module already uses
|
||||
* for `existingProgressExceedsDerived`, rather than growing a second private
|
||||
* copy. This matters because frontmatter scalars arrive as STRINGS
|
||||
* (`"0"`, not `0`) — a raw `=== 0` test is wrong at both call sites.
|
||||
*/
|
||||
export function toFiniteNumber(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback, stateReplaceFieldInSession } from './state-document.cjs';
|
||||
import { KNOWN_TEMPLATE_DEFAULTS } from './state-document.cjs';
|
||||
import { KNOWN_TEMPLATE_DEFAULTS, toFiniteNumber } from './state-document.cjs';
|
||||
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
import type { HeadingToken } from './markdown-sectionizer.cjs';
|
||||
import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs';
|
||||
@@ -260,6 +260,147 @@ export function getFieldClassification(field: string): FieldClassification | nul
|
||||
return FIELD_CLASSIFICATION[field];
|
||||
}
|
||||
|
||||
/**
|
||||
* #3836: the single source of truth for "which frontmatter keys carry the
|
||||
* `preserve-when-unchanged` policy" — read straight off `FIELD_CLASSIFICATION`
|
||||
* rather than re-typed as a hand-maintained literal array at each consumer.
|
||||
* `cmdStateJson` (`state.cts`) previously hardcoded a 6-field list that had
|
||||
* already drifted from this table by one row (`last_activity_desc`, #3258) —
|
||||
* exactly the "second table parallel to the first" shape ADR-3473 exists to
|
||||
* remove. `progress`/`milestone`/`milestone_name` carry a different
|
||||
* preservation policy (`preserve-always` / `preserve-if-placeholder`) and are
|
||||
* naturally excluded by the filter, not by a separate exclusion list.
|
||||
*/
|
||||
export function getPreserveWhenUnchangedFields(): readonly string[] {
|
||||
return Object.keys(FIELD_CLASSIFICATION).filter(
|
||||
(field) => FIELD_CLASSIFICATION[field].preservation === 'preserve-when-unchanged',
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// The state transaction (ADR-3473 §8.6, issue #3871)
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Collapses the two-field `preFm` / `preFmSnapshot` shape (same source, same
|
||||
// arguments, same `extractFrontmatter` call — `preFm` was `preFmSnapshot` with
|
||||
// a policy flag baked in by nulling it on `resync`) into ONE snapshot plus a
|
||||
// typed constructor pair that names the policy explicitly instead of encoding
|
||||
// it as a null. See `.gsd/phase/feat-3871-state-transaction-snapshot/40-design.md`.
|
||||
|
||||
/** The two sanctioned write-path kinds (ADR-3408 §8.3's closed exception list, typed). */
|
||||
export type StateTransactionKind = 'open' | 'rebuild';
|
||||
|
||||
export type StateBodyDelta = { pre: string | null; post: string | null };
|
||||
|
||||
export type StateTransactionInit = {
|
||||
/** Pre-write frontmatter snapshot. `{}` is legal (see `createStateTransaction`). */
|
||||
snapshot: Record<string, unknown>;
|
||||
resync?: boolean;
|
||||
deriveProgressKeys?: boolean;
|
||||
bodyDeltas?: Record<string, StateBodyDelta>;
|
||||
/**
|
||||
* True ONLY when `resync` is true BECAUSE the caller explicitly named a
|
||||
* progress-affecting field (`state update Progress`, `state patch
|
||||
* Progress=...` / `Total Plans in Phase` / `Total Phases` —
|
||||
* `shouldResyncStateProgress`, state.cts), as opposed to `resync`
|
||||
* defaulting true for an unrelated write. See `applyPreserveAlways`'s use
|
||||
* of this flag for why the distinction matters (found while diagnosing a
|
||||
* regression against the pre-existing #3242 "resyncs progress frontmatter
|
||||
* from the updated body" spec, tests/frontmatter.test.cjs).
|
||||
*/
|
||||
explicitProgressField?: boolean;
|
||||
};
|
||||
|
||||
export type StateTransaction = {
|
||||
readonly kind: StateTransactionKind;
|
||||
readonly snapshot: Readonly<Record<string, unknown>>;
|
||||
readonly resync: boolean;
|
||||
readonly deriveProgressKeys: boolean;
|
||||
readonly bodyDeltas?: Readonly<Record<string, StateBodyDelta>>;
|
||||
readonly explicitProgressField: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared constructor body for `openStateTransaction` / `rebuildStateTransaction`
|
||||
* (ADR-3473 §8.6 Decision 2/3). Validates `init.snapshot` and freezes the
|
||||
* result so nothing downstream can mutate a transaction after construction
|
||||
* (this is what makes the aliasing fix in `applyPreserveAlways`'s clone hold:
|
||||
* the snapshot a caller passed in cannot be rewritten out from under it).
|
||||
*
|
||||
* `{}` and a null-prototype object are BOTH legal snapshots (Decision 2 / row
|
||||
* 15 of the behavior table): `extractFrontmatter` returns `{}` for a document
|
||||
* with no frontmatter or an unterminated one and never returns null or throws
|
||||
* (`src/frontmatter.cts`), so `{}` is the honest snapshot of a real document —
|
||||
* and `/gsd-health --repair`, which runs precisely when STATE.md is broken,
|
||||
* depends on that staying legal. What is NOT legal is the snapshot being
|
||||
* ABSENT (`null`/`undefined`/an array/a non-object): that is the caller
|
||||
* forgetting to read the pre-write document at all, a construction failure,
|
||||
* not a data question. Conflating "absent" with "empty" would turn the repair
|
||||
* path's normal case into a hard throw.
|
||||
*/
|
||||
function createStateTransaction(kind: StateTransactionKind, init: StateTransactionInit, ctorName: string): StateTransaction {
|
||||
if (init === null || typeof init !== 'object' || Array.isArray(init)) {
|
||||
const err = new Error(
|
||||
`${ctorName}: expected an init object, got ${init === null ? 'null' : typeof init}. ` +
|
||||
'Per ADR-3473 §8.6 / Decision 2, an absent init is a construction failure, distinct from ' +
|
||||
'a legal empty snapshot ({}) — do not "fix" this by tolerating null.',
|
||||
) as Error & { code: string; constructorName: string };
|
||||
err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
|
||||
err.constructorName = ctorName;
|
||||
throw err;
|
||||
}
|
||||
const snapshot = init.snapshot;
|
||||
if (snapshot === null || snapshot === undefined || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
const err = new Error(
|
||||
`${ctorName}: init.snapshot is required and must be a non-array object (frontmatter map). ` +
|
||||
`Per ADR-3473 §8.6 / Decision 2, an ABSENT snapshot is a construction failure — this is NOT ` +
|
||||
'the same as a legal EMPTY snapshot ({}), which every executor accepts and simply finds ' +
|
||||
'nothing to restore from (extractFrontmatter returns {} for a document with no parseable ' +
|
||||
'frontmatter, and /gsd-health --repair depends on that staying legal). Pass {} explicitly ' +
|
||||
'when the document truly has none; do not tolerate null/undefined here.',
|
||||
) as Error & { code: string; constructorName: string };
|
||||
err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
|
||||
err.constructorName = ctorName;
|
||||
throw err;
|
||||
}
|
||||
return Object.freeze({
|
||||
kind,
|
||||
snapshot,
|
||||
resync: init.resync === true,
|
||||
deriveProgressKeys: init.deriveProgressKeys === true,
|
||||
bodyDeltas: init.bodyDeltas,
|
||||
explicitProgressField: init.explicitProgressField === true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-3473 §8.6's `open()`: the default write-path transaction. Carries the
|
||||
* pre-write snapshot and applies preservation (`applyStatePreservation` runs
|
||||
* its full dispatch loop against it) — this is every STATE.md write EXCEPT
|
||||
* the two sanctioned exceptions below.
|
||||
*/
|
||||
export function openStateTransaction(init: StateTransactionInit): StateTransaction {
|
||||
return createStateTransaction('open', init, 'openStateTransaction');
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-3473 §8.6's `rebuild()`: the TYPED expression of ADR-3408 §8.3's closed
|
||||
* list of sanctioned exceptions to the preservation pipeline. Exactly two
|
||||
* callers may construct this: `cmdStateSync` (`state sync` re-derives
|
||||
* frontmatter FROM the body per #905 — the body is authoritative and
|
||||
* preservation would fight it) and `REGENERATE_STATE` (`/gsd-health --repair`'s
|
||||
* factory reset — the whole point is to replace what's there). The snapshot
|
||||
* is still carried (for §8.7's reporting) but `applyStatePreservation` skips
|
||||
* its dispatch loop entirely for a `rebuild` transaction.
|
||||
*
|
||||
* This list is NOT debt to be paid down later — it is a closed, deliberate
|
||||
* set. Adding a third caller is an amendment to ADR-3408 §8.3, not a call site
|
||||
* convenience.
|
||||
*/
|
||||
export function rebuildStateTransaction(init: StateTransactionInit): StateTransaction {
|
||||
return createStateTransaction('rebuild', init, 'rebuildStateTransaction');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// applyStatePreservation — table-driven post-sync preservation (ADR-1769 #1796)
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -274,38 +415,18 @@ export function getFieldClassification(field: string): FieldClassification | nul
|
||||
// the pre-#1796 inline block; this is the consolidation ADR-1769 / CONTEXT.md
|
||||
// already claimed shipped. See issue #1796 (Path A: finish the consolidation).
|
||||
|
||||
/**
|
||||
* ADR-3473 §8.6: the whole pre-write policy — snapshot, resync, deriveProgressKeys,
|
||||
* bodyDeltas — now travels as ONE `StateTransaction` (see `openStateTransaction`
|
||||
* / `rebuildStateTransaction` above), rather than as four separate fields the
|
||||
* caller could set inconsistently (the `preFm`/`preFmSnapshot` split this
|
||||
* replaces was exactly that: the same snapshot with a policy flag baked in by
|
||||
* nulling one copy of it on resync).
|
||||
*/
|
||||
export type StatePreservationInput = {
|
||||
/** Pre-transform frontmatter; `null` when the transition re-derives from disk (resync=true). */
|
||||
preFm: Record<string, unknown> | null;
|
||||
transaction: StateTransaction;
|
||||
/** Post-`syncStateFrontmatter` frontmatter (the freshly recomputed one). */
|
||||
postFm: Record<string, unknown>;
|
||||
/** Always-present pre-transform frontmatter snapshot (drives the #1230 deltas). */
|
||||
preFmSnapshot: Record<string, unknown>;
|
||||
/** True when the caller asked for a full disk re-derivation (sync / advancePlan / completePhase). */
|
||||
resync: boolean;
|
||||
/**
|
||||
* #2440: when true, total_plans and total_phases take the derived (post-sync)
|
||||
* value even under !resync, instead of the wholesale curated restore. Used
|
||||
* by callers (e.g. cmdStatePlannedPhase) where total_plans must correct to
|
||||
* disk truth after plans are added. Body-only writes (state.update/patch)
|
||||
* leave this false — the #3242 wholesale protection stays in force.
|
||||
*/
|
||||
deriveProgressKeys?: boolean;
|
||||
/**
|
||||
* #3258 / #3468: pre/post body-source values for every preserve-when-unchanged
|
||||
* (#1230 delta heuristic) row, keyed by the FRONTMATTER field the policy
|
||||
* guards — one channel for all seven rows (status, stopped_at,
|
||||
* current_phase_name, current_phase, current_plan, paused_at,
|
||||
* last_activity_desc), folded from the two channels (a bodyDeltas map plus
|
||||
* three dedicated pre/post parameter pairs) #3468 replaced. The caller
|
||||
* snapshots each field's body source before/after the transform (mirroring
|
||||
* how buildStateFrontmatter derives it); `applyPreserveWhenUnchanged`
|
||||
* restores the pre-write frontmatter value when that body source was left
|
||||
* unchanged by this write. Adding a preserve-when-unchanged row is a
|
||||
* one-row table edit PLUS a bodyDeltas entry from the caller — omitting the
|
||||
* entry is not a silent no-op, it THROWS (ADR-3408 §8.2, `applyPreserveWhenUnchanged`).
|
||||
*/
|
||||
bodyDeltas?: Record<string, { pre: string | null; post: string | null }>;
|
||||
};
|
||||
|
||||
export type StatePreservationResult = {
|
||||
@@ -319,13 +440,17 @@ export type StatePreservationResult = {
|
||||
* §8.1 — one executor per policy, sharing one result).
|
||||
*/
|
||||
export type PreservationCtx = {
|
||||
preFm: Record<string, unknown> | null;
|
||||
postFm: Record<string, unknown>;
|
||||
preFmSnapshot: Record<string, unknown>;
|
||||
snapshot: Record<string, unknown>;
|
||||
resync: boolean;
|
||||
deriveProgressKeys: boolean;
|
||||
bodyDeltas: Record<string, { pre: string | null; post: string | null }> | undefined;
|
||||
mutated: boolean;
|
||||
/** See `StateTransactionInit.explicitProgressField`. Defaults false for a
|
||||
* plain `PreservationCtx` built outside a `StateTransaction` (e.g.
|
||||
* `cmdStateJson`'s direct `applyPreserveWhenUnchanged` call, row 19 of the
|
||||
* behavior table) — that read path never reaches `applyPreserveAlways`. */
|
||||
explicitProgressField?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -384,7 +509,7 @@ export function applyPreserveWhenUnchanged(field: string, cls: FieldClassificati
|
||||
// 2. Only a real, non-whitespace-only curated string is worth restoring
|
||||
// (#3468: tightened from `.length > 0` to a trimmed check — a whitespace-
|
||||
// only snapshot is not a real curated value).
|
||||
const snapshot = ctx.preFmSnapshot[field];
|
||||
const snapshot = ctx.snapshot[field];
|
||||
if (typeof snapshot !== 'string' || snapshot.trim().length === 0) return;
|
||||
|
||||
// 3. Closed-vocabulary guard: status's 'unknown' sentinel is never restored.
|
||||
@@ -402,28 +527,112 @@ export function applyPreserveWhenUnchanged(field: string, cls: FieldClassificati
|
||||
ctx.mutated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed set of `progress` keys whose non-zero value means "a real
|
||||
* measurement happened" (ADR-3473 §8.6 / #3756).
|
||||
*/
|
||||
const PROGRESS_TOTAL_KEYS = ['total_phases', 'total_plans'] as const;
|
||||
|
||||
/**
|
||||
* Did this row's derived (or curated) value represent a REAL measurement?
|
||||
*
|
||||
* For a `progress-ratchet` row (today, only `progress`): an empty
|
||||
* milestone-scoped scan is "nothing was measured", not "zero is done"
|
||||
* (#3756, and the convention #3233 established — `computeProgressPercent`
|
||||
* already returns `null` for an empty denominator). Only the TOTALS decide:
|
||||
* `completed_*` being zero is normal for a real project, so it is
|
||||
* deliberately excluded from this check. A non-object / absent / negative /
|
||||
* non-numeric total is NOT a measurement, so it degrades TOWARD preservation,
|
||||
* never toward deletion — `toFiniteNumber` (not a raw `=== 0`/`> 0` test)
|
||||
* because frontmatter scalars arrive as STRINGS (`"0"`, not `0`).
|
||||
*
|
||||
* For any other row (no `progress-ratchet` strategy) the question is
|
||||
* meaningless, so it answers `true` and behavior is unchanged — this
|
||||
* function is only ever consulted from inside the `preserve-always` /
|
||||
* `progress-ratchet` branch below.
|
||||
*/
|
||||
function scanMeasuredSomething(cls: FieldClassification, value: unknown): boolean {
|
||||
if (cls.mergeStrategy !== 'progress-ratchet') return true;
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const rec = value as Record<string, unknown>;
|
||||
return PROGRESS_TOTAL_KEYS.some((k) => (toFiniteNumber(rec[k]) ?? 0) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone a curated value before it re-enters `postFm` (ADR-3473 §8.6,
|
||||
* "Defects fixed inline" / aliasing). `structuredClone` is a Node built-in;
|
||||
* this repo takes no external deps for it. WHY a clone and not a reference
|
||||
* assignment: the transaction's `snapshot` is now the SAME object §8.7's
|
||||
* reporting will diff against. Assigning the nested curated object by
|
||||
* reference would make `postFm.progress` alias that snapshot, so a later
|
||||
* in-place mutation of `postFm` would silently rewrite the snapshot too, and
|
||||
* the diff would report "no change" for a field that did change.
|
||||
*/
|
||||
function cloneCurated(value: unknown): unknown {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural equality for a restored value vs. what `postFm` already held
|
||||
* (ADR-3473 §8.6, "Defects fixed inline" / #948 no-op-write family).
|
||||
* `JSON.stringify` compare when either side is an object (the `progress`
|
||||
* block), `===` otherwise. WHY: `applyPreserveAlways` previously set
|
||||
* `ctx.mutated = true` unconditionally at its tail, even when it restored a
|
||||
* value identical to what was already there — driving a write that changes
|
||||
* nothing but still bumps `last_updated` / restamps `state_head`.
|
||||
* `applyPreserveWhenUnchanged` already guards this (its step 5); this brings
|
||||
* the two executors into agreement.
|
||||
*/
|
||||
function preservedValuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (typeof a === 'object' || typeof b === 'object') {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor for `preservation: 'preserve-always'` (ADR-3408 §8.1). Only
|
||||
* `progress` carries this policy today. Preserves #3242/#1446/#2440/#2969
|
||||
* semantics byte-for-byte: gated on `!resync` and a truthy `preFm[field]`;
|
||||
* the `mergeStrategy: 'progress-ratchet'` per-key merge only fires when the
|
||||
* caller opts in via `deriveProgressKeys`, else the whole curated block wins
|
||||
* wholesale.
|
||||
* semantics byte-for-byte on every row the behavior table marks unchanged;
|
||||
* ADR-3473 §8.6 fixes the #3756 defect (a resyncing write that measured
|
||||
* nothing must not drop a real curated block) plus the two "Defects fixed
|
||||
* inline" no-op-write / aliasing bugs.
|
||||
*/
|
||||
function applyPreserveAlways(field: string, cls: FieldClassification, ctx: PreservationCtx): void {
|
||||
if (ctx.resync || !ctx.preFm || !ctx.preFm[field]) return;
|
||||
const curated = ctx.snapshot[field];
|
||||
if (!curated) return;
|
||||
const derived = ctx.postFm[field];
|
||||
const derivedMeasured = scanMeasuredSomething(cls, derived);
|
||||
const curatedMeasured = scanMeasuredSomething(cls, curated);
|
||||
// On a resyncing write the fresh derivation is authoritative — UNLESS it
|
||||
// measured nothing while the curated block did (#3756), AND the caller did
|
||||
// not explicitly name a progress-affecting field this write. The
|
||||
// unmeasured-scan guard exists to stop an INCIDENTAL resync (e.g. `state
|
||||
// add-decision`, whose `resync` defaults true for reasons that have
|
||||
// nothing to do with `progress`) from dropping a real curated block when a
|
||||
// milestone-scoped disk scan measures nothing (#3756's archived-milestone
|
||||
// case). It must not also block a write the user pointed AT `progress` on
|
||||
// purpose: `preserve-always`'s own contract is "never overwrite unless the
|
||||
// caller explicitly names this field" (FIELD_CLASSIFICATION doc comment),
|
||||
// and `state update Progress` / `state patch Progress=...` are exactly
|
||||
// that naming — the resync they trigger must win even when the disk scan
|
||||
// it also drives (e.g. because there are no phase dirs at all) reads as
|
||||
// "unmeasured" (tests/frontmatter.test.cjs: "state.update \"Progress\"
|
||||
// resyncs progress frontmatter from the updated body", pre-existing, #3242).
|
||||
if (ctx.resync && (derivedMeasured || !curatedMeasured || ctx.explicitProgressField)) return;
|
||||
|
||||
if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && ctx.postFm[field]) {
|
||||
let next: unknown;
|
||||
if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && derived && derivedMeasured) {
|
||||
// #2440: total_plans and total_phases always take the derived (post-sync)
|
||||
// value even under !resync. This is used by cmdStatePlannedPhase where
|
||||
// total_plans must correct upward after plans are added. For body-only
|
||||
// writes (state.update/patch without the flag), the wholesale restore
|
||||
// below preserves everything as before — the #3242 Bug A protection
|
||||
// stays fully in force.
|
||||
const curated = ctx.preFm[field] as Record<string, unknown> | null;
|
||||
const derived = (ctx.postFm[field] ?? {}) as Record<string, unknown>;
|
||||
const merged: Record<string, unknown> = { ...derived };
|
||||
if (curated) {
|
||||
const curatedRecord = curated as Record<string, unknown> | null;
|
||||
const derivedRecord = (derived ?? {}) as Record<string, unknown>;
|
||||
const merged: Record<string, unknown> = { ...derivedRecord };
|
||||
if (curatedRecord) {
|
||||
// #2440: total_plans and total_phases always take the derived value.
|
||||
// #2969: completed_plans and completed_phases take the derived value
|
||||
// when it is GREATER than the curated value (gap-closure plans that
|
||||
@@ -434,10 +643,10 @@ function applyPreserveAlways(field: string, cls: FieldClassification, ctx: Prese
|
||||
// disk counts, and a stale curated percent would be incoherent against
|
||||
// the ratcheted-up completed counts (e.g. 54/54 at 93%).
|
||||
const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
|
||||
for (const [key, value] of Object.entries(curated)) {
|
||||
for (const [key, value] of Object.entries(curatedRecord)) {
|
||||
if (key === 'total_plans' || key === 'total_phases' || key === 'percent') continue;
|
||||
if (ratchetUpKeys.has(key)) {
|
||||
const derivedNum = typeof derived[key] === 'number' ? derived[key] : -Infinity;
|
||||
const derivedNum = typeof derivedRecord[key] === 'number' ? derivedRecord[key] : -Infinity;
|
||||
const curatedNum = typeof value === 'number' ? value : -Infinity;
|
||||
// Take the derived value only when it ratchets up (strictly
|
||||
// greater — #2969's `>` not `>=`); else keep curated.
|
||||
@@ -448,10 +657,12 @@ function applyPreserveAlways(field: string, cls: FieldClassification, ctx: Prese
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.postFm[field] = merged;
|
||||
next = merged;
|
||||
} else {
|
||||
ctx.postFm[field] = ctx.preFm[field];
|
||||
next = cloneCurated(curated);
|
||||
}
|
||||
if (preservedValuesEqual(ctx.postFm[field], next)) return;
|
||||
ctx.postFm[field] = next;
|
||||
ctx.mutated = true;
|
||||
}
|
||||
|
||||
@@ -477,7 +688,7 @@ function applyPreserveIfPlaceholder(_field: string, _cls: FieldClassification, c
|
||||
&& derivedName.length > 0
|
||||
&& derivedName !== MILESTONE_PLACEHOLDER
|
||||
&& !/^[\s—–:-]/.test(derivedName);
|
||||
const snapshotName = ctx.preFmSnapshot['milestone_name'];
|
||||
const snapshotName = ctx.snapshot['milestone_name'];
|
||||
const snapshotNameIsReal = typeof snapshotName === 'string'
|
||||
&& snapshotName.length > 0
|
||||
&& snapshotName !== MILESTONE_PLACEHOLDER;
|
||||
@@ -487,7 +698,7 @@ function applyPreserveIfPlaceholder(_field: string, _cls: FieldClassification, c
|
||||
ctx.postFm['milestone_name'] = snapshotName;
|
||||
ctx.mutated = true;
|
||||
}
|
||||
const snapshotVersion = ctx.preFmSnapshot['milestone'];
|
||||
const snapshotVersion = ctx.snapshot['milestone'];
|
||||
if (
|
||||
typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
|
||||
ctx.postFm['milestone'] !== snapshotVersion
|
||||
@@ -517,14 +728,24 @@ function applyDerive(_field: string, _cls: FieldClassification, _ctx: Preservati
|
||||
* any field was restored.
|
||||
*/
|
||||
export function applyStatePreservation(input: StatePreservationInput): StatePreservationResult {
|
||||
const { transaction } = input;
|
||||
|
||||
// A `rebuild()` transaction still carries the snapshot (§8.7's reporting
|
||||
// needs it) but must not run preservation at all: `state sync` / `REGENERATE_STATE`
|
||||
// exist to let the body / factory-reset win, and restoring curated values
|
||||
// over that would re-lock exactly what the command was invoked to replace.
|
||||
if (transaction.kind === 'rebuild') {
|
||||
return { postFm: input.postFm, mutated: false };
|
||||
}
|
||||
|
||||
const ctx: PreservationCtx = {
|
||||
preFm: input.preFm,
|
||||
postFm: input.postFm,
|
||||
preFmSnapshot: input.preFmSnapshot,
|
||||
resync: input.resync,
|
||||
deriveProgressKeys: input.deriveProgressKeys === true,
|
||||
bodyDeltas: input.bodyDeltas,
|
||||
snapshot: transaction.snapshot,
|
||||
resync: transaction.resync,
|
||||
deriveProgressKeys: transaction.deriveProgressKeys === true,
|
||||
bodyDeltas: transaction.bodyDeltas,
|
||||
mutated: false,
|
||||
explicitProgressField: transaction.explicitProgressField === true,
|
||||
};
|
||||
|
||||
for (const field of Object.keys(FIELD_CLASSIFICATION)) {
|
||||
|
||||
140
src/state.cts
140
src/state.cts
@@ -71,6 +71,9 @@ type StateTransitionIntent = stateTransitionMod.StateTransitionIntent;
|
||||
type StateTransitionDeps = stateTransitionMod.StateTransitionDeps;
|
||||
type PhaseInventoryRecord = stateTransitionMod.PhaseInventoryRecord;
|
||||
type PhaseInventoryResult = stateTransitionMod.PhaseInventoryResult;
|
||||
// ADR-3473 §8.6: the state transaction type (see openStateTransaction /
|
||||
// rebuildStateTransaction in state-transition.cts).
|
||||
type StateTransaction = stateTransitionMod.StateTransaction;
|
||||
import {
|
||||
computeProgressPercent,
|
||||
normalizeProgressNumbers,
|
||||
@@ -133,6 +136,26 @@ interface ReadModifyWriteOptions {
|
||||
* callers that do not report per-field arrays; costs nothing extra.
|
||||
*/
|
||||
divergedFields?: string[];
|
||||
/**
|
||||
* ADR-3473 §8.6 (found while diagnosing a regression against the
|
||||
* pre-existing #3242 "resyncs progress frontmatter from the updated body"
|
||||
* spec): true ONLY when `resync` was set to true BECAUSE the caller
|
||||
* explicitly named a progress-affecting field (`Progress`, `Total Plans in
|
||||
* Phase`, `Total Phases` — see `shouldResyncStateProgress`), as opposed to
|
||||
* `resync` defaulting true for an unrelated write (e.g. `state
|
||||
* add-decision`, `state advance-plan`). The `preserve-always` /
|
||||
* `progress-ratchet` unmeasured-scan guard (`applyPreserveAlways`,
|
||||
* state-transition.cts) exists to stop an INCIDENTAL resync from dropping a
|
||||
* real curated block when the disk scan measured nothing (#3756, an
|
||||
* archived-milestone side effect nobody asked for). It must NOT also block
|
||||
* a write the user pointed AT `progress` on purpose — `preserve-always`'s
|
||||
* own contract is "never overwrite unless the caller explicitly names this
|
||||
* field" (FIELD_CLASSIFICATION doc comment), and `state update Progress` /
|
||||
* `state patch Progress=...` are exactly that explicit naming. Set only by
|
||||
* `cmdStateUpdate` / `cmdStatePatch`, the only two call sites where
|
||||
* `resync` is driven by `shouldResyncStateProgress` rather than defaulting.
|
||||
*/
|
||||
explicitProgressField?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,7 +583,7 @@ function cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolea
|
||||
precomputed = (result.data as { updated: string[]; failed: string[] }) ?? precomputed;
|
||||
preSyncContent = result.content;
|
||||
return result.content;
|
||||
}, cwd, { resync: shouldResync, divergedFields });
|
||||
}, cwd, { resync: shouldResync, divergedFields, explicitProgressField: shouldResync });
|
||||
|
||||
// ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
|
||||
// patchCore's bookkeeping says whether the stateReplaceField text-replace
|
||||
@@ -694,7 +717,7 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
|
||||
}
|
||||
preSyncContent = result.content;
|
||||
return result.content;
|
||||
}, cwd, { resync: shouldResync, divergedFields, authoritativeFm });
|
||||
}, cwd, { resync: shouldResync, divergedFields, authoritativeFm, explicitProgressField: shouldResync });
|
||||
|
||||
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
|
||||
// `updateCore`'s own match does not know whether sync/preservation later
|
||||
@@ -3193,7 +3216,25 @@ function withStateLock<T>(statePath: string, fn: () => T): T {
|
||||
* @param clock
|
||||
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
|
||||
*/
|
||||
function writeStateMd(statePath: string, content: string, cwd?: string, clock?: StateLockClock): void {
|
||||
/**
|
||||
* ADR-3473 §8.6: `writeStateMd` is ADR-3408 §8.3's sanctioned-exception write
|
||||
* path — only a `rebuildStateTransaction` may travel it. Enforced here rather
|
||||
* than left to caller discipline: the transaction TYPE is what makes the two
|
||||
* sanctioned exceptions (`cmdStateSync`, `REGENERATE_STATE`) greppable and
|
||||
* closed, and an `open()` transaction reaching this function would mean a
|
||||
* preservation-governed write silently skipped preservation.
|
||||
*/
|
||||
function writeStateMd(statePath: string, content: string, transaction: StateTransaction, cwd?: string, clock?: StateLockClock): void {
|
||||
if (transaction.kind !== 'rebuild') {
|
||||
const err = new Error(
|
||||
`writeStateMd: expected a 'rebuild' transaction, got '${transaction.kind}'. writeStateMd is ` +
|
||||
'ADR-3408 §8.3\'s sanctioned-exception write path (cmdStateSync / REGENERATE_STATE only) — ' +
|
||||
'only rebuildStateTransaction() may travel it (ADR-3473 §8.6). An open() transaction here ' +
|
||||
'would silently skip preservation for a write that was supposed to run it.',
|
||||
) as Error & { code: string };
|
||||
err.code = 'STATE_TRANSACTION_KIND_INVALID';
|
||||
throw err;
|
||||
}
|
||||
const lockPath = acquireStateLock(statePath, clock);
|
||||
// Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
|
||||
// concurrent writer landing in the (now-closed) scan→lock window.
|
||||
@@ -3213,10 +3254,14 @@ function writeStateMd(statePath: string, content: string, cwd?: string, clock?:
|
||||
if (cwd) _diskScanCache.delete(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);
|
||||
// the sanctioned-permanent empty-field fallback is now DERIVED FROM THE
|
||||
// TRANSACTION KIND (ADR-3473 §8.6) rather than asserted by a literal
|
||||
// `true` at this call site: only a `rebuild` transaction can reach this
|
||||
// function (enforced above), so `transaction.kind === 'rebuild'` is
|
||||
// always `true` here today, but the derivation is what keeps the
|
||||
// fallback's scope tied to the transaction type rather than a
|
||||
// hard-coded constant that could silently drift from it.
|
||||
const synced = syncStateFrontmatter(content, cwd, undefined, transaction.kind === 'rebuild');
|
||||
platformWriteSync(statePath, synced);
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
@@ -3280,10 +3325,7 @@ function applyPostSyncPreservation(
|
||||
options: StatePreservationOptions,
|
||||
): string {
|
||||
assertStatePreservationOptions(options, 'applyPostSyncPreservation');
|
||||
const { resync, authoritativeFm, deriveProgressKeys, divergedFields } = options;
|
||||
// Snapshot the existing progress block BEFORE the transform so we can
|
||||
// restore it when resync is false.
|
||||
const preFm = resync ? null : extractFrontmatter(originalContent, statePath) as Record<string, unknown>;
|
||||
const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField } = options;
|
||||
|
||||
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
||||
// we can detect whether THIS write changed them. syncStateFrontmatter
|
||||
@@ -3399,11 +3441,20 @@ function applyPostSyncPreservation(
|
||||
// callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
|
||||
// nothing extra and see no change to `synced`/the returned content.
|
||||
const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
|
||||
const preservation = applyStatePreservation({
|
||||
preFm, postFm, preFmSnapshot, resync,
|
||||
// ADR-3473 §8.6: the pre-write snapshot + policy flags now travel as ONE
|
||||
// transaction rather than as a nullable `preFm` alongside the always-present
|
||||
// `preFmSnapshot` (same source, same extractFrontmatter call — `preFm` was
|
||||
// `preFmSnapshot` with the `resync` policy baked in by nulling it, which is
|
||||
// what made `applyPreserveAlways` inert on the default resyncing write
|
||||
// path — #3756).
|
||||
const transaction = stateTransitionMod.openStateTransaction({
|
||||
snapshot: preFmSnapshot,
|
||||
resync,
|
||||
deriveProgressKeys: deriveProgressKeys === true,
|
||||
bodyDeltas,
|
||||
explicitProgressField: explicitProgressField === true,
|
||||
});
|
||||
const preservation = applyStatePreservation({ transaction, postFm });
|
||||
if (divergedFields && preservationInputSnapshot) {
|
||||
// §8.5's "liberal but visible": every field whose value actually
|
||||
// differs before vs after `applyStatePreservation` is a field where the
|
||||
@@ -3567,6 +3618,7 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
||||
authoritativeFm: options?.authoritativeFm,
|
||||
deriveProgressKeys: options?.deriveProgressKeys === true,
|
||||
divergedFields: options?.divergedFields,
|
||||
explicitProgressField: options?.explicitProgressField === true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3795,12 +3847,22 @@ function cmdStateJson(cwd: string, raw: boolean): void {
|
||||
?? parseProsePhaseField(stateExtractField(positionScope, 'Phase')).phase;
|
||||
const bodyCurrentPlan = stateExtractField(body, 'Current Plan');
|
||||
const bodyStatus = stateExtractField(body, 'Status');
|
||||
// #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
|
||||
// bodyDeltas, `last_activity_desc`) — the `Last Activity Description`
|
||||
// label, falling back to the prose `Last Activity:` line's parsed
|
||||
// description. Read-side twin of #3258's write-side wiring; this field is
|
||||
// `preserve-when-unchanged` per FIELD_CLASSIFICATION and was previously
|
||||
// absent from this read path entirely (never derived here, never in the
|
||||
// loop below), so a stale body annotation always beat a fresher curated
|
||||
// frontmatter value on every `state json` read.
|
||||
const bodyLastActivityRaw = stateExtractField(body, 'Last Activity') ?? stateExtractField(body, 'Last activity');
|
||||
const bodyLastActivityDesc = stateExtractField(body, 'Last Activity Description')
|
||||
?? parseProseLastActivityField(bodyLastActivityRaw).description;
|
||||
|
||||
const unchanged = (v: string | null): { pre: string | null; post: string | null } => ({ pre: v, post: v });
|
||||
const ctx = {
|
||||
preFm: null,
|
||||
postFm: built,
|
||||
preFmSnapshot: existingFm,
|
||||
snapshot: existingFm,
|
||||
resync: true,
|
||||
deriveProgressKeys: false,
|
||||
bodyDeltas: {
|
||||
@@ -3810,10 +3872,15 @@ function cmdStateJson(cwd: string, raw: boolean): void {
|
||||
current_phase: unchanged(bodyCurrentPhase),
|
||||
current_plan: unchanged(bodyCurrentPlan),
|
||||
current_phase_name: unchanged(bodyPhaseSource),
|
||||
last_activity_desc: unchanged(bodyLastActivityDesc),
|
||||
},
|
||||
mutated: false,
|
||||
};
|
||||
for (const field of ['status', 'stopped_at', 'paused_at', 'current_phase', 'current_plan', 'current_phase_name']) {
|
||||
// #3836: derive the field set from FIELD_CLASSIFICATION's
|
||||
// `preserve-when-unchanged` rows (single source of truth) instead of a
|
||||
// hand-typed literal that can drift from the table — this IS the fix,
|
||||
// not merely an addition of one more name to the literal.
|
||||
for (const field of stateTransitionMod.getPreserveWhenUnchangedFields()) {
|
||||
const cls = stateTransitionMod.getFieldClassification(field);
|
||||
if (cls) stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
|
||||
}
|
||||
@@ -4190,6 +4257,16 @@ 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.
|
||||
//
|
||||
// #3834: without a name, the body-source delta rule that would normally
|
||||
// preserve the curated `current_phase_name` (FIELD_CLASSIFICATION:
|
||||
// preserve-when-unchanged) cannot fire — THIS write rewrites the `Phase:`
|
||||
// source line to `N — READY TO EXECUTE` itself, so pre/post disagree by
|
||||
// construction and the post-sync re-derivation harvests "READY TO EXECUTE"
|
||||
// as if it were the name. The fix mirrors the named-arg path: reassert an
|
||||
// authoritative override, falling back to the pre-write curated value (read
|
||||
// inside the RMW callback, before this write's own body mutation) rather
|
||||
// than leaving the field to a delta heuristic this exact transition defeats.
|
||||
const divergedFields: string[] = [];
|
||||
const rmwOptions: ReadModifyWriteOptions = {
|
||||
resync: false,
|
||||
@@ -4201,6 +4278,13 @@ function cmdStatePlannedPhase(cwd: string, phaseNumber: string | number, phaseNa
|
||||
let precomputedUpdated: string[] = [];
|
||||
let preSyncContent = '';
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
if (!intent.phaseName) {
|
||||
const preFm = extractFrontmatter(content, statePath) as Record<string, unknown>;
|
||||
const curatedName = preFm['current_phase_name'];
|
||||
if (typeof curatedName === 'string' && curatedName.trim().length > 0) {
|
||||
rmwOptions.authoritativeFm = { current_phase_name: curatedName };
|
||||
}
|
||||
}
|
||||
const result = transitionCore(content, intent, deps);
|
||||
precomputedUpdated = result.updated;
|
||||
preSyncContent = result.content;
|
||||
@@ -4828,7 +4912,12 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
}
|
||||
|
||||
if (changes.length > 0 || modified !== content) {
|
||||
writeStateMd(statePath, modified, cwd);
|
||||
// ADR-3473 §8.6: `rebuild()` is the typed expression of #905's contract —
|
||||
// `state sync` exists to let the body win, so preservation must NOT run,
|
||||
// and the snapshot is carried anyway because §8.7's reporting needs it.
|
||||
writeStateMd(statePath, modified, stateTransitionMod.rebuildStateTransaction({
|
||||
snapshot: extractFrontmatter(content, statePath),
|
||||
}), cwd);
|
||||
}
|
||||
|
||||
output({ synced: true, changes, dry_run: false }, raw, undefined);
|
||||
@@ -5218,6 +5307,17 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
||||
const updated: StateCompletePhaseUpdateEntry[] = [];
|
||||
let preSyncContent = '';
|
||||
const divergedFields: string[] = [];
|
||||
// #3835: complete-phase unconditionally rewrites the body `Phase:` line to
|
||||
// `N — COMPLETE` below. That defeats current_phase_name's
|
||||
// preserve-when-unchanged delta rule the same way #3834's no-`--name`
|
||||
// planned-phase write does — pre/post body-source disagree BY CONSTRUCTION
|
||||
// (this write is what changed the source line), so the post-sync
|
||||
// re-derivation harvests nothing from "COMPLETE" and the curated key is
|
||||
// dropped entirely rather than preserved. The write site already documents
|
||||
// "an absent name does NOT clear an existing curated value" for the body
|
||||
// (`Current Phase Name` section below) — this reasserts the same rule for
|
||||
// the frontmatter key, mirroring cmdStatePlannedPhase's fix.
|
||||
const rmwOptions: ReadModifyWriteOptions = { divergedFields };
|
||||
|
||||
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
||||
const currentPhase = resolvedPhase;
|
||||
@@ -5227,6 +5327,10 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
||||
const existingFm = extractFrontmatter(content, statePath) as Record<string, unknown>;
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
let body = stripFrontmatter(content);
|
||||
const curatedPhaseName = existingFm['current_phase_name'];
|
||||
if (typeof curatedPhaseName === 'string' && curatedPhaseName.trim().length > 0) {
|
||||
rmwOptions.authoritativeFm = { current_phase_name: curatedPhaseName };
|
||||
}
|
||||
|
||||
const reassemble = (b: string) =>
|
||||
hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b;
|
||||
@@ -5304,7 +5408,7 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
||||
const out = reassemble(body);
|
||||
preSyncContent = out;
|
||||
return out;
|
||||
}, cwd, { divergedFields });
|
||||
}, cwd, rmwOptions);
|
||||
|
||||
// ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
|
||||
// the analysis pass"). Unlike the transitionCore-based commands, this
|
||||
|
||||
Reference in New Issue
Block a user