Files
msd-core/src/state-transition.cts
Tom Boucher 6b7df61938 enhance(#3881): one YAML parser — vendored js-yaml replaces the hand-rolled dialect (#3888)
* docs(#3881): answer §8.1's open question and correct three wrong premises

ADR-3473 §8.1 carries a blocking open question with a forcing function: it must
be answered before any implementation PR for the rule opens. Answered here as (a),
a string-coercing adapter, with the measurement that settles it.

The sequencing note bet that §8.8's schema would make (b) tractable. Measured
against merged reality it does not: only 33 of extractFrontmatter's 78 non-test
call sites read STATE.md, and two of the five compensating mechanisms §8.1 lists
survive real types, leaving ~31 lines across 3 call sites as the actual prize.

Also corrects three claims verified false while answering it. §8.1's justifying
sentence names #3349 and #3360 as defects a real parser would fix; both are
already fixed on next, confirmed by executing the compiled parser rather than
reading it. The guard roster calls lint-frontmatter-scalar-broad-grep.cjs an
expected casualty of this rule, but it guards shell grep idioms in workflow bash
fences and never touches our parser. The same roster calls lint-vendored-deps.cjs
reusable as-is; it is hardcoded to re2js throughout.

The last two were caught by applying the rule this amendment records -- a factual
claim in this ADR is a hypothesis until the implementing phase executes it -- on
its first use.

Refs #3881

* docs(#3881): record that §8.1's fork is ill-posed and (a) is not implementable

An adversarial pass on the Phase 4 design established by execution that
extractFrontmatter is not a YAML parser but a line-oriented scanner whose output
is a function of raw source text. Four spellings of the same value collapse to
one js-yaml tree but produce four distinct legacy strings, one of them mangled.
No adapter over a tree can choose among outputs the tree does not distinguish,
so fork (a) -- keep a string-coercing adapter so the existing contract holds --
cannot be built. For any document with a non-scalar value, (a) collapses into
(b); about 26 percent of frontmatter-carrying documents have one.

Also records three design defects and one new attack surface, all confirmed by
execution: catching a parse failure and returning {} would delete the frontmatter
block on the next write at eight call sites that conflate empty with unparseable;
an empty value yields null where legacy yields {}, and reconstructFrontmatter
omits null-valued keys, so the shipped state template's empty progress key would
vanish; the #1882 truncation probe is parseYamlRegion itself rather than a
pre-parse heuristic, so it cannot both stay unchanged and survive that deletion;
and FAILSAFE_SCHEMA still resolves aliases, expanding seven lines to 22.8 MB.

The rule is not deferred. The measurement is the deliverable and the re-scoping
is recorded as an open question with a forcing function, per section 8's own rule.

Refs #3881

* test(#3881): failing-first rows for block scalars, unicode keys and the missing #3594 matrix

Creates tests/feat-3594-parser-adversarial-frontmatter.test.cjs, the file the fixture README instructs contributors to register fixtures in but which never existed.

Section C: table-driven ownership check over tests/fixtures/adversarial/frontmatter/ so a fixture with no matrix entry fails loudly; six existing fixtures (duplicate-keys, crlf-mixed, unclosed-block, unicode-keys-and-values, null-byte-value, huge-bounded) each get the invariant its README states.

B1 blockScalarValueIsNotTheBlockIndicator: parsing commands/gsd/add-tests.md must give argument-instructions the instruction text, not the literal '|'. RED today.

B2 blockScalarDoesNotInventATopLevelKey: same parse must not produce a top-level Example key scraped from inside the block body. RED today.

B3 unicodeKeyRoundTripsAsIs: the 相 key in unicode-keys-and-values.md must survive parsing; today it is silently dropped. RED today.

Refs #3881

* chore(#3881): vendor js-yaml and generalize the vendored-deps guard to a manifest

Packaging step for ADR-3473 §8.1: makes js-yaml available to gsd-core/bin/** without promoting it out of devDependencies (promoting broke every installed tree, #3496).

gsd-core/bin/lib/vendor/js-yaml.cjs is a verbatim copy of node_modules/js-yaml/dist/js-yaml.js (the self-contained UMD dist bundle, not index.js), exposing load/dump/FAILSAFE_SCHEMA/YAMLException with zero require() calls of its own.

src/vendor/js-yaml.d.cts is hand-authored, not copied, because js-yaml ships no upstream .d.ts and @types/js-yaml is not installed. It is deliberately narrow, declaring only the four symbols in use, so anchors/aliases/custom types/loadAll are unreachable from typed code -- a compile-time enforcement of ADR-3473 §8.1's refusal to expand alias resolution for security reasons. Because it has no upstream counterpart it is excluded from the byte-compare.

scripts/lint-vendored-deps.cjs is refactored from a script hardcoded to re2js into a table-driven VENDORED manifest (one row per package: upstream/vendored .cjs paths, optional .d.cts paths, twin kind upstream-verbatim vs hand-authored) so a second vendored package does not require a second hardcoded check block, per ADR-3473 §8.3 'one implementation per rule'. The four existing re2js checks (vendored .cjs vs node_modules, vendored .d.cts vs node_modules, src/vendor twin vs bin-side twin, devDependency version pin vs installed version) are preserved unchanged; verified pass/fail identical before and after the refactor, and the guard's ability to fail was re-proven with a deliberate one-byte append to both re2js.cjs and js-yaml.cjs, then restored.

docs/INVENTORY.md and docs/INVENTORY-MANIFEST.json (via gen-inventory-manifest.cjs --write, run after build:lib) register vendor/js-yaml.cjs. gsd-core/bin/lib/vendor/README.md documents both vendored packages and the two twin kinds.

Refs #3881

* feat(#3881): parse .planning frontmatter with the vendored js-yaml

ADR-3473 §8.1: extractFrontmatter's read path is no longer a hand-rolled
line scanner. parseYamlRegion, escapeDoubleQuoted, unescapeDoubleQuoted and
parseQuotedScalar are deleted (not patched); parsing now goes through the
vendored js-yaml (./vendor/js-yaml.cjs) under { schema: FAILSAFE_SCHEMA,
json: true }. Everything js-yaml does not do is layered on top, in one
place, carrying the seven design-doc consequences:

1. Empty value: a null js-yaml value is coerced to {} (matching legacy's
   own empty-value contract) so reconstructFrontmatter — which omits
   null-valued keys — still round-trips a bare `key:` line instead of
   deleting it. Verified live: progress: with no value survives
   parse -> reconstruct -> re-parse.

2. Unparseable no longer collapses to a bare {}: a new FRONTMATTER_UNPARSEABLE
   Symbol (exported), keyed exactly like the existing #3257 FULL_LINE_COMMENTS
   channel, is carried on the {} returned for malformed/refused YAML. Invisible
   to Object.keys/entries/JSON.stringify/for-in, so the 70 call sites that
   never inspect it are unaffected; wiring the 8 hasFrontmatter sites to
   consult it is a separate change, not done here.

3. Non-scalar object-list items (the four spellings of `- test: a b` that
   js-yaml collapses into one tree shape) are rendered as a canonical
   `key: value[, key2: value2]` string per item, keeping the existing
   array-of-strings value SHAPE. A full corpus differential over all 1702
   tracked markdown files found 11 residual divergences from the legacy
   parser (enumerated in the PR/report), most of them the parser now being
   MORE correct (a dropped quoted top-level key, the block-scalar/phantom-key
   defect, a dropped Unicode key).

4. The #1882 truncation probe still runs the one real parser, but derives
   its key count from js-yaml's own thrown error and mark.line when the
   whole region doesn't parse cleanly (the dominant real truncation shape:
   fence opened, well-formed keys, no closing fence). Verified against both
   the clean-parse and the exception-fallback path.

5. The #3257 comment channel now attributes each pending column-0 comment
   against js-yaml's own parsed top-level key list (matched by literal key
   text, in document order) instead of the legacy ASCII-only key regex, so
   a comment above a Unicode key attaches correctly.

6. Anchors, aliases and merge keys are refused outright (a raw-text
   pre-scan, since FAILSAFE_SCHEMA still resolves them) — corpus occurrences
   today: zero. A 7-line billion-laughs fixture is verified refused rather
   than expanded.

7. A literal U+0000 is swapped for a private-use sentinel before the parse
   and restored in every resulting string afterward, since js-yaml rejects
   NUL unconditionally under every schema.

escapeDoubleQuoted is deleted and reimplemented via js-yaml's dump()
(forced double-quoted style), with control-char hex escapes lowercased to
keep serialized output byte-stable (#1779 emitted lowercase); it keeps its
exported name and signature for its two other call sites (commands.cts,
runtime-artifact-conversion.cts), which need no change.

frontmatterDeepEqual, the comment channel, sliceTopLevelFrontmatterSegments,
regenerateFrontmatterKey's guard, noOpObjectListSetError and
parseMustHavesBlock are all unchanged — retiring them is fork (b) and is
not this phase.

Refs #3881

* fix(#3881): quote template placeholders and preserve unparseable frontmatter

SECURITY.md/UI-SPEC.md/VALIDATION.md wrote frontmatter placeholders as
bare {N}/{phase-slug}/{date}, which is valid YAML flow-mapping syntax
under the vendored js-yaml parser, not the literal placeholder text
intended. Quote them so they parse as strings.

Wire the FRONTMATTER_UNPARSEABLE Symbol (exported but unused) at the
8 call sites in state.cts/state-transition.cts that compute
hasFrontmatter via Object.keys(extractFrontmatter(...)).length > 0 and
reassemble the document without a frontmatter block when false. That
check conflated 'no frontmatter' with 'unparseable frontmatter' (both
parse to {}), so a document with a merge-conflict marker or refused
alias in its frontmatter had that block silently dropped on write.
Each site now preserves the exact raw bytes stripFrontmatter removed
when the marker is set, leaving the genuinely-empty case unchanged.

Refs #3881

* test(#3881): consequence and boundary coverage for the js-yaml migration

Rows: A1 emptyValuedKeySurvivesAWrite, A2 unparseableDocumentKeepsItsFrontmatterBlock, A3 unparseableIsDistinguishableFromEmpty, A4 nonScalarValuesCanonicalize, A5 truncationProbeStillFiresOnAnOpenFence, A6 commentsStayOnTheirOwnKey, A7 anchorsAndAliasesAreRefused, A8 aliasExpansionCannotExhaustMemory, F1 UNTERMINATED_KEY_THRESHOLD boundary, F2 alias/nesting refusal bound, F3 frontmatter size boundary (huge-bounded.md + larger). Adds tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md and its entry in the feat-3594 fixture matrix.

Refs #3881

* docs(#3881): document the vendored parser, correct a stale rationale, add a vendoring how-to

Refs #3881

* docs(#3881): correct the frontmatter glossary entry

Two errors in the entry as first written: it named parseYamlRegion as part of
the read path when that function is deleted, and it recorded the eight
hasFrontmatter call sites as unwired follow-on work when they were wired in
e35ac2a2c. Also records the scope caveat that the CLI write path rebuilds the
frontmatter block independently, so the marker binds at the transform layer.

Refs #3881

* docs(#3881): record the semantic-migration decision and the counted guard ledger

The maintainer chose the full semantic migration over splitting the rule into
its own epic or patching the scanner, so section 8.1 is answered as "the fork
was ill-posed and the migration is semantic" rather than as (a) or (b).

Also replaces the pre-implementation guess that this phase would shrink the
guard surface with the counted result: excluding vendored third-party lines the
hand-maintained surface is net +307, and frontmatter.cts grew by 68 lines
despite four functions being deleted, because the compatibility layer over
js-yaml is larger than the scanner it replaced. Section 8.1's stated benefit is
therefore not delivered as written; what improved is the kind of code
maintained, not the amount. Decision 6 requires recording that rather than
netting it away.

Refs #3881

* chore(#3881): changeset for the vendored YAML parser migration

Refs #3881

* test(#3881): golden parity, round-trip property and packaging coverage

Refs #3881

* fix(#3881): refuse anchors structurally and fold in review findings

ADR-3473 §8.1 review findings, addressed inline:

Finding 1 (BLOCKER): refuseAnchorsAndAliases was a raw-line regex that matched
only the bare-key spelling (key: &x). A quoted key ("a": &x), a flow mapping
({b: &x}) and a flow sequence ([&x, *x]) all define/use the SAME anchor
mechanics while never matching that line shape, so the exact expansion the
guard exists to stop went straight through unrefused (a 303-byte quoted-key
bomb expanded to ~35.8MB). Replaced with js-yaml's own `load` `listener`
callback, which reports `state.anchor` for every event belonging to an
anchored node in every spelling, and throws from inside the callback to abort
before any expansion (~1-2ms vs full expand-then-discard). A merge key with
an alias is still refused (merge always requires a previously anchored node,
so the alias itself trips the listener); a bare merge key with NO alias is no
longer separately refused, documented as intentional: FAILSAFE_SCHEMA never
resolves `!!merge`, so it carries no expansion risk. Table-driven tests added
for all four bypass spellings + merge key, plus a quoted-key-spelled
billion-laughs fixture registered in the adversarial matrix and README.

Finding 2: src/vendor/js-yaml.d.cts's docblock falsely claimed anchors/
aliases were "simply UNREACHABLE from typed code" through the twin. Corrected
to state the truth: anchor/alias resolution is document-level `load`
mechanics reachable through exactly the declared surface, and refusal is
enforced at RUNTIME (Finding 1's listener), not by the type surface.

Finding 3 (MAJOR): the null-byte sentinel (U+E000) round-trip was
non-injective — restoreNullBytesDeep rewrote every U+E000 in the parsed tree
back to NUL, including one the document author legitimately wrote, silently
corrupting it. Now refuses outright whenever the raw region already contains
U+E000 (consistent with the existing anchor/merge-key refusal path), making
the substitution provably injective. Tests added for a real NUL alone
(preserved), a pre-existing U+E000 alone (refused, not corrupted), and both
together (refused, not merged into one byte).

Finding 4 (MAJOR): scripts/lint-vendored-deps.cjs's `srcTwin` field was dead
for a hand-authored row (only read inside the upstream-verbatim branch) —
exactly how Finding 2's stale docblock drifted unnoticed. Added
checkHandAuthoredTwin: every value-level export the twin DECLARES must be an
actual own property of the vendored runtime module at require-time. Tests
added, including a sensor that a declared-but-nonexistent export IS caught.

Finding 5: the existingFm/hasFrontmatter/stripFrontmatter/fmPrefix/
unparseableFm/reassemble preamble, copy-pasted at 7 sites in
state-transition.cts plus a sixth hand-inlined copy in state.cts's
cmdStateCompletePhase, is now one exported helper
(beginFrontmatterReassembly) every site routes through, including the
hand-inlined one. Three call sites (beginPhaseCore, patchCore, updateCore)
keep a literal `body = stripFrontmatter(content)` assignment alongside the
helper call so scripts/lint-state-write-path-drift.cjs's single-hop backward
scan (which does not chase aliases) still sees the strip; stripFrontmatter is
pure/idempotent so the extra call changes nothing observable.

Finding 6: corrected the frontmatter.cts docblock's stale "wiring is a
separate change" claim (the 8 call sites are wired on this branch) and the
changeset's backlink from (#3473) to (#3881).

Finding 7: fixed the lint:ci failures blocking the gate — an
@typescript-eslint/only-throw-error violation from throwing a bare Symbol as
the anchor-detected signal (now a real Error subclass), unused-var warnings
left over from the Finding 5 refactor, a lint-test-file-count cap exceeded by
two migration-specific test files (allowlisted with justification), and the
lint-state-write-path-drift false positive from Finding 5's helper (fixed
above). tests/frontmatter-golden-parity.test.cjs:117's execFileSync already
carried an explicit timeout; no change was needed there.

Golden fixture: added a golden entry for the new
anchor-alias-bomb-quoted.md fixture ({} — matches what the legacy line
scanner would also produce, since it independently dropped every quoted
top-level key). No other corpus document diverges: real .planning/ documents
carry zero anchors/aliases/merge keys/U+E000 today.

Refs #3881

* fix(#3881): fold in second-round review findings

Finding 1 (BLOCKER): tests/frontmatter.test.cjs pinned the pre-migration
ASCII-only key regex for the Unicode fixture; updated to require the 相
key's value now that js-yaml has no such restriction. Audited the rest of
the file for other pre-migration pins (block scalars, quoted keys,
flattened values, empty values, duplicate keys, unclosed blocks, null
bytes) by execution against real fixtures; found none regressed.

Finding 2: parseYamlRegion and escapeDoubleQuoted renamed to
parseGuardedYamlRegion and escapeDoubleQuotedScalar in src/frontmatter.cts
so no function still answers to the deleted hand-rolled scanner's name
(ADR-3473 §8.1 "deleted, not patched"). escapeDoubleQuotedScalar's three
external call sites (src/commands.cts, src/runtime-artifact-conversion.cts)
updated in the same change — a mechanical rename, not an ADR-amendment
matter.

Finding 3 (BLOCKER): fixed a real crash and a silent data-loss bug found
by execution. A top-level key named constructor/__proto__/toString/
valueOf/hasOwnProperty crashed reconstructFrontmatter (bracket read
resolving an inherited Object.prototype member); a key literally named
__proto__ was silently DROPPED entirely (bracket assignment on an
ordinary {} invoked the inherited __proto__ setter instead of creating a
data property). Fixed by building every parsed Frontmatter object with
Object.create(null), and replacing an `in` check with hasOwnProperty.call
in propagateCommentChannel. Added round-trip tests for all five hostile
keys, each with its own leading comment.

Finding 4 (MAJOR): escapeDoubleQuotedScalar's docstring falsely claimed
full byte-stability across the migration. Verified by execution: BEL/NUL/
NEL/NBSP/LS/PS/BOM now emit YAML-named escapes instead of the old hex/raw-
literal forms. Proved round-trip equivalence (each escape re-parses to the
exact source codepoint) and corrected the docstring. Found and fixed a
related real defect while verifying: a lone UTF-16 surrogate was emitted
BARE (scalarNeedsDoubleQuoting didn't trigger), producing genuinely
unparseable YAML that silently collapsed to {} on re-read — extended
scalarNeedsDoubleQuoting to route surrogates through the quoted+escaped
path.

Finding 5 (MAJOR): countKeysBeforeTruncation went silent on 4 real
truncation shapes (unquoted colon, open flow collection, mis-indented
sibling key, refused anchor). Root cause: the mark-based prefix recovery
excluded the very line whose key needed counting, and a mark-less refusal
never entered the recovery branch at all. Fixed by taking the max of two
lower bounds: the longest parser-verified line-prefix, and a raw-text
count of key-shaped lines (reusing the same key-shape pattern this file
already uses for isFrontmatterShaped). Extended test-matrix row A5
table-driven over all 4 regressed shapes.

Finding 6: the design doc's claim that no test owned the #3594 adversarial
fixture corpus was false — consolidation epic #1969 had already folded it
into tests/frontmatter.test.cjs. An earlier commit on this branch
re-created a standalone duplicate under that false premise; folded its
genuinely-new coverage (fixture-ownership check, anchor-bomb fixtures,
block-scalar B1/B2 rows) into frontmatter.test.cjs and deleted the
duplicate file. Corrected the false claims in 40-design.md §3.3.1 and the
ADR's §8.1 note, including the roadmap-sibling claim (no such file exists).

Finding 7: the golden serializer sorted object keys, making it structurally
blind to the key-order-parity invariant ADR-3473 §8.1 actually claims.
Made it order-preserving and regenerated the golden fixture from a
standalone compile of the legacy (pre-#3881) parser at ddde001af; the
current parser matches it with zero undocumented divergences, confirming
key-order parity genuinely holds. Extended row A2 table-driven across 6 of
the remaining 7 transitionCore kinds (all pass) plus documented, by
execution, a newly-discovered 8th-site regression: state.cts's
cmdStateCompletePhase calls the same preservation helper but its result is
clobbered by a later unconditional resync — filed as a distinct finding
rather than fixed here (touches syncAndPreserveStateMd, outside this
change's verified scope).

Refs #3881

* fix(#3881): preserve unparseable frontmatter through the CLI write path

Characterization (executed, before/after shown): case (b), not (a). The
frontmatter FENCE survives — `state complete-phase` on a conflict-marked
STATE.md returns success and a well-formed, freshly-derived frontmatter
block, not a document with no frontmatter at all. But the block's actual
content (the merge-conflict markers, and with them any signal to a human
that the document was in conflict) is silently discarded and replaced.

Root cause was two clobber sites, not one:

1. syncStateFrontmatter (src/state.cts) re-parses the already-preserved
   `transformedContent` from readModifyWriteStateMd, finds {} + the
   FRONTMATTER_UNPARSEABLE marker, and unconditionally rebuilt a fresh
   frontmatter block from the body anyway.
2. Even after (1) is fixed, applyPostSyncPreservation's own
   postFm/applyStatePreservation/authoritativeFm-reassertion machinery
   re-extracts frontmatter from syncedContent, restores curated fields
   from the pre-write snapshot, and reconstructs a NEW block again —
   confirmed live via `state begin-phase`, which still lost the markers
   after fixing (1) alone.

Both are now guarded by the same predicate (isUnparseableFrontmatter,
checking FRONTMATTER_UNPARSEABLE): when the ORIGINAL frontmatter did not
parse and the caller is not on ADR-3408 §8.3's closed "body wins" list,
both functions return their input content unchanged rather than
re-deriving over it. The closed list (cmdStateSync #905,
/gsd-health --repair's REGENERATE_STATE, both routed only through
writeStateMd, which never reaches applyPostSyncPreservation and passes
sanctionedPermanentEmptyFallback=true to syncStateFrontmatter) is
untouched — neither widened nor narrowed; verified by execution that
`state sync` still overwrites the conflict-marked block exactly as before.

Other verbs sharing the same readModifyWriteStateMd path were checked and
were equally affected before this fix: state update, query state.patch,
and state begin-phase all lost the conflict markers (RED, shown by
execution), and all three now preserve them (GREEN). Covered table-driven
in tests/feat-3881-yaml-parser-consequences.test.cjs's new A2b describe
block, which drives the real CLI verbs via runGsdTools — not just the pure
transitionCore layer the earlier A2 rows exercised — plus a control
asserting state sync's body-wins contract is unchanged.

Refs #3881

* fix(#3881): restore the parse surface's prototype and fix remote-runner failures

Root cause of the bulk of the 88 remote-runner failures: extractFrontmatter/parseGuardedYamlRegion handed back Object.create(null) trees for prototype-pollution safety, but assert.deepStrictEqual compares prototypes, so every assertion against a plain object literal failed (57 frontmatter.unit.test.cjs + 5 frontmatter.test.cjs + others). Fixed by keeping the internal construction null-prototype (unchanged) and converting to a plain-prototype tree via Object.defineProperty (never bracket assignment, so __proto__/constructor/toString keys stay safe) at the parseGuardedYamlRegion/unparseableResult return boundary only; the internal FULL_LINE_COMMENTS Symbol channel is copied by reference, not recursed, so its own __proto__-safety is untouched.

Per-class fixes: (1) bomAcrossArtifactTypes was the same prototype bug, no separate code change needed. (2) frontmatter-cli #1660: added objectListFieldWouldLoseData, a broader lossy-field detector alongside the existing byte-identical noOpObjectListSetError -- js-yaml's flattenObjectListItem now correctly includes every sub-key of an object-list item (a real bug fix over the legacy scanner, which silently dropped every field but the first), so a set that drops that now-included data is no longer byte-identical to the original and needs its own guard. (3) uat.test.cjs: updated the pinned expectation for the human_verification quote-stripping artifact -- js-yaml resolves quoting correctly where the legacy regex left an unbalanced quote; documented as an intentional, non-lossy behavior change. (4) smart-entry: added a fallback-only loadWithAmbiguousColonRepair so a column-0 key: value line whose value itself contains an unquoted colon (the #2571 hand-edited-STATE.md shape) round-trips instead of failing the whole frontmatter block closed. (5) frontmatter.unit.test.cjs bracket-array leniency: added a second fallback, repairMalformedInlineArrays, restoring the legacy scanner's tolerant inline-array handling (consecutive/blank commas, unclosed bracket) -- both repairs run ONLY after the primary parse already threw, so well-formed documents are unaffected. (6) prompt-injection-scan: src/frontmatter.cts had a literal U+FEFF BOM embedded in a comment illustrating the #2977 fix; replaced with the U+FEFF text escape. (7) eslint-glob-coverage: allowlisted the new src/vendor/js-yaml.d.cts vendored type declaration, same precedent as the existing re2js.d.cts entry. (8) frontmatter-golden-parity: git ls-files *.md now runs with -c safe.directory=* (process-scoped) so it survives the remote runner's dubious-ownership check without a persistent git config write.

Refs #3881

* chore(#3881): backfill changeset PR number

Refs #3881

* test(#3881): make golden parity resistant to unrelated tree churn

A corpus-wide snapshot keyed to every tracked *.md file was coupled to mutable-by-design files: .changeset/*.md's pr:0 -> real-PR-number backfill is a required workflow step, not a parser change, yet it turned this suite red. Training people to 'just regenerate the golden' on that kind of failure defeats the point of the snapshot. Exclude .changeset/** from the golden corpus entirely, tolerate tracked *.md files with no golden entry (they postdate the capture) instead of failing on them, keep hard failures for a golden entry whose file has vanished from the tree and for any real parity divergence, and add a coverage floor so the enumeration cannot quietly degrade to comparing a handful of files. Golden regenerated by recompiling the legacy pre-migration parser (git show ddde001af:src/frontmatter.cts) standalone, independent of the current parser, over the same non-changeset corpus.

Refs #3881

* test(#3881): make the parser golden hermetic instead of tree-keyed

This repo merges ~21 commits/day; a 14-day sample measured 937 touches of the
exact files (commands/gsd/*.md, gsd-core/workflows/*.md, agents/*.md,
docs/*.md) the prior golden pinned by tracked path. Any PR editing one of
those files' frontmatter for reasons unrelated to the parser (an
argument-hint addition, an allowed-tools tweak) turned the suite red, and the
reflex fix -- "regenerate the golden" -- overwrote the very snapshot meant to
catch a real regression. Excluding .changeset/** was not enough; the design
itself was wrong: a regression fixture must not be keyed to mutable repo
paths, and a single 376-entry JSON every such PR touches is also a
guaranteed merge-conflict surface.

Rebuilt the fixture to carry its own documents: each of 51 entries stores a
stable id, literal documentText (shrunk from a real ddde001af-era corpus
document), and an expectedParse captured independently from the
pre-migration legacy parser (git show ddde001af:src/frontmatter.cts,
compiled standalone against its byte-identical sibling modules). The test
reads no tracked path, shells out to no git command, and enumerates no tree
-- a PR editing commands/gsd/help.md cannot affect it. Every entry's
reconstruction was verified at capture time to reproduce both the current
and legacy parser's output on the original document; 0 of 51 candidates
were dropped by that check (1, the deliberately-unterminated
unclosed-block.md adversarial fixture, has no closing fence to truncate at
and is stored unshrunk). Kept the 5 documented DIVERGENCES rows (now
diverges:true entries) and the D2 order-preserving structural serializer
that keeps the comparison from passing vacuously; dropped the
tree-enumeration helpers, the coverage floor, the post-capture-skip logic,
and the vanished-file check -- all artifacts of the path-keyed design.

Refs #3881

* fix(#3881): resolve vendored-deps paths independently of cwd shape

Five rows in tests/lint-vendored-deps-manifest.test.cjs failed on
windows-latest CI: the test passed absolute scratch-file paths into
compareFiles()/checkRow(), whose helpers joined every input onto ROOT
via path.join(ROOT, rel), producing garbage when the input was already
absolute. It surfaced on windows-latest specifically because GitHub's
Windows runners checkout the repo on a different drive than TEMP, so
path.relative(REPO_ROOT, tmpFile) returned the absolute path unchanged
(no relative traversal is representable across drives) rather than the
relative form the test assumed. The remote gsd-test runner this repo
gates pushes on is Linux-only and could never have caught this;
GitHub CI's windows-latest job is the only signal that does, and it did.

Fixed the helper itself (scripts/lint-vendored-deps.cjs's new
resolvePath()) to treat an already-absolute input as absolute-in,
absolute-out instead of silently mis-joining it, and updated the test
to pass the scratch file's absolute path directly rather than relying
on a relative conversion that is not always representable. Kept every
mutation-sensor assertion intact and added coverage proving
resolvePath is a no-op for relative inputs and correctly passes
absolute ones through unchanged.

Refs #3881

* fix(#3881): warn when state sync regenerates over unparseable frontmatter

state sync (ADR-3408 §8.3's sanctioned regenerate path) correctly
overwrites an unparseable frontmatter block per its 'body wins'
contract — that overwrite behavior is unchanged here. The defect was
the silence: synced:true/exit 0 gave no signal that the existing
block (including git merge-conflict markers) could not be parsed and
was destroyed, per ADR-3473 §8.5 ('a derived conclusion may not be
reported as authoritative when the derivation dropped input it could
not resolve') and §8.4 ('failure is a value').

Adds a gsd: warning — ... (#3881) line on stderr, matching the
existing #3573 precedent, and surfaces the same disclosure in the
JSON result's existing changes[] array so a machine consumer sees it
too. Exit code and synced:true are left unchanged — sync did what its
contract says.

REGENERATE_STATE (/gsd-health --repair's sibling on the same
sanctioned-regenerate list) is DESTRUCTIVE-risk and unconditionally
refused by applyRepairs's dispatcher before runRepairAction ever runs
(src/health-diagnostic.cts), so it is not a live path today and is not
in scope for this fix.

Refs #3881

* fix(#3881): exit non-zero when a state command returns an error

Refs #3881

* chore(#3881): changeset for the state exit-code fix

Refs #3881

* fix(#3881): honor the documented --project-dir flag

Refs #3881

* revert(#3881): restore exit-0 result envelopes for state errors

Reverts 9638f2936 and its changeset. The change was wrong and the revert is
the correction.

This repo distinguishes two error mechanisms deliberately. error() in
src/io.cts writes to stderr and calls process.exit(1) -- the hard-failure
path. output({error: ...}) writes a JSON result envelope to stdout and returns
normally with exit 0. The reverted commit converted 23 result-envelope sites
into hard failures, which is a different contract, not a bug fix.

tests/state-contract.test.cjs's errorPathDoesNotPublish asserts the envelope
contract directly -- a failing command exits 0 with a JSON error envelope and
must not publish state.json -- and the remote matrix run caught it along with
four cases in the QA scenario walk. Thirteen tests in tests/state.test.cjs that
the original commit rewrote were encoding that real contract, not the bug it
claimed; they are restored.

Whether an error envelope on stdout with exit 0 is the right CLI design is a
genuine question, and it is section 8.4's rule ('failure is a value') with its
own phase. It is not something to flip inside this PR.

Refs #3881

* chore(#3881): backfill changeset PR number for the project-dir fix

Refs #3881

* test(#3881): keep the frontmatter mutation shard inside its time budget

The Stryker (frontmatter) shard hit the documented 15-minute (900s) shard
cap. Root cause is NOT row-level spawn overhead (contrast the #2790/
core-utils precedent): the three shard test files' own logic runs in
~413ms total (356+30+27ms) with all 392 assertions passing. Instead,
src/frontmatter.cts grew from ~825 to 1496 lines (+671/-187) migrating to
the vendored YAML parser, proportionally growing the mutant count Stryker
generates for gsd-core/bin/lib/frontmatter.cjs. Stryker's command runner
bills the full 'node --test <3 files>' invocation once per mutant, and
node:test's default per-file process isolation forks a child process for
each of the three files on every one of those invocations — pure fork
overhead multiplied by a much larger mutant population.

Fix: scripts/mutation-matrix.cjs COVERED.frontmatter now declares
isolation: 'none', and .github/workflows/mutation.yml passes
--test-isolation=${{ matrix.isolation }} (defaulting to 'process' — i.e.
unchanged behavior — for the other 8 shards, which were not individually
audited for cross-file state leakage under shared-process execution).
Measured locally via node:test's run() API on the exact 3-file set:
isolation:'process' took ~593ms vs isolation:'none' ~478ms for the same
392 passing assertions. The true CI-shard number can only be confirmed
on the GitHub Actions run (Stryker cannot run locally, and 'node --test'
is hard-blocked in this environment).

Refs #3881

* test(#3881): register the vendored-parser tests in the frontmatter mutation shard

stryker.config.mjs's own rule ("Keep this list in sync with the tests
arrays in scripts/mutation-matrix.cjs COVERED") was violated: #3881 grew
src/frontmatter.cts from ~825 to 1496 lines but its new tests
(tests/feat-3881-yaml-parser-consequences.test.cjs,
tests/frontmatter-golden-parity.test.cjs,
tests/frontmatter-roundtrip.property.test.cjs, and +167 lines in
tests/frontmatter.test.cjs) were never added to the frontmatter shard's
tests array, so Stryker's mutants in the new vendored-js-yaml adapter had
nothing constraining them. PR #3888 measured 55.8% against the 65 floor
(748 killed / 593 survived / 17 timeout) and the shard was separately
cancelled at 15m04s against the 15-minute per-shard cap.

Registers all four files (each earns its slot on evidence of a unique
constraining assertion, documented inline), gives the shard a
measured/projected 180-minute budget via a new per-module
timeoutMinutes field threaded through mutation.yml's job-level
timeout-minutes the same way isolation is threaded, and removes the
prior isolation:'none' override (re-measured at this file-set size, its
savings are within run-to-run noise, not worth the unaudited
cross-file-state-leakage risk).

Refs #3881

* feat(#3881): derive the mutation test list and ratchet the score floor

Refs #3881

* test(#3881): ratchet five stale mutation floors and close the frontmatter gap

Raised five module minScore floors per CI run 33012034388 (floor(achieved)-1):
config-schema 75.51%->74, prompt-budget 88.95%->87, context-composer 79.92%->78,
context-utilization 92.31%->91, active-workstream-store 87.42%->86. Updated both
scripts/mutation-matrix.cjs COVERED entries and tests/mutation-matrix-ratchet.test.cjs
RATCHET_BASELINE in the same diff per the ratchet's own contract.

Closed the frontmatter shard's 63.03%-vs-65 gap with new behavioral tests in
tests/feat-3881-yaml-parser-consequences.test.cjs, each paired with a documented
near-miss: frontmatterDeepEqual's array-order/length/type-mismatch/key-order
semantics (via spliceFrontmatter's no-op guard), scalarNeedsDoubleQuoting's
leading/trailing-whitespace and dash/surrogate triggers (via reconstructFrontmatter),
repairAmbiguousColonValues' already-quoted vs ambiguous-colon repair paths (via
extractFrontmatter), and the null-byte sentinel round-trip surviving at region
offset 1. Did not lower minScore.

Refs #3881

* test(#3881): decouple the ratchet test from real module floors

The CLI end-to-end rows in tests/mutation-score-ratchet.test.cjs hardcoded config-schema's real floor (52), which commit 973321541 legitimately ratcheted to 74 -- breaking a test pinned to the exact value the mechanism under test exists to change. Add an injectable --matrix seam to scripts/check-mutation-score-ratchet.cjs and point the CLI rows at a synthetic module + synthetic floor built via a temp fixture, so the rows are indifferent to any real module's floor moving while still exercising the same fail/pass behaviour.

Refs #3881

* refactor(#3881): parse must_haves with the vendored parser and drop re-implemented leniency

Refs #3881

* fix(#3881): restore the ambiguous-colon repair its hand-edited-STATE.md contract needs

A tracked-document sweep of 910 *.md files cannot see this dependent: repairAmbiguousColonValues's one real caller is user hand-edited STATE.md content that never lives in this repo's tree, only on end users' machines, and is pinned by tests/smart-entry.unit.test.cjs. Restores the function plus its post-throw fallback path (loadWithAmbiguousColonRepair) only; repairMalformedInlineArrays and splitLegacyInlineArrayItems stay deleted, reverified against the full frontmatter test shard. Adds a frontmatter-level regression row in tests/feat-3881-yaml-parser-consequences.test.cjs so the dependency is visible where the function lives.

Closes #2571
Refs #3881

---------

Co-authored-by: sim <sim@local>
2026-08-26 19:29:32 -04:00

2931 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* STATE.md Transition Module — ADR-1769.
*
* Phase 1 substrate: field-classification table, section constants, the pure
* `transitionCore` dispatch, and the `beginPhase` intent (migrating
* `cmdStateBeginPhase` in state.cts onto this seam).
*
* Sibling/super-module of the STATE.md Document Module (state-document.cjs):
* consumes its `stateExtractField` / `stateReplaceField` primitives. Body
* section headings live as constants here (single writer after migration).
*
* Pure core + injected I/O (ADR-1769 §3): the exported `transitionCore` is a
* pure function `(content, intent, deps) → result`; adapters that own locks,
* file I/O, and the disk-scan wrap it.
*/
// 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, 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';
import { escapeRegex } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateMdSchemaMod = require('./state-md-schema.cjs');
const { STATE_FIELD_SCHEMA } = stateMdSchemaMod;
type StateFieldSchema = stateMdSchemaMod.StateFieldSchema;
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter;
/**
* ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
* `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a
* frontmatter-fenced region exists but failed to parse (malformed YAML, or a
* refused anchor/alias/merge key)? A plain `Object.keys(existingFm).length >
* 0` check cannot distinguish that case from "no frontmatter block at all" —
* both parse to `{}` — so every `hasFrontmatter`-gated reassemble below would
* silently drop the raw frontmatter block on the next write. The marker is a
* non-enumerable-to-Object.keys Symbol key, so this check is additive and
* never fires for the genuinely-empty case.
*/
function isUnparseableFrontmatter(existingFm: Record<string, unknown>): boolean {
return (existingFm as unknown as Record<symbol, unknown>)[FRONTMATTER_UNPARSEABLE] === true;
}
/**
* ADR-3473 §8.1 (#3881): the exact bytes `stripFrontmatter` removed from the
* front of `content` to produce `strippedBody` — i.e. `content`'s raw
* frontmatter-fenced prefix, verbatim, whether or not it parsed. Reassembling
* with this prefix (instead of dropping it under `hasFrontmatter === false`)
* is what preserves an UNPARSEABLE frontmatter block across a write; it is a
* no-op difference from `content` itself when `strippedBody === content`
* (nothing was stripped).
*/
function rawFrontmatterPrefix(content: string, strippedBody: string): string {
return content.slice(0, content.length - strippedBody.length);
}
/**
* Shared frontmatter-strip-and-reassemble preamble (#3881 review, finding 5): the
* `existingFm` / `hasFrontmatter` / `stripFrontmatter` / `fmPrefix` / `unparseableFm` /
* `reassemble` block above was copy-pasted at every `*Core` transition below (and, before
* this change, hand-inlined a sixth time in `state.cts`'s `cmdStateCompletePhase` instead of
* importing `isUnparseableFrontmatter`/`rawFrontmatterPrefix`). One helper, one place to fix
* the frontmatter-preservation contract. `reassemble` is parameterized on the (possibly
* further-mutated) body rather than closing over it, matching every call site's existing
* usage — several reassign `body` after this preamble runs and reassemble the FINAL body,
* not the one captured here.
*/
export type FrontmatterReassembly = {
existingFm: Record<string, unknown>;
hasFrontmatter: boolean;
body: string;
fmPrefix: string;
unparseableFm: boolean;
reassemble: (b: string) => string;
};
export function beginFrontmatterReassembly(
content: string,
sourcePath?: string,
): FrontmatterReassembly {
const existingFm = extractFrontmatter(content, sourcePath) as Record<string, unknown>;
const hasFrontmatter = Object.keys(existingFm).length > 0;
const body = stripFrontmatter(content);
// ADR-3473 §8.1 (#3881): computed from the ORIGINAL content/body pair, before any caller
// reassigns `body` further — the captured prefix is always the exact bytes stripped from
// the ORIGINAL content, regardless of what the caller does with `body` afterward.
const fmPrefix = rawFrontmatterPrefix(content, body);
const unparseableFm = isUnparseableFrontmatter(existingFm);
const reassemble = (b: string): string =>
hasFrontmatter
? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}`
: unparseableFm
? `${fmPrefix}${b}`
: b;
return { existingFm, hasFrontmatter, body, fmPrefix, unparseableFm, reassemble };
}
// Stop predicate for section-body slicing: a level-2+ heading ends the section.
const STOP_H2_PLUS = (lv: number): boolean => lv >= 2;
// ----------------------------------------------------------------------------
// Field-classification table (ADR-1769 §4)
// ----------------------------------------------------------------------------
//
// Two-column shape per ADR: `{ source, preservation }`. Source alone is
// insufficient because two fields can share a source but need different
// preservation rules (e.g. `current_phase` and `last_activity` are both
// `derived-from-body`, but `current_phase` preserves-when-unchanged per #1230
// while `last_activity` always re-derives). Codex review of Phase 1 caught
// the collapsed-enum shape as a substrate defect that wouldn't survive
// Phases 2–7.
/**
* #3873 (ADR-3473 §8.8): the four closed vocabularies below moved to
* `src/state-md-schema.cts` — the leaf module `FIELD_CLASSIFICATION`'s
* projection is now derived from — and are re-exported here BY THE SAME NAME
* so no existing importer of this module needs to change (`state.cts`
* consumes them via `stateTransitionMod.FieldSource` etc., the namespace
* access pattern this module's plain `export type` already supported before
* this move). See `state-md-schema.cts` for the full ADR-3408 Decision 1
* ("Greenspun's Tenth Rule" — closed vocabulary, never an open predicate slot)
* docstring these four used to carry directly.
*/
export type FieldSource = stateMdSchemaMod.FieldSource;
export type FieldPreservation = stateMdSchemaMod.FieldPreservation;
export type FieldGuard = stateMdSchemaMod.FieldGuard;
export type FieldMergeStrategy = stateMdSchemaMod.FieldMergeStrategy;
export type FieldClassification = {
source: FieldSource;
preservation: FieldPreservation;
/** Closed vocabulary (see the comment above). Adding a member is an ADR-3408 amendment, not a table edit. */
guard?: FieldGuard;
/** Closed vocabulary. Same rule. */
mergeStrategy?: FieldMergeStrategy;
};
/**
* Single source of truth for "which fields win when frontmatter and body
* disagree". Transitions declare which body fields they touch; the core
* consults the table to apply the preservation policy uniformly.
*
* Adding a new STATE.md field = one row here, not 9 transition edits.
*
* Field set verified against `buildStateFrontmatter` (state.cts:1474) — every
* frontmatter key emitted there has a row here.
*
* Frozen null-prototype object: prevents prototype-pollution lookups
* (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
* function). Use `getFieldClassification()` for lookups.
*/
/**
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
* (`src/state-md-schema.cts`) rather than hand-maintained here. Byte-identical
* to the pre-#3873 literal table — same 19 keys, same key ORDER (walks
* `Object.keys(STATE_FIELD_SCHEMA)` directly; see that module's row-order
* comment for why this is the one projection allowed to do that), same
* per-row shape (`{source, preservation, guard?, mergeStrategy?}`, in that
* key order, `guard`/`mergeStrategy` present only when the schema row carries
* them — never as an `undefined` own-property), same frozen null-prototype
* container. Pinned by `tests/state-transition.test.cjs`'s
* `fieldClassificationProjectionMatchesTodaysTable`, whose comparand is
* today's literal copied VERBATIM into the test (never re-derived from this
* schema — see that test's own docstring on why a self-referential parity
* test proves nothing).
*/
export const FIELD_CLASSIFICATION: Readonly<Record<string, FieldClassification>> = Object.freeze(
Object.keys(STATE_FIELD_SCHEMA).reduce((acc, key) => {
const row: StateFieldSchema = STATE_FIELD_SCHEMA[key];
const projected: FieldClassification = { source: row.source, preservation: row.preservation };
if (row.guard !== undefined) projected.guard = row.guard;
if (row.mergeStrategy !== undefined) projected.mergeStrategy = row.mergeStrategy;
acc[key] = projected;
return acc;
}, Object.create(null) as Record<string, FieldClassification>),
);
/**
* Which BODY field feeds each frontmatter key.
*
* `FIELD_CLASSIFICATION` above answers "who wins when frontmatter and body
* disagree"; this answers "and what is the body one called". They are separate
* questions and this one is display/routing knowledge, not preservation policy,
* so it does not widen the ADR-3408-governed table.
*
* #3699: `state update stopped_at …` reported `Field "stopped_at" not found in
* STATE.md` — byte-identical to what a genuinely absent field reports. The key
* IS present; it is a projection of a body field, and the message pointed away
* from the route that works. Naming the source is what makes the two cases
* distinguishable.
*
* Transcribed from `buildStateFrontmatter` (`state.cts`), which is the real
* deriver. That makes this a SECOND copy of knowledge that already exists, so it
* ships with a parity test asserting this key set equals the body-derived key set
* the builder actually emits (CLAUDE.md → Generative Fix Divergence). Keys the
* builder derives from disk, an external file, or the clock have no body source
* and are deliberately ABSENT here rather than mapped to a lie.
*/
/**
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
* (`src/state-md-schema.cts`)'s `bodySource` field, in this EXPLICIT key
* order. This order is NOT `STATE_FIELD_SCHEMA`'s own row order filtered down
* to the body-sourced keys — the pre-#3873 literal already put `status`
* before `stopped_at`/`paused_at` here while `FRONTMATTER_KEY_TO_BODY_LABEL`
* (`src/state.cts`) put it AFTER them, i.e. the two pre-existing tables
* disagreed with each other's order too, and this projection must reproduce
* ITS table's order specifically. Byte-identical to the pre-#3873 literal —
* same 8 keys, same order, same frozen null-prototype container with frozen
* per-key arrays. Pinned by `tests/state-transition.test.cjs`'s
* `bodySourceProjectionMatchesTodaysTable`.
*/
const FRONTMATTER_BODY_SOURCE_KEY_ORDER = Object.freeze([
'current_phase',
'current_phase_name',
'current_plan',
'status',
'stopped_at',
'paused_at',
'last_activity',
'last_activity_desc',
] as const);
export const FRONTMATTER_BODY_SOURCE: Readonly<Record<string, readonly string[]>> = Object.freeze(
FRONTMATTER_BODY_SOURCE_KEY_ORDER.reduce((acc, key) => {
const row: StateFieldSchema = STATE_FIELD_SCHEMA[key];
acc[key] = Object.freeze([...(row.bodySource ?? [])]);
return acc;
}, Object.create(null) as Record<string, readonly string[]>),
);
/**
* The frontmatter keys whose body source lives inside `## Session`.
*
* #3374 established that these fields must be written where the reader reads
* them: `buildStateFrontmatter` harvests `Stopped At` / `Paused At` from the
* session section only, so a whole-body replace "lets a decoy `**Stopped at:**`
* line in an unrelated (e.g. archive) section absorb the refresh while the
* harvested session value stays stale" (`stateReplaceFieldInSession`'s own
* docstring). `updateCore` was still doing the whole-body replace.
*/
const SESSION_SCOPED_KEYS: ReadonlySet<string> = new Set(['stopped_at', 'paused_at']);
/**
* The `(primary, fallback)` label pair for a session-scoped frontmatter KEY.
*/
function sessionLabelsForKey(key: string): { primary: string; fallback: string | null } | null {
if (!SESSION_SCOPED_KEYS.has(key)) return null;
const labels = FRONTMATTER_BODY_SOURCE[key];
return { primary: labels[0], fallback: labels[1] ?? null };
}
/**
* The same pair, resolved from a BODY LABEL the caller named (`Stopped At`,
* `Stopped at`, `Paused At`). `null` for anything else.
*
* Deliberately does NOT accept a frontmatter key. An earlier cut resolved both
* spellings through one function and used it for the write, which made
* `state update stopped_at …` write the BODY line through the session writer —
* silently defeating the "frontmatter keys are not directly writable" contract
* this whole change exists to state, and reporting `updated: false` while having
* written. The write may only ever be reached by naming a body field.
*/
function sessionLabelsForBodyField(field: string): { primary: string; fallback: string | null } | null {
const key = frontmatterKeyForBodyField(field);
return key === null ? null : sessionLabelsForKey(key);
}
/**
* Would a session-scoped write actually land? Asks by attempting the real write
* with a throwaway value and seeing whether anything moved.
*
* Deliberately reuses the writer rather than re-deriving "where is the session
* section" — a separate scope check could disagree with the writer, and a
* presence check that disagrees with the write it guards is the whole bug class
* here. `stateReplaceFieldInSession` is replace-only and pure, so probing costs
* nothing and the result is discarded.
*/
function sessionSourceExists(body: string, labels: { primary: string; fallback: string | null }): boolean {
return stateReplaceFieldInSession(body, labels.primary, labels.fallback, '\u0000probe') !== body;
}
/**
* Own-property body-source lookup. `null` for a key with no body source (a
* disk/external/clock-derived key) and for anything not a frontmatter key.
*/
export function getFrontmatterBodySource(field: string): readonly string[] | null {
if (!Object.prototype.hasOwnProperty.call(FRONTMATTER_BODY_SOURCE, field)) return null;
return FRONTMATTER_BODY_SOURCE[field];
}
/**
* Reverse lookup: the frontmatter key a body field feeds, or `null`.
* Lets a failed body-field update name the frontmatter key that still carries a
* value (#3699 case D), instead of reporting a bare absence.
*/
export function frontmatterKeyForBodyField(bodyField: string): string | null {
const wanted = bodyField.trim().toLowerCase();
for (const key of Object.keys(FRONTMATTER_BODY_SOURCE)) {
if (FRONTMATTER_BODY_SOURCE[key].some((f) => f.toLowerCase() === wanted)) return key;
}
return null;
}
/**
* Own-property classification lookup. Returns `null` for unknown fields
* (including inherited prototype methods like `toString`/`valueOf`).
*/
export function getFieldClassification(field: string): FieldClassification | null {
if (!Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field)) return null;
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)
// ----------------------------------------------------------------------------
//
// Absorbs the post-sync preservation block that previously lived inline in
// `readModifyWriteStateMd` (state.cts). One pure, field-classification-table-
// driven implementation replaces the three drifting encodings (the RMW post-sync
// block, `syncStateFrontmatter`, and `cmdStateBuildFrontmatter`'s read-path
// copy). Every preserved field — progress, status, stopped_at, current_phase_name
// — is governed by its FIELD_CLASSIFICATION row, so a policy change is a one-row
// table edit rather than a per-call-site patch. Behavior is byte-identical to
// 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 = {
transaction: StateTransaction;
/** Post-`syncStateFrontmatter` frontmatter (the freshly recomputed one). */
postFm: Record<string, unknown>;
};
export type StatePreservationResult = {
postFm: Record<string, unknown>;
mutated: boolean;
};
/**
* Mutable, single-write dispatch context threaded through every policy
* executor below. `mutated` accumulates across the whole field loop (ADR-3408
* §8.1 — one executor per policy, sharing one result).
*/
export type PreservationCtx = {
postFm: 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;
};
/**
* ADR-3408 §8.2: an unenforced `preserve-when-unchanged` row throws. Both
* ends of this invariant are gsd-core's own source — a declared row the
* *caller code* forgot to wire via `bodyDeltas` — so it is a programming
* error, unreachable from any user document. The bright line, stated because
* conflating its two sides would be severe: a drifted, malformed, or
* unparseable user STATE.md NEVER reaches this throw (§8.5 governs that case
* with preserve-and-warn); this fires only when the *caller* omitted a
* `bodyDeltas` entry for a row the table itself declares. Getting this
* backwards turns every desynced project's `phase.complete` into a hard
* failure.
*/
function throwUnwiredRow(field: string): never {
const err = new Error(
`applyStatePreservation: preserve-when-unchanged row ${JSON.stringify(field)} reached the ` +
'executor with no wired ctx.bodyDeltas entry. This is an internal invariant violation (ADR-3408 ' +
'§8.2) — the caller (readModifyWriteStateMd) forgot to supply this field\'s body-source delta. ' +
'Add a bodyDeltas entry for this field per ADR-3408 §8.3, or remove the row from ' +
'FIELD_CLASSIFICATION if the field no longer needs this policy.',
) as Error & { code: string; field: string };
err.code = 'STATE_PRESERVATION_UNWIRED_ROW';
err.field = field;
throw err;
}
/**
* Executor for `preservation: 'preserve-when-unchanged'` (ADR-3408 §8.1). The
* #1230 delta heuristic: restore the pre-write frontmatter snapshot when this
* write did not change the field's body source, and the snapshot is a real
* (non-empty-after-trim) curated value the derived value should not clobber.
*
* Every row carrying this policy — status, stopped_at, current_phase_name,
* 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`).
*/
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-
// unchanged row unconditionally.
const delta = ctx.bodyDeltas ? ctx.bodyDeltas[field] : undefined;
if (!delta) throwUnwiredRow(field);
// 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.snapshot[field];
if (typeof snapshot !== 'string' || snapshot.trim().length === 0) return;
// 3. Closed-vocabulary guard: status's 'unknown' sentinel is never restored.
// Exact-match, case-sensitive — 'Unknown' is a real value and IS restored.
if (cls.guard === 'non-sentinel-unknown' && snapshot === 'unknown') return;
// 4. The body source changed this write → the freshly-derived value wins.
if (delta.pre !== delta.post) return;
// 5. Already correct → no-op (avoid a spurious `mutated=true`).
if (ctx.postFm[field] === snapshot) return;
// 6. Restore.
ctx.postFm[field] = snapshot;
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 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 {
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;
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 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
// completed after the plan count grew) — ratcheting UP only, never
// deriving downward (preserves the #3242 curated-progress protection
// for cases unrelated to plan-count growth, e.g. a deleted SUMMARY).
// percent also takes the derived value — the resync recomputed it from
// 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(curatedRecord)) {
if (key === 'total_plans' || key === 'total_phases' || key === 'percent') continue;
if (ratchetUpKeys.has(key)) {
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.
if (derivedNum > curatedNum) continue;
merged[key] = value;
} else {
merged[key] = value;
}
}
}
next = merged;
} else {
next = cloneCurated(curated);
}
if (preservedValuesEqual(ctx.postFm[field], next)) return;
ctx.postFm[field] = next;
ctx.mutated = true;
}
/**
* Executor for `preservation: 'preserve-if-placeholder'` (ADR-3408 §8.1).
* `milestone` and `milestone_name` both carry this policy in the table, and
* both rows dispatch into this SAME executor body — no branch is selected by
* field name (ADR-3408 §8.1). The body always restores the name+version pair
* together (#948/#2135), ignoring which of the two rows triggered the call;
* this is deliberately safe because the executor is idempotent: whichever
* row fires first either performs the restore (after which the second row's
* call recomputes against already-restored state and finds nothing left to
* do) or finds no placeholder to restore (in which case the second row's
* call, seeing the same unchanged inputs, reaches the same conclusion). Two
* dispatches per write converge to the identical single-pass result, so the
* field argument itself is unused here — it exists only to satisfy the
* shared executor signature every policy branch in the dispatch loop shares.
*/
function applyPreserveIfPlaceholder(_field: string, _cls: FieldClassification, ctx: PreservationCtx): void {
const MILESTONE_PLACEHOLDER = 'milestone';
const derivedName = ctx.postFm['milestone_name'];
const derivedLooksLikeName = typeof derivedName === 'string'
&& derivedName.length > 0
&& derivedName !== MILESTONE_PLACEHOLDER
&& !/^[\s—–:-]/.test(derivedName);
const snapshotName = ctx.snapshot['milestone_name'];
const snapshotNameIsReal = typeof snapshotName === 'string'
&& snapshotName.length > 0
&& snapshotName !== MILESTONE_PLACEHOLDER;
if (derivedLooksLikeName || !snapshotNameIsReal) return;
if (ctx.postFm['milestone_name'] !== snapshotName) {
ctx.postFm['milestone_name'] = snapshotName;
ctx.mutated = true;
}
const snapshotVersion = ctx.snapshot['milestone'];
if (
typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
ctx.postFm['milestone'] !== snapshotVersion
) {
ctx.postFm['milestone'] = snapshotVersion;
ctx.mutated = true;
}
}
/**
* Executor for `preservation: 'derive'`. Explicit no-op — the sync's
* freshly-derived value stands untouched. Naming this executor (rather than
* skipping `derive` rows by omission) is what makes ADR-3408 §8.2's throw
* decidable: "policy says do nothing" is now distinguishable from "nobody
* wired this", because every member of `FieldPreservation` reaches an
* executor.
*/
function applyDerive(_field: string, _cls: FieldClassification, _ctx: PreservationCtx): void {
// No-op by design — see docstring.
}
/**
* Pure, table-driven post-sync preservation (ADR-3408 §8.1). One loop over
* `FIELD_CLASSIFICATION`, dispatching on the row's `preservation` value —
* never on the field name. Mutates `postFm` in place to mirror the
* pre-#3468 inline block (which also mutated in place) and returns whether
* 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 = {
postFm: input.postFm,
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)) {
const cls = getFieldClassification(field);
if (!cls) continue;
if (cls.preservation === 'preserve-when-unchanged') {
applyPreserveWhenUnchanged(field, cls, ctx);
} else if (cls.preservation === 'preserve-always') {
applyPreserveAlways(field, cls, ctx);
} else if (cls.preservation === 'preserve-if-placeholder') {
applyPreserveIfPlaceholder(field, cls, ctx);
} else if (cls.preservation === 'derive') {
applyDerive(field, cls, ctx);
}
}
return { postFm: ctx.postFm, mutated: ctx.mutated };
}
// ----------------------------------------------------------------------------
// Body section constants (ADR-1769 §6 — single writer after migration)
// ----------------------------------------------------------------------------
/**
* Top-level STATE.md section headings (H2). Aligned byte-for-byte with the
* canonical template at `gsd-core/templates/state.md`. Sub-headings (H3) like
* `### Decisions` / `### Pending Todos` / `### Blockers/Concerns` live under
* `## Accumulated Context` and are not mutated by any Phase 1–7 transition;
* they will be added here if a future transition needs them.
*
* Verified against `gsd-core/templates/state.md` (codex Phase 1 review).
*/
export const STATE_MD_SECTIONS = {
projectReference: '## Project Reference',
currentPosition: '## Current Position',
performanceMetrics: '## Performance Metrics',
accumulatedContext: '## Accumulated Context',
deferredItems: '## Deferred Items',
sessionContinuity: '## Session Continuity',
} as const;
// ----------------------------------------------------------------------------
// Intent + deps + result types (ADR-1769 §3)
// ----------------------------------------------------------------------------
export type StateTransitionDeps = {
clock: { today: () => string; localToday: () => string; nowIso: () => string };
/**
* Roadmap content provider for transitions that re-derive milestone-wide
* progress from ROADMAP.md (completePhase). Optional: transitions that don't
* consult the roadmap ignore it. Injected (not imported) so the core stays
* pure and testable without disk I/O.
*/
roadmapProvider?: () => string | null;
/**
* Phase-inventory provider for `rebuild` (ADR-1817 §2): re-derives the
* `## By-Phase Progress` table from canonical disk sources. Optional: when
* absent, `rebuild` skips table reconciliation entirely (Leaky-Abstractions
* guard — the core stays pure and testable without disk I/O).
*
* When present, MUST return a discriminated result, never a bare
* array-or-null: `{ ok: true, phases }` for a (possibly empty) successful
* scan vs `{ ok: false, reason }` for a scan that could not complete. A
* disk-scan failure is NOT the same as "no phases on disk" — collapsing
* both to the same value made a real failure indistinguishable from
* "nothing to reconcile" (issue #3057 B1); `reconcileByPhaseTable` treats
* the two differently and surfaces `ok:false` to the caller instead of
* silently no-op'ing.
*/
phaseInventoryProvider?: () => PhaseInventoryResult;
/**
* Resolved path of the STATE.md the content was read from. Injected (not
* derived) so the core stays pure; used only to name the file in an
* unusable-input frontmatter diagnostic (#1882). Optional: existing
* callers and test stubs are unaffected when absent.
*/
sourcePath?: string;
};
/**
* One on-disk phase record (ADR-1817). The `rebuild` transition consumes
* these to re-derive the `## By-Phase Progress` table; the adapter wires
* this to the same disk scan `buildStateFrontmatter` uses.
*/
export type PhaseInventoryRecord = {
number: string;
name: string;
planCount: number;
summaryCount: number;
};
/**
* Discriminated result of a `phaseInventoryProvider` disk scan (issue #3057
* B1). `ok:true` covers a completed scan, including the genuinely-empty case
* (`phases: []` — no `.planning/phases/` directory exists yet). `ok:false`
* covers a scan that could not complete (e.g. the phases directory could not
* be read). The two are NOT interchangeable: only `ok:true` with an empty
* array means "nothing to reconcile"; `ok:false` means "unknown — do not
* treat as reconciled".
*/
export type PhaseInventoryResult =
| { ok: true; phases: PhaseInventoryRecord[] }
| { ok: false; reason: string };
export type StateTransitionIntent =
| { kind: 'beginPhase'; phaseNumber: string | number; phaseName: string | null; planCount: number | null }
| { kind: 'advancePlan' }
| {
kind: 'completePhase';
phaseNum: string;
/** Next phase number, or null when completing the final phase. */
nextPhaseNum: string | null;
/** Display name for the next phase (may be null when unknown). */
nextPhaseName: string | null;
/** True when this is the final phase in the milestone. */
isLastPhase: boolean;
planCount: number;
summaryCount: number;
}
| { kind: 'plannedPhase'; phaseNumber: string | number; phaseName: string | null; planCount: number | null }
| { kind: 'milestoneSwitch'; version: string; name: string }
| {
kind: 'milestoneComplete';
version: string;
/** Resolved runtime slash command for the Operator Next Steps hint (e.g. '/gsd:new-milestone'). */
nextMilestoneCommand: string;
}
| { kind: 'patch'; patches: Record<string, string> }
| { kind: 'update'; field: string; value: string }
| {
kind: 'prune';
/** Phases at or below this number are archived (currentPhase - keepRecent). */
cutoff: number;
}
| {
kind: 'sync';
/** Disk-derived plan count for the highest incomplete phase, or null. */
totalPlansInPhase: number | null;
/** Recomputed progress percent (0-100), or null when it must be left untouched (#1761). */
percent: number | null;
}
| {
kind: 'rebuild';
};
// Phase 7 closed the union for the 10 ADR-1769 intents. ADR-1817 adds `rebuild`
// as the 11th capstone transition (body-structure derivability contract).
export type StateTransitionResult = {
content: string;
updated: string[];
/** Intent-specific output (e.g. advancePlan returns {advanced, currentPlan, totalPlans}).
* Adapters read this to construct the CLI output shape. */
data?: Record<string, unknown>;
};
// ----------------------------------------------------------------------------
// transitionCore — pure dispatch (ADR-1769 §3)
// ----------------------------------------------------------------------------
/**
* Pure transition core. `(content, intent, deps) → result`.
*
* Discriminated-union dispatch via plain `switch` (ADR-1769 §2.7 Kernighan's
* Law: debuggability over conciseness; the substrate sets the pattern).
*
* Phases 2–7 add cases for the remaining 9 intent kinds. A missing case is
* a compile-time error (the function would not return on that path).
*/
export function transitionCore(
content: string,
intent: StateTransitionIntent,
deps: StateTransitionDeps,
): StateTransitionResult {
switch (intent.kind) {
case 'beginPhase':
return beginPhaseCore(content, intent, deps);
case 'advancePlan':
return advancePlanCore(content, deps);
case 'completePhase':
return completePhaseCore(content, intent, deps);
case 'plannedPhase':
return plannedPhaseCore(content, intent, deps);
case 'milestoneSwitch':
return milestoneSwitchCore(content, intent, deps);
case 'milestoneComplete':
return milestoneCompleteCore(content, intent, deps);
case 'patch':
return patchCore(content, intent);
case 'update':
return updateCore(content, intent);
case 'prune':
return pruneCore(content, intent);
case 'sync':
return syncCore(content, intent, deps);
case 'rebuild':
return rebuildCore(content, intent, deps);
}
}
// ----------------------------------------------------------------------------
// beginPhase — intent implementation (Phase 1)
// ----------------------------------------------------------------------------
/**
* Apply a `beginPhase` transition to STATE.md content.
*
* Phase 1 scope (this file): the Status field update only. Subsequent
* behaviors land via RED-GREEN cycles per the ADR-1769 migration plan:
* - Current Phase, Current Phase Name, Current Plan, Total Plans
* - Current Position section mutation
* - Idempotency guard (#3127)
* - Resume vs first-time branching
* - #1255 / #1257 format-detection parity
*
* Adapters that acquire the STATE.md lock and call this core live in
* state.cts and consume the existing `readModifyWriteStateMd` post-sync
* machinery (preserves the #1230 delta heuristic without re-implementing it).
*/
function beginPhaseCore(
content: string,
intent: { kind: 'beginPhase'; phaseNumber: string | number; phaseName: string | null; planCount: number | null },
deps: StateTransitionDeps,
): StateTransitionResult {
const updated: string[] = [];
// #1255: body-field replacements operate on body only (frontmatter stripped),
// not on the full content. The YAML `status:` key matches `^Status:\s*`
// before the body pipe-table row if full content is passed.
const { reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
// #3881 review, finding 5: `body` is deliberately a LITERAL `stripFrontmatter(content)`
// assignment here rather than the helper's own `body` (which the destructure above skips) —
// scripts/lint-state-write-path-drift.cjs's Axis 3 backward scan is a single-hop textual
// pattern match, not real dataflow, and only recognizes `body = stripFrontmatter(...)` written
// out at the call site. `stripFrontmatter` is pure and idempotent, so computing it here (in
// addition to the helper's own internal call) changes nothing observable.
let body = stripFrontmatter(content);
const today = deps.clock.localToday();
// Consult the field-classification table for the frontmatter keys this
// transition touches (codex Phase 1 review: "table not consulted by
// transitionCore"). The table tracks FRONTMATTER keys (lowercase: `status`,
// `current_phase`, `last_activity`); body field names like `Status` /
// `Current Phase` are aliases and aren't enforced here — they're driven by
// the first-time/resume branching below, which encodes the same rules.
// Phase 2+ will dispatch preservation based on this lookup.
for (const fmKey of ['status', 'current_phase', 'current_plan', 'last_activity']) {
const cls = getFieldClassification(fmKey);
if (cls === null) {
throw new Error(
`transitionCore beginPhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
`add a row per ADR-1769 §4 before touching it.`,
);
}
}
// Helper: try to replace a body field; push to `updated` on success.
// Body field names (Title Case: 'Status', 'Current Phase') are not in the
// table — they're body-side aliases of classified frontmatter keys.
const tryField = (name: string, value: string): void => {
const replaced = stateReplaceField(body, name, value);
if (replaced !== null) {
body = replaced;
updated.push(name);
}
};
// #3127 idempotency guard: if Status already contains "Executing Phase N" for
// the current phase number, this is a resume (e.g. --wave N continue). Skip
// the first-time-only fields so mid-flight state (Current Plan, Total Plans,
// Current Phase Name, Last Activity Description) is preserved.
// Extract from body (not full content) so the YAML `status:` key cannot
// shadow the body Status field (#1255).
const currentStatus = stateExtractField(body, 'Status') || '';
const isAlreadyExecuting = new RegExp(
`Executing Phase\\s+${escapeRegex(String(intent.phaseNumber))}\\b`,
'i',
).test(currentStatus);
// Status update (applies on both first-time and resume — Status is always refreshed).
tryField('Status', `Executing Phase ${intent.phaseNumber}`);
// Last Activity date — safe to refresh on resume (tracks when execute-phase ran).
tryField('Last Activity', today);
if (!isAlreadyExecuting) {
// First-time execution: set all progress fields.
tryField('Last Activity Description', `Phase ${intent.phaseNumber} execution started`);
tryField('Current Phase', String(intent.phaseNumber));
if (intent.phaseName) {
tryField('Current Phase Name', intent.phaseName);
}
tryField('Current Plan', '1');
if (intent.planCount) {
tryField('Total Plans in Phase', String(intent.planCount));
}
// **Current focus:** body text line (#1104).
const focusLabel = intent.phaseName
? `Phase ${intent.phaseNumber} — ${intent.phaseName}`
: `Phase ${intent.phaseNumber}`;
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
if (focusPattern.test(body)) {
body = body.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`);
updated.push('Current focus');
}
// ## Current Position section mutation (#1104, #1365).
// `locateCurrentPosition` (fence-aware, tokenizeHeadings-based) locates
// the section; mirrors state.cts:2261-2324 byte-for-behaviour.
body = mutateCurrentPositionFirstTime(body, intent, today, updated);
} else {
// Resume path: only update Last activity timestamp in Current Position
// (do not touch Plan:, Phase:, Status:, stopped_at, progress.percent).
body = mutateCurrentPositionResume(body, intent, today, updated);
}
// #2736: surface the #3127 resume decision so the adapter can drop its
// intent-first current_phase_name override on a resume — the core just
// preserved the mid-flight name, and an override would drift frontmatter
// away from the preserved body value.
return { content: reassemble(body), updated, data: { resumed: isAlreadyExecuting } };
}
// Local frontmatter type aliases matching frontmatter.cts so we can call
// reconstructFrontmatter without cross-module type re-exports.
type FrontmatterValue = string | string[] | Record<string, unknown>;
type Frontmatter = Record<string, FrontmatterValue>;
/**
* Find the `## Current Position` section, return its `{start, end}` byte
* offsets in `body` (end is exclusive — first byte of the next section or
* body.length). Returns `null` when the section is absent.
*
* ADR-1372 T6: tokenizeHeadings-based locator (fence-aware).
*/
function locateCurrentPosition(body: string): { start: number; end: number } | null {
const hs = tokenizeHeadings(body);
const idx = hs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
if (idx === -1) return null;
const h = hs[idx];
const lines = body.split('\n');
const hl = lines[h.line - 1];
const start = h.offset + hl.length + 1;
let end = body.length;
for (let j = idx + 1; j < hs.length; j++) {
if (STOP_H2_PLUS(hs[j].level)) {
// Exclude the newline that separates this section from the next
// heading. Walk back over a bare `\n`, then over a `\r` if one
// immediately precedes it (CRLF), so a CRLF document's slice does not
// retain a stray unpaired trailing `\r` (#3118).
let e = hs[j].offset;
if (e > 0 && body[e - 1] === '\n') {
e -= 1;
if (e > 0 && body[e - 1] === '\r') e -= 1;
}
// Clamp so the span can never invert (#3118 review): when the section
// is empty and the next heading follows with no blank line between,
// walking back over the newline(s) can land `e` before `start`. An
// inverted span makes every mutator's `body.slice(0, start) +
// sectionBody + body.slice(end)` reassembly duplicate the bytes in
// `[end, start)`. A zero-length span (`end === start`) is the correct
// representation of an empty-but-present section.
end = Math.max(e, start);
break;
}
}
return { start, end };
}
/**
* Return the body text of the `## Current Position` section, or `null` when it
* is absent. Reuses the fence-aware `locateCurrentPosition` locator (ADR-1372).
*
* Exposed so callers that must read a position field (e.g. `cmdStatePrune`,
* #1776) can scope extraction to the canonical section instead of the whole
* document — where `stateExtractField`'s pipe-table fallback could otherwise
* latch onto an unrelated `| Phase | N |` row elsewhere in STATE.md. This
* scopes the *caller*; the shared extractor is left broad for every other use.
*/
export function sliceCurrentPositionSection(body: string): string | null {
const span = locateCurrentPosition(body);
return span === null ? null : body.slice(span.start, span.end);
}
/**
* First-time ## Current Position mutation: update Phase / Plan / Status /
* Last activity lines. Mirrors state.cts:2261-2324 byte-for-behaviour
* (inline regex first, pipe-table fallback via stateReplaceField — #1257).
*
* F2 (#2245 review, MAJOR): a prior revision of this function used
* `collectSection`/`replaceSection` here, whose default `levelBounded: true`
* only stops the section at the next heading of level <= the opener's own
* level (H1/H2 for a `##`-opened section) — an H3+ subsection nested under
* `## Current Position` was NOT a stop boundary and got folded into
* `sectionBody`, so the field regexes below (which run with the `m` flag,
* matching ANY line start in the body) could clobber a same-named line
* inside that subsection (the #2130/#2067/#2080 truncation/clobber class).
* Restored to the fence-aware `locateCurrentPosition` locator (which stops
* at ANY heading level >= 2, `STOP_H2_PLUS` — H2 through H6) + manual splice,
* exactly matching the `mutateCurrentPositionResume`/
* `mutateCurrentPositionForAdvance` siblings below, both of which use
* `locateCurrentPosition` directly.
*/
function mutateCurrentPositionFirstTime(
body: string,
intent: { phaseNumber: string | number; phaseName: string | null; planCount: number | null },
today: string,
updated: string[],
): string {
const span = locateCurrentPosition(body);
if (span === null) return body;
let sectionBody = body.slice(span.start, span.end);
// Phase line — inline first, then pipe-table fallback (#1257).
const phaseLabel = `${intent.phaseNumber}${intent.phaseName ? ` (${intent.phaseName})` : ''} — EXECUTING`;
if (/^Phase:/m.test(sectionBody)) {
sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${phaseLabel}`);
} else {
const replaced = stateReplaceField(sectionBody, 'Phase', phaseLabel);
if (replaced !== null) sectionBody = replaced;
}
// Plan line.
const planValue = `1 of ${intent.planCount || '?'}`;
if (/^Plan:/m.test(sectionBody)) {
sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${planValue}`);
} else {
const replaced = stateReplaceField(sectionBody, 'Plan', planValue);
if (replaced !== null) sectionBody = replaced;
}
// Status line.
const statusValue = `Executing Phase ${intent.phaseNumber}`;
if (/^Status:/m.test(sectionBody)) {
sectionBody = sectionBody.replace(/^Status:.*$/m, `Status: ${statusValue}`);
} else {
const replaced = stateReplaceField(sectionBody, 'Status', statusValue);
if (replaced !== null) sectionBody = replaced;
}
// Last activity line. The inline value carries date + narrative.
const activityValue = `${today} — Phase ${intent.phaseNumber} execution started`;
if (/^Last activity:/im.test(sectionBody)) {
sectionBody = sectionBody.replace(/^Last activity:.*$/im, `Last activity: ${activityValue}`);
} else {
const replaced =
stateReplaceField(sectionBody, 'Last Activity', activityValue) ??
stateReplaceField(sectionBody, 'Last activity', activityValue);
if (replaced !== null) sectionBody = replaced;
}
updated.push('Current Position');
return body.slice(0, span.start) + sectionBody + body.slice(span.end);
}
/**
* Resume ## Current Position mutation: only update Last activity line
* (preserves Plan/Phase/Status — #3127). Mirrors state.cts:2329-2363
* byte-for-behaviour.
*/
function mutateCurrentPositionResume(
body: string,
intent: { phaseNumber: string | number },
today: string,
updated: string[],
): string {
const span = locateCurrentPosition(body);
if (span === null) return body;
let sectionBody = body.slice(span.start, span.end);
const resumeActivity = `Last activity: ${today} — Phase ${intent.phaseNumber} execution resumed (wave continue)`;
if (/^Last activity:/im.test(sectionBody)) {
sectionBody = sectionBody.replace(/^Last activity:.*$/im, resumeActivity);
updated.push('Last activity (resume)');
} else {
// Pipe-table format fallback (#1255).
const replaced =
stateReplaceField(sectionBody, 'Last Activity', resumeActivity) ??
stateReplaceField(sectionBody, 'Last activity', resumeActivity);
if (replaced !== null) {
sectionBody = replaced;
updated.push('Last activity (resume)');
}
}
return body.slice(0, span.start) + sectionBody + body.slice(span.end);
}
/**
* Update fields within the ## Current Position section for advancePlan.
* Mirrors `updateCurrentPositionFields` (state.cts:496) byte-for-behaviour:
* only replaces Status / Last Activity when the existing value is a known
* template default (Knuth invariant: preserve executor-authored values).
* Plan is always replaced (system-derived, never executor-authored).
*
* Cannot import `updateCurrentPositionFields` from state.cjs directly (circular
* dep: state.cjs → state-transition.cjs → state.cjs), so the mutation is
* inlined here using the same primitives.
*/
function mutateCurrentPositionForAdvance(
content: string,
fields: { phase?: string; status?: string; lastActivity?: string; plan?: string },
statusDefaults: string[] | null | undefined,
lastActivityDefaults: string[] | null | undefined,
): string {
const span = locateCurrentPosition(content);
if (span === null) return content;
let sectionBody = content.slice(span.start, span.end);
let mutated = false;
// #3395: Phase is always replaced when a caller passes it — system-derived,
// not executor-authored (same rule as Plan below). plannedPhaseCore uses
// this so the transition that declares phase N planned also owns the `Phase:`
// line the frontmatter resync and `state json` re-derive current_phase from;
// before, the line survived stale from a previous phase and every
// body-derived consumer kept reading it (#948 class).
if (fields.phase) {
if (/^Phase:/m.test(sectionBody)) {
sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${fields.phase}`);
mutated = true;
} else {
const replaced = stateReplaceField(sectionBody, 'Phase', fields.phase);
if (replaced !== null) { sectionBody = replaced; mutated = true; }
}
}
if (fields.status) {
const replaced = stateReplaceFieldIfTemplate(sectionBody, 'Status', statusDefaults, fields.status);
if (replaced !== null && replaced !== sectionBody) { sectionBody = replaced; mutated = true; }
}
if (fields.lastActivity) {
const replaced =
stateReplaceFieldIfTemplate(sectionBody, 'Last Activity', lastActivityDefaults, fields.lastActivity) ??
stateReplaceFieldIfTemplate(sectionBody, 'Last activity', lastActivityDefaults, fields.lastActivity);
if (replaced !== null && replaced !== sectionBody) { sectionBody = replaced; mutated = true; }
}
if (fields.plan) {
// Plan is always replaced — system-derived, not executor-authored.
if (/^Plan:/m.test(sectionBody)) {
sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
mutated = true;
} else {
const replaced = stateReplaceField(sectionBody, 'Plan', fields.plan);
if (replaced !== null) { sectionBody = replaced; mutated = true; }
}
}
if (!mutated) return content;
return content.slice(0, span.start) + sectionBody + content.slice(span.end);
}
// ----------------------------------------------------------------------------
// advancePlan — intent implementation (Phase 2)
// ----------------------------------------------------------------------------
/**
* Apply an `advancePlan` transition to STATE.md content.
*
* Parses Current Plan / Total Plans (legacy separate fields or compound
* "Plan: X of Y" format), increments the plan number, updates body fields
* and the ## Current Position section. When currentPlan >= totalPlans,
* takes the phase-complete branch (sets Status to "Phase complete — ready
* for verification") instead of advancing.
*
* Uses `stateReplaceFieldIfTemplate` (template-default-aware) to preserve
* executor-authored field values (Knuth invariant from cmdStateAdvancePlan).
*
* Returns `data.advanced` / `data.currentPlan` / `data.totalPlans` for the
* adapter to construct CLI output.
*/
function advancePlanCore(content: string, deps: StateTransitionDeps): StateTransitionResult {
const today = deps.clock.localToday();
// #1255: body-field replacements operate on body only (frontmatter stripped),
// not on the full content. The YAML `status:` key matches `^Status:\s*`
// before the body field if full content is passed (codex Phase 2 review:
// HIGH blocking finding — same pattern beginPhaseCore already handles).
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
let body = initialBody;
// Parse plan number — legacy first, then compound.
const legacyPlan = stateExtractField(content, 'Current Plan');
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
const planField = stateExtractField(content, 'Plan');
let currentPlan: number;
let totalPlans: number;
let useCompoundFormat = false;
if (legacyPlan && legacyTotal) {
currentPlan = parseInt(legacyPlan, 10);
totalPlans = parseInt(legacyTotal, 10);
} else if (planField) {
currentPlan = parseInt(planField, 10);
const ofMatch = planField.match(/of\s+(\d+)/);
totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
useCompoundFormat = true;
} else {
currentPlan = NaN;
totalPlans = NaN;
}
if (isNaN(currentPlan) || isNaN(totalPlans)) {
return { content: reassemble(body), updated: [], data: { error: true } };
}
const updated: string[] = [];
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
if (currentPlan >= totalPlans) {
// Phase-complete branch.
body = stateReplaceFieldIfTemplate(body, 'Status', statusDefaults, 'Phase complete — ready for verification') || body;
body = stateReplaceFieldIfTemplate(body, 'Last Activity', lastActivityDefaults, today) || body;
body = stateReplaceFieldIfTemplate(body, 'Last activity', lastActivityDefaults, today) || body;
body = mutateCurrentPositionForAdvance(body, {
status: 'Phase complete — ready for verification',
lastActivity: today,
}, statusDefaults, lastActivityDefaults);
updated.push('Status', 'Last Activity', 'Current Position');
return {
content: reassemble(body),
updated,
data: { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' },
};
}
// Normal advance branch.
const newPlan = currentPlan + 1;
let planDisplayValue: string;
if (useCompoundFormat) {
planDisplayValue = (planField as string).replace(/^\d+/, String(newPlan));
body = stateReplaceField(body, 'Plan', planDisplayValue) || body;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
body = stateReplaceField(body, 'Current Plan', String(newPlan)) || body;
}
body = stateReplaceFieldIfTemplate(body, 'Status', statusDefaults, 'Ready to execute') || body;
body = stateReplaceFieldIfTemplate(body, 'Last Activity', lastActivityDefaults, today) || body;
body = stateReplaceFieldIfTemplate(body, 'Last activity', lastActivityDefaults, today) || body;
body = mutateCurrentPositionForAdvance(body, {
status: 'Ready to execute',
lastActivity: today,
plan: planDisplayValue,
}, statusDefaults, lastActivityDefaults);
updated.push('Current Plan', 'Status', 'Last Activity', 'Current Position');
return {
content: reassemble(body),
updated,
data: { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans },
};
}
// ----------------------------------------------------------------------------
// completePhase — intent implementation (Phase 3)
// ----------------------------------------------------------------------------
/**
* Apply a `completePhase` transition to STATE.md content.
*
* Migrates the inline STATE.md transform that lived inside `cmdPhaseComplete`
* (phase.cts) onto the substrate. Owns the field-classification-governed body
* mutations: Current Phase (preserving the `of total` shape and phase name),
* Current Phase Name, Status (`All phases complete` on the last phase, else
* `Ready to plan` per ADR-2207), Current Plan (`Not started`), Last Activity + Description,
* and the Completed/Total Phases + Progress percent block (re-derived from the
* roadmap via the injected `roadmapProvider`).
*
* The adapter (`cmdPhaseComplete`) retains two concerns that are NOT pure field
* updates: `updatePerformanceMetricsSection` (a section table upsert) and
* `syncStateFrontmatter` (the disk-scan post-sync). It also retains the
* multi-file atomic transaction (`writePlanningFileSet`) that writes ROADMAP,
* REQUIREMENTS, and STATE together — `readModifyWriteStateMd` is not used here
* because STATE.md is committed atomically with the other two files.
*
* Behavior is byte-for-byte with the pre-migration `phase.cts:1671-1772` block
* (verified by characterization tests in tests/state-transition.test.cjs).
*/
function completePhaseCore(
content: string,
intent: {
kind: 'completePhase';
phaseNum: string;
nextPhaseNum: string | null;
nextPhaseName: string | null;
isLastPhase: boolean;
planCount: number;
summaryCount: number;
},
deps: StateTransitionDeps,
): StateTransitionResult {
const updated: string[] = [];
const today = deps.clock.localToday();
// Consult the field-classification table for the frontmatter keys this
// transition touches (same guard beginPhaseCore applies). A missing row is a
// substrate defect — fail loudly rather than silently re-encoding policy.
for (const fmKey of [
'current_phase',
'current_phase_name',
'status',
'current_plan',
'last_activity',
'last_activity_desc',
'stopped_at',
'progress',
]) {
const cls = getFieldClassification(fmKey);
if (cls === null) {
throw new Error(
`transitionCore completePhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
`add a row per ADR-1769 §4 before touching it.`,
);
}
}
// #1255: body-field replacements operate on body only (frontmatter stripped),
// so the YAML `status:` / `current_phase:` keys cannot shadow the body fields.
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
let body = initialBody;
// Current Phase — preserve the existing `of <total>` shape and the phase name
// in parens (mirrors phase.cts:1675-1697 byte-for-behaviour).
const phaseValue = intent.nextPhaseNum || intent.phaseNum;
const nextPhaseDisplayName = intent.nextPhaseName;
const existingPhaseField =
stateExtractField(body, 'Current Phase') || stateExtractField(body, 'Phase');
let newPhaseValue = String(phaseValue);
if (existingPhaseField) {
const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
if (totalMatch) {
const total = totalMatch[1];
const nameStr = nextPhaseDisplayName
? ` (${nextPhaseDisplayName})`
: nameMatch
? ` (${nameMatch[1]})`
: '';
newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
} else if (nextPhaseDisplayName) {
newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`;
}
}
const phaseAfter = stateReplaceFieldWithFallback(body, 'Current Phase', 'Phase', newPhaseValue);
if (phaseAfter !== body) {
body = phaseAfter;
updated.push('Current Phase');
}
// Current Phase Name — only written when a next-phase display name is known
// (#1743/#1695: classified curated/preserve-when-unchanged, so an absent
// name does NOT clear an existing curated value).
if (nextPhaseDisplayName) {
const after = stateReplaceField(body, 'Current Phase Name', nextPhaseDisplayName);
if (after) {
body = after;
updated.push('Current Phase Name');
}
}
// Status — `All phases complete` on the final phase (ADR-2207), otherwise
// `Ready to plan`. Milestone termination (`<version> milestone complete`) is
// owned solely by the milestone-close verb (milestoneCompleteCore).
const statusValue = intent.isLastPhase ? 'All phases complete' : 'Ready to plan';
const statusAfter = stateReplaceFieldWithFallback(body, 'Status', null, statusValue);
if (statusAfter !== body) {
body = statusAfter;
updated.push('Status');
}
// Current Plan — reset for the next phase.
const planAfter = stateReplaceFieldWithFallback(body, 'Current Plan', 'Plan', 'Not started');
if (planAfter !== body) {
body = planAfter;
updated.push('Current Plan');
}
// Last Activity — prefer the prose `Last activity:` line (date + narrative)
// when present, else the bold `Last Activity:` date field.
const lastActivityDescription = `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, transitioned to Phase ${intent.nextPhaseNum}` : ''}`;
if (/^Last activity:/m.test(body)) {
const after = stateReplaceField(body, 'Last activity', `${today} — ${lastActivityDescription}`);
if (after) {
body = after;
updated.push('Last Activity');
}
} else {
const after = stateReplaceField(body, 'Last Activity', today);
if (after) {
body = after;
updated.push('Last Activity');
}
}
const ladAfter = stateReplaceField(body, 'Last Activity Description', lastActivityDescription);
if (ladAfter) {
body = ladAfter;
updated.push('Last Activity Description');
}
// Stopped At — #3374: write the continuity line this transition implies.
// The frontmatter `stopped_at` is a projection of this body line
// (source: 'body' in FIELD_CLASSIFICATION), and phase completion is exactly
// the event the line describes — leaving it stale made the post-sync harvest
// overwrite a fresher frontmatter value with pre-completion prose on every
// completion (#3374), and left the workflow's later prose refresh as a
// divergence source. Session-SCOPED replace (stateReplaceFieldInSession):
// the harvest reads only the session section, so the write must target the
// same scope — a whole-body replace let a decoy `**Stopped at:**` line in an
// unrelated section absorb the refresh. Replace-only (no insertion): a
// STATE.md with no session continuity line keeps its shape, and the
// unchanged body source then lets the preservation delta keep an existing
// frontmatter value. Last-phase wording reuses the ADR-2207 status phrase;
// milestone termination wording stays owned by milestoneCompleteCore.
const stoppedAtLine = intent.isLastPhase
? `Phase ${intent.phaseNum} complete — all phases complete`
: `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, ready to plan Phase ${intent.nextPhaseNum}` : ''}`;
const stoppedAfter = stateReplaceFieldInSession(body, 'Stopped At', 'Stopped at', stoppedAtLine);
if (stoppedAfter !== body) {
body = stoppedAfter;
updated.push('Stopped At');
}
// Progress block — re-derive completed/total phases from the roadmap when
// available (milestone-wide source of truth), then recompute the percent.
// Only runs when a Completed Phases field exists (the existing guard).
const completedRaw = stateExtractField(body, 'Completed Phases');
if (completedRaw !== null) {
let newCompleted = parseInt(completedRaw, 10);
let derivedTotalPhases: number | null = null;
const roadmapContent = deps.roadmapProvider ? deps.roadmapProvider() : null;
if (roadmapContent) {
const derived = deriveProgressFromRoadmap(roadmapContent);
if (derived.completedPhases !== null) newCompleted = derived.completedPhases;
if (derived.totalPhases !== null) derivedTotalPhases = derived.totalPhases;
}
// #3057 B9: only mark 'Completed Phases' updated when the text actually
// changed. `stateReplaceField` returns the full (re-)substituted content
// whenever the field pattern matches, REGARDLESS of whether newCompleted
// differs from the value already in `body` — so a truthy-only check here
// marked the field 'updated' even when the roadmap was unavailable and
// newCompleted is just completedRaw parsed back to itself. That collapsed
// "recomputed from roadmap" and "left as-is" into the same `updated`
// signal. Comparing to `body` (the idiom every other field in this
// function already uses) restores the distinction.
const completedAfter = stateReplaceField(body, 'Completed Phases', String(newCompleted));
if (completedAfter !== null && completedAfter !== body) {
body = completedAfter;
updated.push('Completed Phases');
}
const totalRaw = stateExtractField(body, 'Total Phases');
const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null);
if (totalPhases && totalPhases > 0) {
const newPercent = clampPercent(newCompleted, totalPhases);
// Same guard as 'Completed Phases' above, and for the same reason.
const progAfter = stateReplaceField(body, 'Progress', `${newPercent}%`);
if (progAfter !== null && progAfter !== body) {
body = progAfter;
updated.push('Progress');
}
// Inline `percent:` token (frontmatter / progress sub-block).
body = body.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
}
}
return { content: reassemble(body), updated };
}
// ----------------------------------------------------------------------------
// plannedPhase — intent implementation (Phase 4)
// ----------------------------------------------------------------------------
/**
* Apply a `plannedPhase` transition to STATE.md content.
*
* Migrates `cmdStatePlannedPhase` (state.cts) onto the substrate. Updates the
* per-phase body fields after plan-phase runs: Status (template-aware — only
* replaces handler-generated values, preserving executor-authored ones),
* Total Plans in Phase, Last Activity (template-aware), Last Activity
* Description, and the ## Current Position section — including its `Phase:`
* line, which this transition owns (#3395: the line is the body source
* `current_phase` re-derives from, so it must not survive stale from a
* previous phase). The adapter wraps this in
* `readModifyWriteStateMd({ resync: false })` so the milestone-wide progress.*
* frontmatter is NOT re-derived from a half-planned disk snapshot (#500 RC1).
*
* Uses `mutateCurrentPositionForAdvance` (the inlined twin of state.cts's
* `updateCurrentPositionFields`) so the Knuth template-default invariant
* applies inside the Current Position section too.
*/
function plannedPhaseCore(
content: string,
intent: { kind: 'plannedPhase'; phaseNumber: string | number; phaseName: string | null; planCount: number | null },
deps: StateTransitionDeps,
): StateTransitionResult {
const updated: string[] = [];
const today = deps.clock.localToday();
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
const cls = getFieldClassification(fmKey);
if (cls === null) {
throw new Error(
`transitionCore plannedPhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
`add a row per ADR-1769 §4 before touching it.`,
);
}
}
// #1255: body-field replacements operate on body only.
const { existingFm, hasFrontmatter, body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
let body = initialBody;
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
// Status — template-aware (preserve executor-authored values).
const statusAfter = stateReplaceFieldIfTemplate(body, 'Status', statusDefaults, 'Ready to execute');
if (statusAfter !== null && statusAfter !== body) {
body = statusAfter;
updated.push('Status');
}
// Total Plans in Phase — system-derived; always replaced when a count is given.
if (intent.planCount !== null && intent.planCount !== undefined) {
const result = stateReplaceField(body, 'Total Plans in Phase', String(intent.planCount));
if (result) {
body = result;
updated.push('Total Plans in Phase');
}
}
// Last Activity — template-aware.
const lastActivityAfter = stateReplaceFieldIfTemplate(body, 'Last Activity', lastActivityDefaults, today);
if (lastActivityAfter !== null && lastActivityAfter !== body) {
body = lastActivityAfter;
updated.push('Last Activity');
}
// Last Activity Description.
const ladResult = stateReplaceField(
body,
'Last Activity Description',
`Phase ${intent.phaseNumber} planning complete — ${intent.planCount || '?'} plans ready`,
);
if (ladResult) {
body = ladResult;
updated.push('Last Activity Description');
}
// ## Current Position section — Phase + Status + Last activity.
// #3395: plannedPhaseCore owns the `Phase:` line for the same reason
// beginPhaseCore/completePhaseCore do — it is the body source the frontmatter
// resync and `state json` re-derive `current_phase` from. Before, a stale
// line from a previous phase survived this transition and every
// body-derived consumer kept reading it (the write path was already
// protected by the #3258 preserve-when-unchanged row; the source itself was
// never refreshed). The label mirrors beginPhaseCore's `N (Name) — EXECUTING`
// convention with this transition's status vocabulary ("Ready to execute").
// Phase is system-derived, always replaced (Knuth invariant does not apply);
// Status / Last activity stay template-aware.
const beforePos = body;
body = mutateCurrentPositionForAdvance(
body,
{
phase: `${intent.phaseNumber}${intent.phaseName ? ` (${intent.phaseName})` : ''} — READY TO EXECUTE`,
status: 'Ready to execute',
lastActivity: `${today} — Phase ${intent.phaseNumber} planning complete`,
},
statusDefaults,
lastActivityDefaults,
);
if (body !== beforePos) updated.push('Current Position');
// #2400 Bug B: sync progress.total_plans to the frontmatter when a plan count
// is given. This writes the explicitly-provided count — it is NOT a re-derivation
// from disk (#500 RC1 is about deriving from a half-planned snapshot, not about
// refusing to write an explicitly-passed argument).
if (intent.planCount !== null && intent.planCount !== undefined && hasFrontmatter) {
const fmProgress = (existingFm['progress'] as Record<string, unknown> | undefined) || {};
if (fmProgress['total_plans'] !== intent.planCount) {
fmProgress['total_plans'] = intent.planCount;
existingFm['progress'] = fmProgress;
updated.push('progress.total_plans');
}
}
return { content: reassemble(body), updated };
}
// ----------------------------------------------------------------------------
// milestoneSwitch — intent implementation (Phase 4)
// ----------------------------------------------------------------------------
/**
* Apply a `milestoneSwitch` transition to STATE.md content.
*
* Migrates `cmdStateMilestoneSwitch` (state.cts) onto the substrate. Resets
* STATE.md for a new milestone cycle: rewrites the frontmatter (milestone,
* milestone_name, status='planning', last_updated, last_activity, and the
* progress block zeroed) and rewrites the ## Current Position body to the
* "defining requirements" starting state. `gsd_state_version` is preserved.
* Body content OUTSIDE Current Position (e.g. Accumulated Context) is
* preserved.
*
* This is a destructive reset intent: it intentionally overwrites the curated
* `progress` (preserve-always) / `current_phase_name` (preserve-when-unchanged)
* fields because a new milestone starts from zero. That is the intent's
* contract, not a violation of the field-classification table — the table
* governs the steady-state RMW transitions; a milestone boundary is an
* explicit reset.
*
* The adapter wraps this in `acquireStateLock` + `platformWriteSync` (NOT
* `readModifyWriteStateMd`) because milestoneSwitch rebuilds frontmatter
* directly and must not run the steady-state `syncStateFrontmatter` post-sync.
*/
function milestoneSwitchCore(
content: string,
intent: { kind: 'milestoneSwitch'; version: string; name: string },
deps: StateTransitionDeps,
): StateTransitionResult {
const today = deps.clock.localToday();
const updated: string[] = [
'milestone',
'milestone_name',
'status',
'last_updated',
'last_activity',
'progress',
'Current Position',
];
const existingFm = extractFrontmatter(content, deps.sourcePath) as Record<string, unknown>;
const body = stripFrontmatter(content);
const resolvedName = (intent.name && intent.name.trim()) || 'milestone';
// ## Current Position reset body (mirrors state.cts:2371-2375).
const resetPositionBody =
`\nPhase: Not started (defining requirements)\n` +
`Plan: —\n` +
`Status: Defining requirements\n` +
`Last activity: ${today} — Milestone ${intent.version} started\n\n`;
let newBody: string;
const hs = tokenizeHeadings(body);
const posIdx = hs.findIndex((h) => h.level === 2 && /^current\s+position$/i.test(h.text));
if (posIdx !== -1) {
const h = hs[posIdx];
const lines = body.split('\n');
const hl = lines[h.line - 1];
const bodyStart = h.offset + hl.length + 1;
let bodyEnd = body.length;
for (let j = posIdx + 1; j < hs.length; j++) {
if (STOP_H2_PLUS(hs[j].level)) {
bodyEnd = hs[j].offset - 1;
break;
}
}
newBody = body.slice(0, bodyStart) + resetPositionBody + body.slice(bodyEnd);
} else {
const preface = body.trim().length > 0 ? body : '# Project State\n';
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
}
// Rebuilt frontmatter — curated fields are intentionally reset (milestone
// boundary). gsd_state_version is preserved.
const fm: Record<string, unknown> = {
gsd_state_version: existingFm['gsd_state_version'] || '1.0',
milestone: intent.version,
milestone_name: resolvedName,
status: 'planning',
last_updated: deps.clock.nowIso(),
last_activity: today,
progress: {
total_phases: 0,
completed_phases: 0,
total_plans: 0,
completed_plans: 0,
percent: 0,
},
};
const yamlStr = reconstructFrontmatter(fm as unknown as Frontmatter);
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
return { content: assembled, updated };
}
// ----------------------------------------------------------------------------
// milestoneComplete — intent implementation (Phase 5)
// ----------------------------------------------------------------------------
/**
* Replace a section's ENTIRE body with `newBody`, discarding whatever was
* there — the "wholesale reset" write pattern used by milestoneComplete's
* closure write (## Current Position / ## Operator Next Steps). Retires the
* fence-blind raw regex `(##\s*<heading>\s*\n)([\s\S]*?)(?=\n##|$)`, which a
* literal `##` inside a fenced code block in the section body could fool into
* stopping early (the #2130/#2067/#2080 truncation class) — heading location
* here goes through `tokenizeHeadings`, which is fence-aware.
*
* Byte-parity note: the retired regex's greedy `\s*` (before its mandatory
* `\n`) swallowed any blank line(s) immediately after the heading into the
* discarded match, and its non-greedy body match always left exactly ONE
* newline unconsumed before the next heading (or EOF), regardless of how many
* blank lines originally separated the section from what followed. Both
* edges are reproduced explicitly (rather than delegated to `collectSection`'s
* `trimEnd()`-based body, which trims a *different* amount and would drift
* the surrounding blank-line count) so `newBody`'s own leading/trailing
* formatting is exactly what appears in the output.
*
* Returns `null` when no heading matches `headingPredicate` (mirrors the
* retired regex's `pattern.test(body)` miss) — callers fall back to their own
* append-a-new-section path.
*/
function resetSectionVerbatim(
content: string,
headingPredicate: (heading: HeadingToken) => boolean,
newBody: string,
): string | null {
const headings = tokenizeHeadings(content);
const idx = headings.findIndex(headingPredicate);
if (idx === -1) return null;
const target = headings[idx];
const lines = content.split('\n');
const headingLineEnd = target.offset + lines[target.line - 1].length + 1;
// Swallow blank line(s) immediately after the heading (mirrors the retired
// regex's greedy `\s*` folding them into the discarded match).
//
// F7 (#2245 review, nit): recognise a CRLF blank line (`\r\n`), not only a
// bare LF — a lone `content[bodyStart] === '\n'` check never advances past
// a `\r` byte, so on a CRLF STATE.md the blank line right after the
// heading fell into the DISCARDED [bodyStart, bodyEnd) span instead of the
// KEPT prefix, silently dropping one blank line (contradicting this
// function's own byte-parity docstring).
let bodyStart = headingLineEnd;
while (bodyStart < content.length) {
if (content[bodyStart] === '\n') { bodyStart += 1; continue; }
if (content[bodyStart] === '\r' && content[bodyStart + 1] === '\n') { bodyStart += 2; continue; }
break;
}
// Stop at the next heading of level >= 2 (mirrors the retired regex's
// literal `##` lookahead, which matches any ATX heading two-or-more levels
// deep); leave exactly one newline unconsumed before it, or run to EOF.
let bodyEnd = content.length;
for (let j = idx + 1; j < headings.length; j++) {
if (STOP_H2_PLUS(headings[j].level)) { bodyEnd = headings[j].offset - 1; break; }
}
return content.slice(0, bodyStart) + newBody + content.slice(bodyEnd);
}
/**
* Apply a `milestoneComplete` transition to STATE.md content.
*
* Migrates the STATE.md write path inside `cmdMilestoneComplete` (milestone.cts)
* onto the substrate. Owns the closure write: Status (`<version> milestone
* complete`), Last Activity, Last Activity Description, a ## Current Position
* reset to the "Awaiting next milestone" state, and a ## Operator Next Steps
* reset pointing at the next-milestone command.
*
* The adapter (`cmdMilestoneComplete`) retains `writeStateMd` (the writer that
* owns the lock + steady-state syncStateFrontmatter post-sync) and resolves the
* runtime-specific next-milestone slash command, injecting it via
* `intent.nextMilestoneCommand` so the core stays pure.
*
* Behavior is byte-for-byte with the pre-migration milestone.cts:314-353 block.
*/
function milestoneCompleteCore(
content: string,
intent: { kind: 'milestoneComplete'; version: string; nextMilestoneCommand: string },
deps: StateTransitionDeps,
): StateTransitionResult {
const updated: string[] = [];
const today = deps.clock.localToday();
const version = intent.version;
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
const cls = getFieldClassification(fmKey);
if (cls === null) {
throw new Error(
`transitionCore milestoneComplete: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
`add a row per ADR-1769 §4 before touching it.`,
);
}
}
// #1255: body-field replacements operate on body only.
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
let body = initialBody;
// Status — `<version> milestone complete`.
const statusAfter = stateReplaceFieldWithFallback(body, 'Status', null, `${version} milestone complete`);
if (statusAfter !== body) {
body = statusAfter;
updated.push('Status');
}
// Last Activity.
const lastActivityAfter = stateReplaceFieldWithFallback(body, 'Last Activity', 'Last activity', today);
if (lastActivityAfter !== body) {
body = lastActivityAfter;
updated.push('Last Activity');
}
// Last Activity Description.
const ladAfter = stateReplaceFieldWithFallback(
body,
'Last Activity Description',
null,
`${version} milestone completed and archived`,
);
if (ladAfter !== body) {
body = ladAfter;
updated.push('Last Activity Description');
}
// ## Current Position reset — stop resume/progress flows pointing at closed
// execution instructions.
const closedPositionBody =
`\nPhase: Milestone ${version} complete\n` +
`Plan: —\n` +
`Status: Awaiting next milestone\n` +
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
const positionReset = resetSectionVerbatim(
body,
(h) => h.level === 2 && /^current\s+position$/i.test(h.text),
closedPositionBody,
);
if (positionReset !== null) {
body = positionReset;
} else {
body = `${body.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
}
updated.push('Current Position');
// ## Operator Next Steps — normalize stale tails that can persist after close.
const operatorReset = resetSectionVerbatim(
body,
(h) => h.level === 2 && /^operator\s+next\s+steps$/i.test(h.text),
`\n- Start the next milestone with ${intent.nextMilestoneCommand}\n\n`,
);
if (operatorReset !== null) {
body = operatorReset;
} else {
body = `${body.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${intent.nextMilestoneCommand}\n`;
}
updated.push('Operator Next Steps');
return { content: reassemble(body), updated };
}
// ----------------------------------------------------------------------------
// patch — intent implementation (Phase 6)
// ----------------------------------------------------------------------------
/**
* Apply a `patch` transition to STATE.md content.
*
* Migrates `cmdStatePatch` (state.cts) onto the substrate. Applies each
* caller-supplied `{field: value}` pair, resolved BODY-FIRST:
*
* - A key that resolves against the STRIPPED body (via `stateReplaceField`,
* case-insensitive on the field name) is applied there and reported
* `updated` — this is the legitimate, documented case (display-cased body
* fields — Status, Current Plan, Phase — which are never frontmatter
* keys). It wins deterministically even when the same key also happens to
* exist as a parsed frontmatter key (e.g. `status` matches both the
* frontmatter key and a `Status:` body line) — frontmatter is inert for
* that key.
* - Only when the body has no match is the key checked against parsed
* frontmatter (determined structurally, never by a naming heuristic), and
* routed through the seam: `FIELD_CLASSIFICATION` governs it. A CLASSIFIED
* key (has a row, e.g. `current_phase`, `current_phase_name`) is NOT
* writable by an arbitrary patch — policy owns it — and is reported
* `failed`. An UNCLASSIFIED key (no row, e.g. a custom `risk_level`) is a
* pass-through per Phase 1 behavior-table row 19 ("field absent from
* FIELD_CLASSIFICATION → untouched pass-through"): it is applied directly
* to the frontmatter object before reassembly and reported `updated`.
* - A key matching neither the body nor the frontmatter is reported `failed`.
*
* ADR-3408 §8.3(b): this used to run `stateReplaceField` over the FULL
* document (body + frontmatter), which — because `field` is an arbitrary,
* caller-supplied string, unlike every other `stateReplaceField` call site in
* this file, which passes a fixed Title-Case string literal that can never
* collide with a lowercase/snake_case YAML key — let a frontmatter-shaped
* patch key (e.g. `status`, `current_phase`) match and rewrite the YAML
* frontmatter block directly via `stateReplaceField`'s case-insensitive
* `^field:` line pattern, entirely outside `FIELD_CLASSIFICATION` and the
* write-seam preservation policy: a second, undeclared writer. The fix is
* that a CLASSIFIED frontmatter key no longer writes outside the declared
* policy table — not that every frontmatter-shaped key stops working.
* `.gsd/phase/refactor-3469-one-write-seam/40-design.md` row 9 requires
* frontmatter changes to route through the seam (still work, governed by
* FIELD_CLASSIFICATION), not to stop working outright. Body-shaped keys
* (`Status`, `Current Plan`, `Phase`, ...) are the LEGITIMATE case and are
* unaffected — they were always matched against the body text, and still are.
*
* The curated-field preservation that fixes #1743/#1695 is NOT in this core —
* it lives in the write seam's post-sync delta (table-driven via
* `current_phase_name`'s `preserve-when-unchanged` row, ADR-3408 §8.1 —
* reclassified from `preserve-always` in #3468 to match its long-standing,
* delta-gated behavior).
* `patch` consulting the table "refuses to overwrite" curated fields implicitly:
* when the patch does not change a curated field's body source line, the
* existing frontmatter value wins over the sync re-derivation. The adapter
* still owns field-name validation (security) and the resync-progress decision.
*
* `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
*/
function patchCore(
content: string,
intent: { kind: 'patch'; patches: Record<string, string> },
): StateTransitionResult {
const { existingFm, hasFrontmatter, fmPrefix, unparseableFm } = beginFrontmatterReassembly(content);
// #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
// literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
// Axis 3 single-hop backward scan.
let body = stripFrontmatter(content);
const fm: Record<string, unknown> = { ...existingFm };
const updated: string[] = [];
const failed: string[] = [];
for (const [field, value] of Object.entries(intent.patches)) {
// Body-first: a key that resolves against a body field is the
// legitimate, documented case (display-cased body fields — Status,
// Current Plan, Phase — are never frontmatter keys) and wins
// deterministically even when the same key also happens to exist as a
// frontmatter key (case-insensitively, via stateReplaceField's
// `^field:` pattern — e.g. `status` matching both the frontmatter key
// and a `Status:` body line). Frontmatter is only consulted when the
// body has no match for this key.
const replaced = stateReplaceField(body, field, value);
if (replaced !== null) {
body = replaced;
updated.push(field);
continue;
}
if (Object.prototype.hasOwnProperty.call(existingFm, field)) {
// Frontmatter-shaped key: route through the seam. A classified field
// is policy-owned — a raw patch may not bypass it. An unclassified
// field is an untouched pass-through (behavior-table row 19).
if (getFieldClassification(field) !== null) {
failed.push(field);
} else {
fm[field] = value;
updated.push(field);
}
continue;
}
failed.push(field);
}
if (updated.length === 0) {
// No field matched — return `content` VERBATIM (mirrors `updateCore`'s
// null-result branch): reassembling via stripFrontmatter/
// reconstructFrontmatter even when nothing changed can round-trip the
// frontmatter block to different bytes than the original (key order,
// formatting), which would falsely defeat `readModifyWriteStateMd`'s
// #948 no-op write guard for every patch that updates nothing, not just
// a frontmatter-shaped one.
return { content, updated, data: { updated, failed } };
}
const result = hasFrontmatter
? `---\n${reconstructFrontmatter(fm as unknown as Frontmatter)}\n---\n\n${body}`
: unparseableFm
? `${fmPrefix}${body}`
: body;
return { content: result, updated, data: { updated, failed } };
}
// ----------------------------------------------------------------------------
// update — intent implementation (Phase 7)
// ----------------------------------------------------------------------------
/**
* Apply an `update` transition to STATE.md content.
*
* Migrates `cmdStateUpdate` (state.cts) onto the substrate. A single-field
* body-only update (the field is replaced in the body; frontmatter is preserved
* as-is and re-synced by the adapter's `readModifyWriteStateMd` post-sync).
* Mirrors the pre-migration body-strip/reassemble contract.
*/
function updateCore(
content: string,
intent: { kind: 'update'; field: string; value: string },
): StateTransitionResult {
const { existingFm, hasFrontmatter, reassemble } = beginFrontmatterReassembly(content);
// #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
// literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
// Axis 3 single-hop backward scan.
const body = stripFrontmatter(content);
// #3699 review: session-scoped fields are written through the session-scoped
// writer. A whole-body `stateReplaceField` matches the FIRST occurrence
// anywhere, so with no `Stopped At:` line in `## Session` but a stale one in
// `## Session Continuity Archive`, `state update "Stopped At" …` reported
// `updated: true` while rewriting the ARCHIVE line and leaving both the session
// section and the `stopped_at` frontmatter key untouched — a silent corruption
// of a historical record reported as success. #3374 already established this
// rule for the other writer; this one had not adopted it.
const sessionWriteLabels = sessionLabelsForBodyField(intent.field);
let result: string | null;
if (sessionWriteLabels) {
// Replace-only by contract: unchanged content means the field is not in the
// session section, which is a miss, not a write.
const replaced = stateReplaceFieldInSession(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, intent.value);
result = replaced === body ? null : replaced;
} else {
result = stateReplaceField(body, intent.field, intent.value);
}
if (result === null) {
// #3699 case D — the frontmatter fallback.
//
// Normally frontmatter keys are NOT writable here: they are projections, and
// `buildStateFrontmatter` re-derives them from the body on every write, so a
// direct frontmatter write would be discarded. But when the body source line
// is absent entirely, there is nothing to derive FROM: the key's existing
// value survives on `preserve-when-unchanged`, and neither the frontmatter
// key nor the body field can be updated by any route. That document is
// unrepairable through `state update`, which is the gap this closes.
//
// Deliberately narrow — all three must hold:
// (1) the field is a frontmatter key with a known body source,
// (2) NO body source line exists, so the body route is genuinely unavailable
// (this is what keeps case A, where the body route works, routing to the
// body as before), and
// (3) the frontmatter already carries the key, so this updates a value that
// is really there rather than inventing one.
//
// The presence check in (2) is UNSCOPED on purpose, unlike the builder's
// `## Session` scoping for stopped_at/paused_at. The asymmetry is the safe
// direction: any `Stopped at:` line anywhere in the body — including one in an
// archive section — suppresses the fallback, so this never writes frontmatter
// while a body line the user could edit still exists.
const bodySource = getFrontmatterBodySource(intent.field);
const frontmatterCarriesKey =
hasFrontmatter && Object.prototype.hasOwnProperty.call(existingFm, intent.field);
// The presence check asks the same question the WRITE asks, in the same
// scope. An earlier cut checked the whole body on the reasoning that any
// editable line should suppress the repair — but a line the reader never
// reads is not a source, and suppressing on it left the document
// unrepairable while pointing the user at a command that would rewrite the
// wrong line. Same scope for read, write and probe, or they disagree.
const sessionProbeLabels = sessionLabelsForKey(intent.field);
const bodySourceExists = sessionProbeLabels
? sessionSourceExists(body, sessionProbeLabels)
: (bodySource ?? []).some((f) => stateExtractField(body, f) !== null);
if (bodySource && frontmatterCarriesKey && !bodySourceExists) {
const nextFm = { ...existingFm, [intent.field]: intent.value };
return {
content: `---\n${reconstructFrontmatter(nextFm as unknown as Frontmatter)}\n---\n\n${body}`,
updated: [intent.field],
data: { updated: true, wroteFrontmatter: true },
};
}
return { content, updated: [], data: { updated: false } };
}
const reassembled = reassemble(result);
return { content: reassembled, updated: [intent.field], data: { updated: true } };
}
// ----------------------------------------------------------------------------
// prune — intent implementation (Phase 7)
// ----------------------------------------------------------------------------
type PrunedSection = { section: string; count: number; lines: string[] };
// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends
// the section (mirrors state.cts STOP_H2_H3 — Decisions / Recently Completed /
// Blockers / Performance Metrics live at H2 or H3).
const STOP_H2_H3 = (lv: number): boolean => lv === 2 || lv === 3;
/**
* Apply a `prune` transition to STATE.md content.
*
* Migrates the section-pruning half of `cmdStatePrune` (state.cts) onto the
* substrate. Pure `content → {content, archivedSections}` given a cutoff phase:
* archives Decisions / Recently Completed / resolved Blockers / Performance
* Metrics table rows whose phase number is <= cutoff. ADR-1372 T6
* tokenizeHeadings + untrimmed-span splicing, byte-identical to the pre-migration
* `prunePass`.
*
* The adapter owns currentPhase derivation (with the #1760 `Phase` / `Current
* Phase` fallback), keepRecent/dryRun, and STATE-ARCHIVE.md writes.
*/
function pruneCore(
content: string,
intent: { kind: 'prune'; cutoff: number },
): StateTransitionResult {
const cutoff = intent.cutoff;
const sections: PrunedSection[] = [];
let c = content;
// Helper: locate a heading matching pred, extract untrimmed body [bs, se),
// apply transform, splice back. All prune sections stop at level 2 or 3.
const pruneSectionSpan = (
pred: (lv: number, text: string) => boolean,
transform: (body: string) => { keep: string[]; archive: string[] },
sectionName: string,
): void => {
const hs = tokenizeHeadings(c);
const i = hs.findIndex((h) => pred(h.level, h.text));
if (i === -1) return;
const h = hs[i];
const ls = c.split('\n');
const hl = ls[h.line - 1];
const bs = h.offset + hl.length + 1;
let se = c.length;
for (let j = i + 1; j < hs.length; j++) {
if (STOP_H2_H3(hs[j].level)) {
se = hs[j].offset - 1;
break;
}
}
const body = c.slice(bs, se);
const { keep, archive } = transform(body);
if (archive.length > 0) {
sections.push({ section: sectionName, count: archive.length, lines: archive });
c = c.slice(0, bs) + keep.join('\n') + c.slice(se);
}
};
pruneSectionSpan(
(lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); }
}
return { keep, archive };
},
'Decisions',
);
pruneSectionSpan(
(lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); }
}
return { keep, archive };
},
'Recently Completed',
);
pruneSectionSpan(
(lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); }
}
return { keep, archive };
},
'Blockers (resolved)',
);
pruneSectionSpan(
(lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
if (tableRowMatch) {
const rowPhase = parseInt(tableRowMatch[1], 10);
if (rowPhase <= cutoff) { archive.push(line); } else { keep.push(line); }
} else {
keep.push(line);
}
}
return { keep, archive };
},
'Performance Metrics',
);
const totalPruned = sections.reduce((sum, s) => sum + s.count, 0);
return {
content: c,
updated: totalPruned > 0 ? ['pruned'] : [],
data: { archivedSections: sections, totalPruned },
};
}
// ----------------------------------------------------------------------------
// sync — intent implementation (Phase 7)
// ----------------------------------------------------------------------------
/**
* Apply a `sync` transition to STATE.md content.
*
* Migrates the body-write half of `cmdStateSync` (state.cts) onto the substrate.
* Updates Total Plans in Phase, the Progress bar, and Last Activity from
* disk-derived numbers (injected via the intent). Returns the per-field change
* log via `data.changes` so the adapter can build the CLI output.
*
* #1761: when the current milestone cannot be bounded to a versioned phase set,
* the adapter passes `percent: null` and this core leaves Progress untouched
* (rather than silently writing fallback-derived wrong values).
*/
function syncCore(
content: string,
intent: { kind: 'sync'; totalPlansInPhase: number | null; percent: number | null },
deps: StateTransitionDeps,
): StateTransitionResult {
const today = deps.clock.localToday();
const changes: string[] = [];
let modified = content;
const updated: string[] = [];
if (intent.totalPlansInPhase !== null) {
const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
if (currentPlansField && parseInt(currentPlansField, 10) !== intent.totalPlansInPhase) {
changes.push(`Total Plans in Phase: ${currentPlansField} -> ${intent.totalPlansInPhase}`);
const result = stateReplaceField(modified, 'Total Plans in Phase', String(intent.totalPlansInPhase));
if (result) { modified = result; updated.push('Total Plans in Phase'); }
}
}
if (intent.percent !== null) {
const currentProgress = stateExtractField(modified, 'Progress');
if (currentProgress) {
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
if (currentPercent !== intent.percent) {
const barWidth = 10;
const filled = Math.round((intent.percent / 100) * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${intent.percent}%`;
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
const result = stateReplaceField(modified, 'Progress', progressStr);
if (result) { modified = result; updated.push('Progress'); }
}
}
}
const lastActivityResult = stateReplaceField(modified, 'Last Activity', today);
if (lastActivityResult) {
const oldActivity = stateExtractField(modified, 'Last Activity');
if (oldActivity !== today) {
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
updated.push('Last Activity');
}
modified = lastActivityResult;
}
return { content: modified, updated, data: { changes } };
}
// ----------------------------------------------------------------------------
// rebuild — intent implementation (ADR-1817, capstone 11th transition)
// ----------------------------------------------------------------------------
//
// Implements the body-structure derivability contract (ADR-1817 §2–§6):
// - §2 re-derives derived sections (## Current Position prose, By Phase table
// inside ## Performance Metrics), preserves curated sections verbatim
// (## Accumulated Context, ## Deferred Items, ## Project Reference, ##
// Session Continuity's prose fields) and unknown sections.
// - §3 every mutation appends a structured entry to ## Rebuild Log
// (ADR-1411 provenance principle — never drop silently).
// - §4 idempotency: a no-mutation rebuild appends NO log entry, so two
// successive runs on a clean file are byte-identical.
// - §5 non-overlapping with sync (sync = 3 frontmatter fields, lightweight,
// auto-triggered; rebuild = body structure, heavier, manual).
// - §6 orthogonal to auto_prune_state (rebuild reconciles with current
// canonical sources; prune removes by retention policy).
//
// Section ordering is invariant: rebuild rewrites content IN PLACE; it does
// not reorder, insert (other than ## Rebuild Log when absent), or remove
// sections.
const REBUILD_LOG_SECTION = '## Rebuild Log';
const REBUILD_LOG_TRUNCATION_LIMIT = 512;
type RebuildLogEntryKind =
| 'placeholder-removed'
| 'current-position-reconciled'
| 'by-phase-table-reconciled'
| 'session-archive-deduplicated';
interface RebuildLogEntry {
timestamp: string;
kind: RebuildLogEntryKind;
section: string;
before: string;
after: string;
reason: string;
}
/**
* Out-of-band signal from `reconcileByPhaseTable` back to `rebuildCore`
* (issue #3057 B1): a mutable sibling to `log`, carrying whether the
* phase-inventory disk scan failed. Kept separate from `log`/`RebuildLogEntry`
* because a scan failure does not mutate content (there is nothing to diff),
* so it does not fit the before/after log-entry shape — but it still must
* reach the caller so "scan failed" and "nothing to reconcile" stay
* distinguishable in `rebuildCore`'s returned `data`.
*/
interface PhaseInventoryScanMeta {
failed: boolean;
reason: string | null;
}
/**
* Truncate a string for inclusion in a rebuild log entry. Per ADR-1817 §3 the
* `before` / `after` fields are bounded to REBUILD_LOG_TRUNCATION_LIMIT chars
* to prevent unbounded log growth when the drifted content is large.
*/
function truncateForLog(s: string): string {
if (s.length <= REBUILD_LOG_TRUNCATION_LIMIT) return s;
return s.slice(0, REBUILD_LOG_TRUNCATION_LIMIT - 3) + '...';
}
/**
* Apply a `rebuild` transition to STATE.md content. Pure core per ADR-1769 §3
* and ADR-1817 §1. Returns `{ content, updated, data }` where `data.mutated`
* is false when no drift was found (idempotency contract, ADR-1817 §4).
*/
function rebuildCore(
content: string,
_intent: { kind: 'rebuild' },
deps: StateTransitionDeps,
): StateTransitionResult {
const timestamp = deps.clock.nowIso();
const log: RebuildLogEntry[] = [];
const phaseInventoryScan: PhaseInventoryScanMeta = { failed: false, reason: null };
let modified = content;
// §2 Decision: re-derive derived sections, preserve others. Order is
// oldest-section-first so log entries appear in body order.
// sourcePath threaded so `state rebuild --dry-run` names the file: that branch reads STATE.md
// directly rather than through readModifyWriteStateMd, so nothing upstream has named it yet.
modified = reconcileCurrentPosition(modified, timestamp, log, deps.sourcePath);
modified = reconcileByPhaseTable(modified, deps, timestamp, log, phaseInventoryScan);
modified = stripTemplatePlaceholders(modified, timestamp, log);
modified = deduplicateSessionArchive(modified, timestamp, log);
// §3 + §4: append the audit log ONLY when mutations occurred. The
// log-appends-only-on-mutation rule is what makes idempotency byte-identical
// (without it, the second invocation would always append a no-op entry).
if (log.length > 0) {
modified = appendRebuildLogSection(modified, log);
}
const updated = log.length > 0 ? ['rebuild'] : [];
return {
content: modified,
updated,
data: {
mutated: log.length > 0,
mutations: log.length,
log,
// #3057 B1: distinguishable from a clean "nothing to reconcile" — a
// failed phase-inventory scan means the by-phase table was NOT
// verified against disk, even though `mutated` may still be false.
phase_inventory_scan_failed: phaseInventoryScan.failed,
...(phaseInventoryScan.reason !== null ? { phase_inventory_scan_reason: phaseInventoryScan.reason } : {}),
},
};
}
/**
* §2 — re-derive `## Current Position` prose fields from frontmatter.
*
* Drift class: `Phase:`, `Status:` etc. in body contradict frontmatter after
* a milestone switch or prune (epic #1817). The body prose is re-derivable
* because `buildStateFrontmatter` already derives the canonical values from
* disk; rebuild pushes those back into the body prose.
*
* Implementation: pull each canonical value from frontmatter and replace the
* body field via `stateReplaceField`. Skip silently when frontmatter lacks
* the key (Leaky-Abstractions guard — don't synthesize values the canonical
* source doesn't have).
*/
function reconcileCurrentPosition(
content: string,
timestamp: string,
log: RebuildLogEntry[],
sourcePath?: string,
): string {
const fm = extractFrontmatter(content, sourcePath) as Record<string, unknown>;
if (!fm || typeof fm !== 'object') return content;
let modified = content;
// Phase prose: frontmatter `current_phase` overrides body `**Current Phase:**`.
// The body `Phase:` prose line (e.g. "Phase: 3 of 12 (Test Phase)") is owned
// by other transitions (beginPhase / completePhase) and reconstructed from
// total-phase counts; rebuild reconciles only the `**Current Phase:**` body
// field that frontmatter is the canonical source for.
const fmPhase = fm.current_phase;
if (typeof fmPhase === 'string' || typeof fmPhase === 'number') {
const canonicalPhase = String(fmPhase);
const existing = stateExtractField(modified, 'Current Phase');
if (existing !== null && existing !== canonicalPhase) {
const replaced = stateReplaceField(modified, 'Current Phase', canonicalPhase);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalPhase),
reason: "frontmatter 'current_phase' is canonical; body 'Current Phase' was stale",
});
}
}
}
// Phase name prose.
const fmPhaseName = fm.current_phase_name;
if (typeof fmPhaseName === 'string' || typeof fmPhaseName === 'number') {
const canonicalName = String(fmPhaseName);
const existing = stateExtractField(modified, 'Current Phase Name');
if (existing !== null && existing !== canonicalName) {
const replaced = stateReplaceField(modified, 'Current Phase Name', canonicalName);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalName),
reason: "frontmatter 'current_phase_name' is canonical; body 'Current Phase Name' was stale",
});
}
}
}
return modified;
}
/**
* §2 — re-derive the `**By Phase:**` table inside `## Performance Metrics`
* from the injected `phaseInventoryProvider`. Drift class: orphaned rows for
* phases from a prior milestone, or zero-padded phase IDs that were renamed
* (epic #1817).
*
* Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is
* absent (no disk scan wired), this step is a no-op. The core stays pure and
* testable without disk I/O.
*
* A DIFFERENT case is a scan that ran but failed (`ok:false`): that is NOT a
* no-op-equivalent "nothing to reconcile" — the table is left untouched (we
* have no trustworthy inventory to reconcile against) but the failure is
* recorded into `meta` so the caller (`rebuildCore`) can surface it instead
* of reporting a clean, fully-reconciled rebuild (#3057 B1).
*/
function reconcileByPhaseTable(
content: string,
deps: StateTransitionDeps,
timestamp: string,
log: RebuildLogEntry[],
meta: PhaseInventoryScanMeta,
): string {
if (!deps.phaseInventoryProvider) return content;
const result = deps.phaseInventoryProvider();
if (!result.ok) {
meta.failed = true;
meta.reason = result.reason;
return content; // cannot reconcile without a trustworthy inventory — leave the table as-is
}
const inventory = result.phases;
if (inventory.length === 0) return content;
// The canonical table shape (from gsd-core/templates/state.md):
// | Phase | Plans | Total | Avg/Plan |
// |-------|-------|-------|----------|
// | - | - | - | - |
// rebuild renders one row per inventory record (Phase N: P plans). The
// Total/Avg columns are runtime-collected by other commands; rebuild does
// NOT re-derive them and resets them to '-' so future plan-completion
// repopulates. The canonical reconciliation target is the row SET.
const tableRows = inventory.map((r) => `| ${r.number} | ${r.planCount} | - | - |`);
const canonicalTable = [
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
...tableRows,
];
// Line-based splice: find `**By Phase:**` line, then walk forward collecting
// the table block (header + separator + body rows), replace the block with
// the canonical table preceded by a single blank-line separator.
const lines = content.split('\n');
const markerIdx = lines.findIndex((l) => l.trim() === '**By Phase:**');
if (markerIdx === -1) return content; // unknown shape — preserve verbatim
// Walk forward from markerIdx+1 to find the table block span. Skip leading
// blank lines; once we see the first table row, consume subsequent table
// rows; stop at the first non-table line after we've started.
let blockStart = -1;
let blockEnd = -1;
for (let i = markerIdx + 1; i < lines.length; i++) {
const trimmed = lines[i].trim();
const isTable = trimmed.startsWith('|') && trimmed.endsWith('|');
if (blockStart === -1) {
if (isTable) { blockStart = i; blockEnd = i + 1; }
else if (trimmed === '') continue;
else break; // non-table, non-blank before any row — unknown shape
} else {
if (isTable) blockEnd = i + 1;
else break;
}
}
if (blockStart === -1) return content; // no table found
// Replace lines[blockStart..blockEnd) with canonicalTable.
const beforeBlock = lines.slice(0, markerIdx + 1);
const afterBlock = lines.slice(blockEnd);
// Splice: `**By Phase:**` + blank + canonicalTable rows + (whatever came after)
const newLines = [...beforeBlock, '', ...canonicalTable, ...afterBlock];
const candidate = newLines.join('\n');
if (candidate === content) return content;
log.push({
timestamp,
kind: 'by-phase-table-reconciled',
section: STATE_MD_SECTIONS.performanceMetrics,
before: truncateForLog(lines.slice(blockStart, blockEnd).join('\n')),
after: truncateForLog(canonicalTable.join('\n')),
reason: 'phase dirs on disk are canonical; rows for missing phases dropped, missing phases added',
});
return candidate;
}
/**
* §2 + epic-#1817 drift class — template-placeholder field values left in
* place when an AI agent wrote partial state. The canonical template uses
* `[X]`, `[Y]`, `[Phase name]`, `[date]`, `[N]`, etc. (see
* `gsd-core/templates/state.md`). Rebuild clears any `**Field:** [placeholder]`
* line where the value still matches the placeholder shape.
*
* "Clears" means: leaves the field in place with the literal text `(pending)`,
* signalling that rebuild recognized the placeholder but had no canonical
* source to substitute. This is honest — better than silently leaving `[X]`
* which looks like a value.
*/
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/;
function stripTemplatePlaceholders(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
// Scan body `**Field:** value` lines; when value matches the placeholder
// shape, replace with `(pending)`. We deliberately do NOT touch fields that
// other transitions actively maintain (syncCore's three, beginPhase's set,
// etc.) — only the template placeholder rows that nothing has touched.
const lines = content.split('\n');
const replacements: Array<{ lineIdx: number; before: string; after: string; fieldName: string }> = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(/^\s*\*\*([^*]+):\*\*\s*(.*)$/);
if (!m) continue;
const fieldName = m[1];
const value = m[2];
if (TEMPLATE_PLACEHOLDER_VALUE.test(value)) {
const cleared = `**${fieldName}:** (pending)`;
replacements.push({ lineIdx: i, before: line, after: cleared, fieldName });
}
}
if (replacements.length === 0) return content;
for (const r of replacements) {
lines[r.lineIdx] = r.after;
log.push({
timestamp,
kind: 'placeholder-removed',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(r.before.trim()),
after: truncateForLog(r.after),
reason: `field ${JSON.stringify(r.fieldName)} still carried template placeholder ${JSON.stringify(r.before.match(/\*\*[^*]+:\*\*\s*(.*)$/)?.[1]?.trim() ?? '')}; no canonical source available — replaced with (pending)`,
});
}
return lines.join('\n');
}
/**
* §2 + epic-#1817 drift class — duplicate `## Session Continuity Archive`
* blocks from repeated `state record-session` calls on a corrupt file. The
* canonical template has one `## Session Continuity` section; archived blocks
* may accumulate as `### Session — <timestamp>` H3 sub-sections under it.
* Rebuild keeps the most-recent N (default 3) and drops older duplicates,
* logging each drop.
*
* Conservative scope: only acts when the section has more than 3 H3
* `### Session —` sub-headings; otherwise it's a no-op (preserve verbatim).
*/
const DEFAULT_MAX_SESSION_ARCHIVES = 3;
// `tokenizeHeadings` strips leading `#` markers — `h.text` for `### Session — X`
// is just `Session — X`. Match the bare heading text.
const SESSION_ARCHIVE_H3 = /^Session\s+—/;
function deduplicateSessionArchive(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
const hs = tokenizeHeadings(content);
// Find `## Session Continuity` H2.
const sectionIdx = hs.findIndex((h) => h.level === 2 && h.text === 'Session Continuity');
if (sectionIdx === -1) return content;
// Find the section span: from this H2's offset to the next H2 (or EOF).
const sectionStart = hs[sectionIdx].offset;
let sectionEnd = content.length;
for (let i = sectionIdx + 1; i < hs.length; i++) {
if (hs[i].level === 2) { sectionEnd = hs[i].offset; break; }
}
// Count `### Session — …` H3 sub-headings inside the section.
const archiveHeadings = hs.filter(
(h) => h.level === 3 && h.offset >= sectionStart && h.offset < sectionEnd && SESSION_ARCHIVE_H3.test(h.text),
);
if (archiveHeadings.length <= DEFAULT_MAX_SESSION_ARCHIVES) return content;
// Keep the most-recent N by offset (last N in document order; if timestamps
// in the H3 text are in chronological order — the template convention —
// last-N == most-recent-N).
const dropCount = archiveHeadings.length - DEFAULT_MAX_SESSION_ARCHIVES;
const toDrop = archiveHeadings.slice(0, dropCount);
// Compute the byte spans to drop: each archived H3 spans from its offset to
// the next H3 (or to sectionEnd). Drop with one preceding blank line so we
// don't leave a dangling separator.
let mutated = content;
// Process from the bottom up so offsets don't shift mid-edit.
for (let i = toDrop.length - 1; i >= 0; i--) {
const h = toDrop[i];
let spanEnd = sectionEnd;
// Find next H3 at-or-after h.offset (within the section).
for (const candidate of hs) {
if (candidate.level === 3 && candidate.offset > h.offset && candidate.offset < sectionEnd) {
spanEnd = candidate.offset;
break;
}
}
const dropStart = h.offset;
const before = mutated.slice(0, dropStart);
const after = mutated.slice(spanEnd);
const droppedText = mutated.slice(dropStart, spanEnd);
mutated = before + after;
log.push({
timestamp,
kind: 'session-archive-deduplicated',
section: STATE_MD_SECTIONS.sessionContinuity,
before: truncateForLog(droppedText),
after: '',
reason: `archived session ${JSON.stringify(h.text)} exceeded the ${DEFAULT_MAX_SESSION_ARCHIVES}-most-recent retention; dropped`,
});
}
return mutated;
}
/**
* §3 — append a structured audit entry to `## Rebuild Log`. Per ADR-1817 §3
* the section is created if absent; existing entries are preserved verbatim
* (append-only).
*
* Format (yaml-ish, human-readable, machine-parseable):
*
* ## Rebuild Log
*
* - timestamp: 2026-06-29T19:30:00Z
* kind: placeholder-removed
* section: ## Current Position
* before: ...
* after: ...
* reason: ...
*/
function appendRebuildLogSection(content: string, entries: RebuildLogEntry[]): string {
const lines = content.split('\n');
// Render the new entry block.
const rendered: string[] = [];
for (const e of entries) {
rendered.push(`- timestamp: ${e.timestamp}`);
rendered.push(` kind: ${e.kind}`);
rendered.push(` section: ${e.section}`);
rendered.push(` before: ${e.before.replace(/\n/g, ' \\n ')}`);
rendered.push(` after: ${e.after.replace(/\n/g, ' \\n ')}`);
rendered.push(` reason: ${e.reason.replace(/\n/g, ' \\n ')}`);
}
// Locate an existing `## Rebuild Log` section.
const sectionHeaderIdx = lines.findIndex((l) => l.trim() === REBUILD_LOG_SECTION);
if (sectionHeaderIdx === -1) {
// Create the section at end-of-file, separated by a blank line.
const needsLeadingBlank = lines.length > 0 && lines[lines.length - 1].trim() !== '';
const trailer = needsLeadingBlank ? ['', REBUILD_LOG_SECTION, '', ...rendered] : [REBUILD_LOG_SECTION, '', ...rendered];
return [...lines, ...trailer].join('\n');
}
// Append to the existing section. Find the end of the existing log entries
// (walk forward until the next H2 or EOF). Insert before that boundary.
let insertAt = sectionHeaderIdx + 1;
while (insertAt < lines.length) {
const l = lines[insertAt];
if (/^##\s/.test(l)) break;
insertAt++;
}
// Preserve a blank-line separator before the new entries if the prior line
// is non-blank and non-header.
const sep: string[] = [];
if (insertAt > 0 && lines[insertAt - 1].trim() !== '' && lines[insertAt - 1].trim() !== REBUILD_LOG_SECTION) {
sep.push('');
}
const next = [...lines.slice(0, insertAt), ...sep, ...rendered, ...lines.slice(insertAt)];
return next.join('\n');
}