309 Commits

Author SHA1 Message Date
Jakub Zych
6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00
Jakub Zych
a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00
Michel Moreira
1e3e1f7cd8 enhance(#4570): allow disabling planner stall detection (#4585)
* enhance(#4570): allow disabling planner stall detection

* docs(#4570): add changelog fragment

Emitted-Drift-Ack-Growth: plan-phase.md — the explicit opt-out gate covers all five planner and checker spawn classes
Emitted-Drift-Ack-Growth: settings-advanced.md — the toggle prompt and bounded-recovery warning expose the new setting

* chore(#4570): refresh compact-content baseline

* fix(#4570): preserve default-on watchdog fallback

* docs(#4570): qualify the chunked-mode orchestrator rules with the toggle

The two chunked-planning-mode stall-watch imperatives read as
unconditional, with the opt-out stated only in a following bullet. State
the PLANNER_STALL_DETECTION_ENABLED condition inline, matching the three
sites already qualified in plan-phase.md.

* chore(#4570): refresh compact-content baselines against rebased next

* fix(#4570): sync planner stall launcher

Keep the stall-detection helper aligned with the canonical runtime launcher.

* chore(#4570): refresh compact baseline after rebase

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-23 18:46:23 -04:00
0xdhx
b90eef28e8 enhance(#3829): report code review severity counts and record a per-finding disposition (#3861)
* enhance(#3829): report code review severity counts and record a per-finding disposition

`code_review_gate` extracted `status:` from REVIEW.md's frontmatter and discarded
the `critical`/`warning`/`info`/`total` values sitting in the same range, so its
output was byte-identical for a review with one `info` finding and a review with a
Critical. Nothing anywhere recorded what happened to a finding: no file under
`gsd-core/workflows/` branches on `issues_found`, and `gsd-verifier.md` has zero
references to REVIEW.md. A phase therefore reached `phase.complete` with Criticals
standing and no trace they had been seen.

Both halves were approved on the issue; the gate stays advisory.

A — severity surfacing, in `execute-phase.md`. The gate states the breakdown it
already parsed, accepting `blocker:` as the documented tier-equivalent of
`critical:`. The breakdown is shown only when all four counts are numeric
(`REVIEW_COUNTS_OK`); otherwise the countless message stands, because gating on
the total alone still emits `6 findings —  critical` for a review carrying a total
and nothing else.

Frontmatter is extracted by an `awk` that emits only when it saw the CLOSING
delimiter, after stripping CR. A `sed` range re-opens on a body `---` and runs to
EOF: first-match protects a key the frontmatter always carries, but not an
optional one, so a review with no `findings:` block and a body `total:` line would
have reported the body's number. An unterminated block would leak the whole body
the same way.

Every read is guarded and `|| true`-terminated. This step is advisory, and under
`set -e`/`pipefail` a non-matching `grep` exits 1 — an assignment whose command
substitution fails would take the step down with it. A REVIEW.md that is missing,
a directory, or unreadable now leaves the counts empty and execution continues.

B — per-finding disposition, in a new lazily-read step file,
`gsd-core/workflows/execute-phase/steps/code-review-disposition.md`, referenced
from the gate in the established plain read-and-execute form. One row per finding
ID, defaulting to `open`, and:

- `fixed`/`skipped` are reconciled from REVIEW-FIX.md, whose section headings are
  matched WHOLE — a prefix match let `## Fixed Issues Verification` classify every
  finding beneath it as fixed — and only when the fix report names the SAME
  finding. Finding ids are reused across re-reviews, so matching on the id alone
  let a stale fix report declare a brand-new CR-01 already fixed.
- headings inside fenced blocks are ignored; a quoted example is not a finding.
- an id listed under both sections resolves by first occurrence, not row order.
- a recorded disposition is preserved together with the reason in its Source cell,
  escaped pipes included, and a hand-mangled row missing its trailing pipe still
  keeps its decision.
- a decided finding the current review no longer reports is CARRIED and marked;
  `--auto` rewrites REVIEW.md each iteration, so this is routine, and dropping the
  row would erase the record that it was seen. An untriaged `open` row for a
  vanished finding is not carried. A review reporting nothing still reconciles an
  existing ledger rather than freezing it.
- a run that changes no disposition rewrites nothing, so a re-executed phase does
  not produce a docs commit whose only delta is a timestamp.

The record is a sibling artifact, not a section inside REVIEW.md: `--auto`'s
re-review loop rewrites REVIEW.md every iteration, so a ledger kept inside it
would not survive the next pass, and REVIEW.md has a single writer that this step
is not.

B lives in an extracted step file because `execute-phase.md` was 91,493 bytes
against a 98,304 hard cap the size-budget test calls a red line, and because
`scanWiredKinds` caps a call site's dispatch-coverage region at 6000 characters —
an inline version pushed the `kind == "gate"` paragraph out of that window, which
silently drops `gate` from the covered set and fails
`gen-capability-registry --check` while pointing at the capability rather than at
the prose that displaced it. Extraction is what that size test's own message
prescribes, and it leaves the file at 93,854 bytes.

The tests execute the shipped script rather than modelling it. Three adversarial
review rounds each refuted "the mirror is faithful", and mutation testing agreed:
with a hand-written model, deleting the carried-row logic from the shipped file
turned nothing red. The suite now extracts the embedded script — undoing exactly
the four shell double-quote escapes — and runs it, so all ten mutations of its
behaviour are caught.

* chore(#3829): set changeset fragment pr to 3861

* fix(#3829): keep execute-phase.md under both size ceilings and propagate the launcher probe

The first push failed `full test (macos-latest, 24, shard 3/3)`. Two things it
caught that the CI-selected scope for this diff does not run, and that I
therefore did not run either:

1. `execute-phase.md` is governed by TWO ceilings, not one. The XL hard cap in
   `tests/workflow-size-budget.test.cjs` (98304) was satisfied at 95179, but the
   frozen ADR-857 pre-phase-6 ceiling in `tests/claude-orchestration.test.cjs`
   (93600) was not. The whole budget from base is 2107 bytes, which the inline
   reporting half alone did not fit. That half now lives in the extracted step
   file alongside the disposition half, and the parent carries only the paragraph
   that reads and executes it — 91529 bytes, 36 over base.

2. The step file calls `gsd_run`, so it owes the hermes runtime-home probe that
   `tests/runtime-launcher-parity.test.cjs` (E) requires of every workflow file
   that does. Propagated with `node scripts/sync-runtime-launcher.cjs`, the
   remedy that test names.

Verified with the FULL unit suite this time rather than the scoped selection —
14 shards, 0 failures — plus `npm run lint:ci`, and a re-run of the ten mutations
of the shipped disposition script, all still caught.

* fix(#3829): stop the disposition step instructing the agent to execute itself

Blocker 1 and Minor 7 of the round-1 review are one defect. The step file
carried a copy of execute-phase.md's pointer paragraph, so it named its own
path as something to "read and execute" — unbounded self-recursion at runtime
— and that copy is also the duplicated paragraph, sitting immediately above
the full instruction it duplicates.

Removing the copy resolves both. execute-phase.md remains the only surface
that points here, which is what it always intended.

Two structural tests guard it. Both are red against the pre-fix file: no
behavioural test could see either defect, because they execute the node
script through the process seam and so never read the prose that tells the
agent what to load.

* fix(#3829): re-derive the ledger paths in the block that uses them

Blocker 2. The disposition block reads REVIEW_FILE, DISPOSITION_FILE and
PADDED, all derived in the step's FIRST shell block. Each fenced block is
dispatched as its own shell, so all three are empty by the time the second
block runs: the ledger write lands on a bare `-REVIEW-DISPOSITION.md` path
and the review read finds nothing. The step then reports success having
produced no artifact — the feature's central acceptance criterion, silently
unmet, with no error to notice.

The tell was already in the file: the gsd_run shim preamble is re-emitted in
the second block for exactly this reason. These three paths belong beside it,
and now are.

The guard test asserts the general property rather than the instance — every
block derives what it reads, inheriting only the step's declared inputs
(PHASE_DIR, PHASE_NUMBER) — so a third block added later cannot reintroduce
it. Red against the pre-fix file.

* test(#3829): assert the counts mirror against the shipped shell, and execute its guards

Major 4, with Minor 6 and part of Minor 9.

The disposition builder stopped being a mirror three rounds ago, and the
reason given then was that a hand model of a shell-embedded script drifts
while the tests stay green. parseGateCounts kept its mirror anyway. That
argument does not stop applying at the boundary between the step's two shell
blocks, so the mirror now loses its authority: it is asserted against the
shipped awk and greps, run under `set -euo pipefail` in a real shell, across
every fixture it is exercised on.

Negative-controlled in both directions. Dropping `blocker:` from the mirror
alone fails the parity test; replacing the shipped awk with the leaky
`sed -n '/^---$/,/^---$/p'` range fails it on the unterminated-frontmatter
fixture. Divergence in either half is now red, which is what the finding asks
for. Skipped on win32, where there is no bash to compare against.

Minor 6: the zero-count edge is covered — `0` is numeric, so a zero-finding
review reports `0 findings — 0 critical, …` rather than falling back to the
countless form. A guard written against truthiness would have failed here
silently, and now cannot.

Minor 9, partially: running the block makes its advisory guards behavioural,
so the four `src.includes()` assertions that stood in for them are retired —
a missing and an unreadable REVIEW.md are now proven not to abort under
`set -e`, rather than asserted to contain a string. The remaining docs-parity
assertions are kept deliberately; see the PR discussion.

* test(#3829): add the render/re-parse fixed-point property for the ledger

Major 3. RULESET.TESTS.property-based-testing asks for at least one fc
property on a parsing/transformation contract, and the ledger is one with a
fixed point stated in its own prose: re-running the gate preserves every
disposition except `open`, and rewrites nothing when nothing changed.

Two properties, both driving the SHIPPED script rather than a model of it:

  idempotency — a second run reports `unchanged` and leaves the file
                byte-identical. Without it, the timestamp alone dirties the
                tree on every phase re-run.
  round-trip  — a hand-recorded decision AND the reason beside it survive
                render -> re-parse -> render, escaped pipes included. The
                Source cell is where a human writes why something was
                deferred, so losing it loses the only thing that instruction
                asks for.

Negative-controlled per property: disabling the unchanged-check fails the
first and only the first; discarding the carried source cell fails the second
and only the second.

numRuns is 40 rather than the shared 200 because each case spawns the shipped
script twice through the process seam. The seed stays pinned, so a failure
still reproduces; the deviation is stated in the file header rather than made
silently.

* fix(#3829): state a stale fix-report match instead of dropping it silently

Minor 5, plus the finding-id census this round owes.

Exact-title coupling stays — ids are reused across re-reviews, so a stale
REVIEW-FIX.md must not mark a brand-new CR-01 as already fixed. What changes
is the silence. A row that stays `open` because the report named a different
finding under the same id is indistinguishable, to any reader, from a row
that stays open because no report mentioned it. The gate now names the ids it
could not reconcile, on both report paths, and stays advisory throughout.

The census (RV4, self-found — the review did not ask for this). The script
enumerates finding-id prefixes in three places: the heading matcher, the
ledger re-parser, and the severity map's keys. The DOMAIN those enumerate is
owned elsewhere — gsd-code-reviewer.md's body template and its
Label-equivalence paragraph — so it can acquire a member without this script
changing.

Reached: CR, BL, WR, IN — 4 of 4, all present. Not reached: none today. What
follows if that changes is the payload: an unlisted prefix is not mis-tiered,
it is INVISIBLE — the finding never enters the order list and gets no row at
all, so the artifact silently under-reports the review it is meant to record.
Adding a prefix to two of the three copies fails the same way, and additionally
drops carried rows on the next run.

Two guards rather than a rewrite: hoisting the alternation into one constant
means rebuilding three regexes inside a double-quoted shell string, which is
the exact class of edit that produced both of this round's blockers. The
guards make the drift loud instead, and are negative-controlled against each
of the two ways it can happen.

* docs(#3829): keep the feature reference descriptive, not instructional

Minor 8 — a Diataxis mode mix. "Set `deferred` by hand and put the reason in
the Source cell" is a how-to instruction sitting in a reference doc. The
information belongs there (a reader needs to know the field exists and what
preserves it); the imperative does not.

Rewritten to describe the field instead: `deferred` is the one disposition the
gate never writes, and the reason recorded beside it survives re-runs. The
same pass records Minor 5's new behaviour, since the reference described the
title coupling but not what happens when it misses.

The imperative form is kept where it belongs — inside the ledger the gate
renders, which is where a reader meets the field and the only place an
instruction has an audience.

docs/FEATURES.md regenerated from it; `gen-features.cjs --check` is green.

* fix(#3829): close six defects found by reviewing this round's own fixes

None of these came from the maintainer's review. They came from adversarially
reviewing the five commits above before pushing them, and two are worse than
anything the round was opened to fix.

1. A foreign fence marker swapped an example for a finding. The heading scanner
   toggled fenced/not-fenced on ANY fence marker, so a ~~~ line inside a ```
   example closed the fence and the example's real close reopened one. Driven:
   a review quoting ~~~ inside a fenced example produced a ledger recording
   CR-77, the illustration, and omitting CR-01, the actual finding. A
   confidently-written artifact wrong in both directions at once. The open
   marker's character and length are now remembered, and a fence closes only on
   the same character at least as long, per CommonMark.

2. The disposition block had no status gate at all. The prose above it says it
   runs only when the review reports issues — but block 1 computes
   REVIEW_STATUS, emits nothing, and its shell is discarded, so no later block
   could act on that condition even in principle. A prose gate on a value
   nothing downstream can see is not a gate, and a clean re-review would rewrite
   a ledger it was never meant to touch. Re-derived in block 2's own shell.

3. A numeric breakdown could still be internally false. `total: 0` beside
   `critical: 1` is four valid numbers rendering `0 findings — 1 critical, …`.
   Numeric was necessary and not sufficient; an inconsistent breakdown is now
   withheld for the same reason a partial one is.

4. The carried-marker strip ate hand-written prose. It removed a trailing
   `(not in the current review)` unboundedly and unconditionally, so a deferral
   reason that merely ENDED in that phrase lost it — the one field a human
   writes into this artifact. Now bounded to one occurrence, and only on rows
   the marker can legitimately be on. The no-growth property it exists for is
   re-pinned.

5. parseGateCounts diverged from the shipped pipeline in two ways no fixture
   reached. The shipped reads are `cut -d: -f2 | tr -d ' '`: `tr` removes
   INTERNAL spaces (`1 0` -> `10`) where `.trim()` keeps them, and `cut` takes
   only the second colon-field where a tail capture keeps the rest. The mirror
   models the pipeline now, and both counterexamples are fixtures — a parity
   assertion that agrees only on well-formed input asserts very little.

6. The prefix census guards were both partly vacuous. The drift guard read the
   two regex alternations and not the severity map, so a set could agree in both
   regexes while mis-tiering in the map. The domain guard scanned only `### XX-01:`
   headings — and BL appears in no heading at all, only in the Label-equivalence
   prose, so the guard passed purely because BL happened to be hard-coded and
   would have missed the next prose-defined prefix exactly as it missed BL. Both
   widened; the domain the guard now sees is BL, CR, IN, WR.

Each fix fails a named test on reversion and none fires on the ordinary path.
The property generator now deliberately produces the reserved suffix from (4),
which a generator drawn only from innocuous characters could never reach.

Also corrected: the previous commit's account of the empty-path failure. The
script did not write a bare `-REVIEW-DISPOSITION.md`; it threw on reading the
empty review path and the trailing `|| echo` swallowed it as a non-blocking
skip. Same silent outcome, different mechanism, and the comment said the wrong
one.

* fix(#3829): the tests now run what bash runs — and six fixes to the fixes

A second adversarial pass over the previous commit. It found a regression that
commit introduced, and the reason it slipped through is the finding worth
keeping.

THE FIDELITY GAP. Every test here extracts the embedded script as TEXT and
runs it. Bash does not: it expands the double-quoted `node -e "..."` argument
first, so a backtick inside it is COMMAND SUBSTITUTION. The previous commit put
one in a code comment. Bash duly ran it, failed with `+: command not found`,
and handed Node a script two bytes shorter than the one 122 green tests were
exercising. No behavioural test could see this, because none of them ever
asked bash what it would actually pass. One now does, and it is the general
guard: it catches an unescaped backtick, an unescaped $, and any other
expansion the extractor cannot model.

Then, in the shipped step:

- A padded count silently disabled the sum check. `$((08 + …))` fails on base
  inference; it does not abort — the expansion sits in an `if` condition, where
  set -e does not fire — so the check simply never ran and an inconsistent
  breakdown passed with a stray diagnostic as its only trace. `10#` on every
  operand.
- The status guard made the script's own reconciliation unreachable. A clean
  review with an EXISTING ledger must still be reconciled — decided rows
  carried, stale `open` rows dropped — or the ledger freezes showing findings
  as open that the review no longer reports. The guard now skips only when
  there is nothing to reconcile.
- The carried marker is no longer stripped at parse time at all. Bounding the
  strip still ate a carried row's human-written reason. No-growth is a property
  of the RENDER, so it is enforced there: a marker already present is not
  appended again. Nothing is stripped, nothing doubles.
- Fence openers are bounded to three leading spaces, per CommonMark.
- parseGateCounts matched `[ \t]` where the shipped grep uses `[[:space:]]`,
  which covers form feed and vertical tab. Third counterexample of the same
  class, and a fixture.
- The census drift guard checked only one direction, so a tier for a prefix the
  regexes never admit stayed green as dead code that reads as coverage.

TWO OF MY OWN TESTS WERE VACUOUS, and the controls are what said so. The
leading-zero test asserted exit 0 and a consistent verdict — both true before
the fix. The clean-review test drove the node script directly, which never
executes the shell guard at all: it passed unchanged with the guard made
unconditional. Both are rewritten to test the layer the defect lives on, and
both now fail when their fix is reverted.

Every fix in this commit fails a named test on reversion, each mutation
verified to have applied before its verdict was read.

* fix(#3829): the carried marker can no longer outlive the carry

A third adversarial pass. Its most important finding is a defect the SECOND
pass talked me into, which is worth recording as plainly as the fix.

THE MARKER BECAME A LIE. Pass 2 objected that bounding the carried-marker strip
still altered a human-written reason, and proposed storing the cell verbatim
instead. That objection was a preference, not a defect — its own driven output
showed exactly one marker, which is correct — and adopting it created a real
one: once the generated marker is stored it can never leave, so a carried
finding that REAPPEARS in a later review still renders "not in the current
review". The ledger then contradicts its own contents. Driven both runs.

The strip is back, bounded to one occurrence and unconditional. The residual
ambiguity is irreducible — a reason ending in exactly that phrase is
indistinguishable from the marker — and it costs nothing real: on a carried row
the render puts the phrase straight back, and on a current row the phrase was
self-contradictory to begin with. The unbounded quantifier is what had to go,
not the strip. The property now states that contract rather than asserting a
verbatim survival the code deliberately does not provide.

Also:

- An ABSENT REVIEW.md abandoned the ledger it was meant to reconcile. The guard
  proceeds when a ledger exists, then the script read the review unconditionally,
  threw, and the trailing fallback swallowed it — the freeze the reconciliation
  path exists to prevent, reached through the door the guard opened.
- Counts are length-bounded as well as digit-only. Bash integers wrap at 2^64,
  so a 20-digit count arrived at the sum as 0 and an inconsistent breakdown
  passed.
- A closing fence must carry only whitespace after its marker; a line with an
  info string is an opener's shape and ended the fence early.
- parseGateCounts matched [ \t\n\v\f\r] where the shipped grep uses
  [[:space:]], which under this UTF-8 locale matches EM SPACE. `\s` is the
  faithful model. Fourth counterexample of that class, and a fixture.
- The agent-domain scan required [A-Z]{2,}, so a one-letter prefix like `C-01`
  — explicit and parseable, not prose — was invisible to it.

AND THE FIDELITY GUARD PAID FOR ITSELF INSIDE ONE SESSION: writing this round's
first draft I put backticks around a token in a code comment again, in the very
commit whose subject is that mistake. The probe failed, named it, and no test
of behaviour could have. Two of my own tests also had to be rewritten: one
asserted things true before its fix, and one drove the node script directly
where the defect lived in the shell.

383 pass across the touched files and the two size ceilings; ten lint gates
green; every fix fails a named test on reversion, each mutation verified to have
applied before its verdict was read.

* docs(#3829): the Source reason is preserved, but not verbatim — say so

Found by claim-auditing the response comment before posting it, which is the
one place this would have been caught: the doc and the code were written in
different commits and only a reader holding both notices they disagree.

The feature reference said the hand-written reason is "preserved verbatim
across re-runs". It is not, and deliberately so — a reason ending in the
literal phrase "(not in the current review)" loses that trailing phrase,
because it is indistinguishable from the carried marker the gate appends.

The exception is stated rather than dropped, with the reason it is the better
trade: storing the marker instead means it never leaves, and a carried finding
that later reappears goes on claiming it is absent from the very review that
reports it. A ledger wrong about its own contents beats losing a duplicated
phrase, but only if the doc admits which one it chose.

FEATURES.md regenerated; gen-features --check and lint:docs green.

* fix(#3829): the gate now emits the counts it computes (B1a/B1b)

Block 1 computed REVIEW_STATUS and the four counts and printed none of
them, then the prose below asked the agent to display four of them. The
shell exits at the closing fence and the agent sees only stdout, so those
values were unobtainable: REQ-REVIEW-08 was unreachable in every shipped
path and the fence was decorative.

The rule was already stated one block down -- "a prose-only gate on a
value no later block can see is not a gate" -- and applied only to block
2. It now governs the block that is this step's primary deliverable.

Both arms emit, and the status gate is mechanical rather than prose:
a clean/skipped/absent review prints nothing, an inconsistent or partial
breakdown prints the countless form, and the full breakdown prints
otherwise. Driven against the review's own case (critical: 1, warning: 9,
info: 8, total: 18) with no appended emitter:

  Code review: 18 findings - 1 critical, 9 warning, 8 info.
  Consider running: /gsd:code-review 1 --fix

* test(#3829): the counts harness stops manufacturing the output it asserts on (B2)

runShippedGateCounts extracted the shipped fence and then APPENDED its own
printf of the six internal variables before running it. Every counts
assertion was green against a script that existed only inside the test
process: the shipped fence emitted nothing, the tested fence emitted six
lines because the test added them. That is why B1a shipped past a suite
that looks like it covers exactly that surface -- the green was
structurally incapable of turning red for it.

The emitter now lives in the fence, so the harness reads the fence's own
stdout and synthesizes nothing. Parity with the mirror moved up a level
with it: renderGateMessage() renders both arms from the mirror's parsed
counts and the assertion compares the WHOLE emitted message, so a drift
in any parsed value changes the string or the arm it selects. Asserting
on the observable is strictly stronger than asserting on five
intermediates, and it can express what the old probe could not -- an
absent review now reports NOTHING, which is a different fact from
reporting a countless review.

A fifth src.includes() assertion converted with it (round 1 retired
four). It pinned the PROSE stating the countless condition, so it went
red when the emitter moved into the fence while the behaviour it named
was untouched -- the pin arguing for its own conversion.

Negative control: reverting the shipped echo now turns 16 tests red.
Before this commit the same reversion turned zero red, which is the
finding.

* fix(#3829): the disposition column is an enum, not any lowercase token (B3)

ADR-227 requires a trust boundary to validate semantic SHAPE and to coerce
a failure to the contract's safe default. The ledger is a trust boundary by
construction -- the rendered instruction tells a human to hand-edit it --
and the prior-row parser captured column 3 as ([a-z]+), checked against
nothing.

One transposed character was enough. `| CR-01 | critical | opne | - |` is
not the literal 'open', so it beat the default, was excluded from the
`open:` headline count, and was carried forward forever. The ledger then
reported the phase fully triaged off a typo.

The asymmetry is what made this a correctness bug rather than a style
point: a typo OUTSIDE [a-z] ('Deferred') already failed to match, lost the
decision and reset the row to open -- safe. A typo INSIDE [a-z] was unsafe.
The parser failed open in the one direction that matters. A row that fails
the enum now yields no prior entry and the row falls back to 'open', by the
same path the capital-D case already took.

The property test could not have caught this: DECIDED is drawn from the
vocabulary, so no property built on it can present an out-of-vocabulary
token. Added JUNK, the arbitrary for the complement, deliberately
lowercase so it stays inside the old capture's own character set -- the
unsafe half is the token that LOOKS like a decision and is not. The new
property also asserts the headline count agrees with the row it renders,
which is the half the defect actually reported wrongly.

Negative control: the new property fails against the ([a-z]+) capture and
passes against the enum.

* fix(#3829): a finding the heading parser cannot match is surfaced, not dropped (B4)

Two independent parsers produce two numbers one paragraph apart -- the
counts from REVIEW.md's frontmatter, the rows from `### <ID>:` heading
matches against a closed CR|BL|WR|IN alternation -- and nothing reconciled
them. A finding the alternation could not reach contributed no row, no note
and no diagnostic, and the ledger then declared `open: 3 of 3` over a set
strictly smaller than the console line had reported one paragraph earlier.
Two findings recorded nowhere, and neither artifact said so.

The PR's own argument for the closed alternation -- that an unlisted prefix
produces no row rather than a MIS-CLASSIFIED one -- is the wrong trade under
this repo's fail-safe rule. A dropped finding is demoted below every finding
that parsed, and an unparseable finding is precisely the one a human most
needs to see.

Block 2 now derives the frontmatter total (anchored inside the findings:
mapping, digit-and-length-bounded like block 1's) and hands it to the
script, which reconciles it against the CURRENT review's matched findings --
order.length, never rows.length, which also counts carried rows and would
either understate the shortfall or invent one. Surfaced exactly as the
stale fix-report case already is: a non-blocking `unparsed: N` key plus the
console line, both naming the two numbers so the claim is checkable.

  Code review disposition recorded: 3 of 3 finding(s) open (2 finding(s)
  recorded NOWHERE: the review reports 5, but only 3 matched the expected
  heading shape `### <CR|BL|WR|IN>-NN: <title>`)

The key is emitted only when there IS a shortfall, so an ordinary ledger
gains no noise key and the unchanged-run check is unaffected.

Four tests, including three negative controls the round owed itself: a
clean review gains no key, an absent/non-numeric total reconciles nothing
rather than fabricating a shortfall, and a total SMALLER than the row count
cannot render `unparsed: -1`. Reversion control: dropping the key turns the
first red.

* fix(#3829): pass --raw to the commit_docs config-get (#3763)

Not from the review -- from a gate the base range added after it. #3763
lands `tests/config-get-raw-guard.test.cjs`, and this branch was its sole
offender: a config-get command substitution without --raw feeds
JSON.stringify output into a bash string comparison, where it silently
never matches for string values. The consumer here is exactly that:

  if [ "$COMMIT_DOCS" = "true" ]

Every other shipped call site in the tree already passes --raw
(spike.md, fast.md, new-milestone.md, sketch-wrap-up.md, ...), so this is
sibling convention, not a new posture.

Worth recording because the two readings are both correct and they
disagree: round 2's review cleared this exact line under ADR-3409 as "the
safe member of that family", since `query config-get <key>` with no --pick
exits 1 on absence and the fallback arm is reachable. That is still true --
--raw does not change it. The base then moved and added a gate that reads
the same line for a different property.

* fix(#3829): scope the count reads to the findings: mapping, not just the frontmatter (m1)

`^[[:space:]]*total:` matches any indented key anywhere in the block, so a
top-level key later named `total:`, `info:` or `critical:` was picked up
ahead of the nested one. The block's own extensive comment is about scoping
the FRONTMATTER, and the scoping stopped one level short of the mapping the
values actually belong to. `status:` was never exposed -- it is anchored to
column 0 because it IS top-level.

The reads now run over the `findings:` block alone, selected by awk and cut
at the next column-0 key. Block 2's REVIEW_TOTAL derivation (added with B4)
already used that filter; this brings block 1 to it, so the two agree by
construction rather than by coincidence.

The mirror models the same scoping, and two fixtures drive it: a top-level
`total: 999` ahead of a nested `total: 1`, and top-level `critical:`/`info:`
ahead of theirs. Reversion control: unanchoring the shipped reads turns them
red.

* fix(#3829): severity comes from the section heading, not just the id prefix (M3)

gsd-code-reviewer.md emits findings under '## Critical Issues' /
'## Warnings' / '## Info', and that heading is the reviewer's own statement
of a finding's severity. The walker already visits every line -- the
fix-report path tracks '## ' sections -- so the signal was in hand and
discarded in favour of the id prefix alone.

A reviewer who mis-numbers a Critical as WR-04 while filing it under
'## Critical Issues' produced a row reading 'warning'. The ledger's Severity
column is the whole basis for triaging it, and it then disagreed both with
the review it summarizes and with the frontmatter count line block 1 prints
from findings.critical.

Section first, prefix as fallback: a finding under no recognized section --
a review that does not use the documented headings, and every row carried
from an earlier review -- keeps the prefix mapping, BL- included. Sections
are matched WHOLE, exactly as the fix-report sections are, so
'## Critical Issues Verification' does not re-tier what sits under it, and
a heading inside a fenced example does not govern.

Five tests: both mis-numbering directions, the prefix fallback across all
four prefixes, the lookalike heading, and the fenced-example case.
Reversion control: prefix-only turns the first two red.

Sixth src.includes() assertion converted with it -- it pinned the exact
source LINE of the enumeration loop, so it went red when that loop was
reformatted while the property it names was strictly widened. It now
asserts the property: every finding id, in order, once each.

* fix(#3829): an untriaged row is carried too, not silently deleted (M1)

The carry-forward kept a prior row only when its disposition was not
'open', so an untriaged row for a finding the current review no longer
reports was dropped entirely. Combined with the reconciliation gap that
left EVERY row open, a re-review deleted the whole ledger.

The re-review loop rewrites REVIEW.md on every iteration, so REVIEW.md does
not retain it either: run 1 records CR-01 open, the re-review renumbers it
to CR-02, run 2's ledger contains neither. That is #3829's complaint
verbatim -- "no trace of what happened to them" -- reproduced by the
artifact built to prevent it. The old justification, "nothing was decided
about it", is exactly the state #3829 says must leave a trace.

Every prior row is now carried, and the carried marker is what keeps it
honest: the row does not claim the finding is live, it records that it was
seen and never triaged. Two costs, stated rather than discovered: a
renumbered finding shows twice until the old row is triaged, and a carried
untriaged row persists until decided. Both are bounded by the phase's own
findings, both are legible from the marker, and both beat a silent delete.

Five tests updated -- they encoded the dropped-untriaged behaviour as the
contract -- plus one new test for the renumbering case M1 names. Reversion
control: restoring the guard turns six red.

Two self-inflicted defects caught while writing this, both by probes round
1 built:

  - Four unescaped backticks in a comment inside the double-quoted node -e
    argument, which bash ran as command substitution. The extractor-parity
    probe fired ("--auto: command not found"). Third time that trap has
    been sprung in this PR, third time the probe caught it.
  - The reworded ledger footer contained the literal carried-marker phrase,
    and the marker-accumulation assertion counts it across the whole file,
    so a doc line read as a second marker. The assertion was right.

* test(#3829): cover the count-length threshold at limit-1, limit and limit+1 (M2)

The guard is `?????????*` -- nine or more characters -- so the limit is
8 digits accepted, 9 rejected. The only cases were 'x', single digits and a
20-digit value, none of which pins the boundary. RULESET.TESTS
boundary-coverage is a hard rule here and it was unmet.

All three points asserted, with the sum kept consistent at each so the
LENGTH rule is what decides the verdict rather than the sum check
incidentally agreeing.

Reversion control is the off-by-one M2 names: dropping one `?` moves the
limit to 7 digits, which no test could previously notice, and now turns
this one red.

* feat(#3829): wire the disposition ledger into the fix path (B1c/B1d)

REQ-REVIEW-09 was unreachable in every shipped path. execute-phase.md's
code_review_gate invokes review with neither --fix nor --auto, so
<NN>-REVIEW-FIX.md cannot exist when the gate runs and every row it writes
is `open` by construction. The operator then runs /gsd:code-review N --fix
by hand -- the very suggestion the step prints -- which writes REVIEW-FIX.md
and never touched the ledger. A phase with 23 findings, all fixed, ended at
`open: 23 / total: 23`: the artifact that exists to distinguish a triaged
finding from a forgotten one asserted that 23 triaged findings were
forgotten. Worse than recording nothing, because it looks authoritative and
is inverted.

Taking remedy (i), not (ii). Narrowing the docs to say the ledger reflects
the previous phase execution is a legitimate choice, but it ships a feature
whose central artifact is inert and then documents the inertness.

ONE ADAPTATION, because the prescribed site does not exist. The review says
to wire code-review.md's --fix/--auto path. code-review.md is not the writer
(gsd-code-fixer writes the report, code-review-fix.md commits it), and more
decisively it has no point that is AFTER the report exists: it delegates
through code-review/steps/dispatch-fix.md, which calls
Workflow(code-review-fix.md) and then exits the workflow. There is nothing
downstream of that call to wire to.

The site is code-review-fix.md, immediately after commit_fix_report. That
is where the report is on disk and committed, it is the canonical
implementation for all fix logic by dispatch-fix.md's own statement, and it
additionally covers a direct invocation of that workflow -- which a wiring
in code-review.md would have missed.

The same step, not a second copy: it consumes PHASE_DIR and PHASE_NUMBER,
both already parsed from the init JSON, and it is idempotent, so a phase
that reaches the gate and then a fix run ends with one ledger reflecting
both rather than two competing ones.

Driven end to end: the gate writes `open: 2 of 2`, the fix path reconciles
to fixed/skipped and `open: 0`. Two tests -- one pins the wiring and its
ordering relative to commit_fix_report and present_results, one drives the
two call sites in sequence. Reversion control: removing the step turns the
first red; the second covers the reconciliation the wiring makes reachable
rather than the wiring itself.

* fix(#3829): a reflowed fix-report title is the same title (m2)

The stale-fix-report guard compared titles with trim() equality. The strict
instinct is right -- ids are reused across re-reviews, so a stale
REVIEW-FIX.md must not mark a brand-new CR-01 as already fixed -- but
gsd-code-fixer.md writes '### {finding_id}: {title}' under no contract that
the title is copied byte-for-byte from REVIEW.md. A fixer that reflows a
long title produced a spurious mismatch note, left a genuinely-fixed row
'open', and told the reader the report named a different finding. That
false-positive mode was acknowledged nowhere.

Whitespace is normalized, and only whitespace: a wrapped title is the same
title, and it is the one divergence that carries no information. Case
changes and truncation stay strict on purpose -- they are the shapes a
genuinely DIFFERENT finding takes, and widening to them would trade a
visible false positive for the silent false negative the strict match
exists to prevent. The residual is now stated in the step rather than left
to be rediscovered.

The note's wording changed with it. It asserted the report "names a
different finding"; both causes reach that branch and the step cannot tell
them apart, so it now reports the observation -- "titles its finding
differently from the review ... a stale report, or a re-titled one" --
rather than a conclusion it has not earned.

Three tests: the reflow case reconciles cleanly, the re-cased case still
reports, and the stale case still reports with the new wording. Reversion
control: restoring the strict comparison turns the reflow test red.

Seventh src.includes() converted -- it pinned the comparison EXPRESSION, so
it went red when the comparison gained normalization while the property it
names was unchanged.

* docs(#3829): describe the flow that ships, not the one implied (m3)

Both reference pages said "/gsd-code-review <N> --fix records fixed and
skipped, which the gate reconciles from REVIEW-FIX.md" -- true in the
abstract, materially misleading in practice, because no shipped path
performed that reconciliation. With B1c/B1d wired it is now real, and the
pages say WHERE it happens rather than leaving a reader to assume the
in-phase gate does it: the gate runs before any fix report exists and
writes all-open, and --fix is what records what happened.

The round's other behaviour changes land here too, since a reference page
that lags the artifact is worse than none:

  - the disposition column is a closed vocabulary, and a value outside it
    falls back to open rather than being treated as a decision
  - severity comes from the section heading when the review uses one, and
    from the ID prefix otherwise
  - an unparsed shortfall is stated rather than dropped
  - titles are compared ignoring whitespace, so a reflowed title still
    reconciles, and a mismatch is reported as an observation rather than as
    a claim that the report is stale
  - EVERY row is carried now, triaged or not, with the cost of the
    renumbered-finding double-entry stated rather than left to be found

docs/FEATURES.md regenerated from the fragment; lint:generated-sync and
lint:docs both exit 0.

* chore(#3829): migrate the emitted-drift ack from a fragment to commit trailers

ADR-3942 landed on next in #3954: the acknowledgment is a git commit
trailer now, and tests/emitted-drift-acks/ no longer exists.

Worth noting for anyone reading the rebase: this did NOT surface as the
modify/delete conflict the migration guidance predicts. This branch ADDED
its fragment rather than modifying an existing one, and the base deleted
only the files that were already there, so the replay was clean and the
fragment survived silently into a directory that no longer exists. Quieter
than a conflict, and worse -- the gate is what catches it, not git.

Two Growth keys rather than the fragment's one: round 2 wired the ledger
into code-review-fix.md, so that file grew too. Both key on the bare
filename, per the Growth namespace.

Emitted-Drift-Ack-Growth: code-review-fix.md — #3829 review round 2, blocker 1c/1d: REQ-REVIEW-09 was unreachable in every shipped path because the in-phase gate runs before any REVIEW-FIX.md exists, so every ledger row it wrote was open and nothing ever reconciled them. This file gains one step, record_disposition, that reads and executes the same lazily-read step after commit_fix_report. It is the only point in the fix flow that is after the report is on disk: code-review.md delegates here through steps/dispatch-fix.md and exits, so it has no such point at all. Growth is one step of prose, no logic is duplicated, and the step is idempotent so the two call sites converge on one ledger.

* chore(#3829): the changeset describes the round's behaviour, not round 1's

It renders into CHANGELOG, so it carries the same misleading implication
minor 3 was about: "the gate ... reconciling fixed/skipped from
REVIEW-FIX.md" reads as though the in-phase gate does it, when the gate
runs before any fix report exists. Says where it happens, and picks up the
round's other user-visible changes -- carried untriaged rows, section-based
severity, the disposition vocabulary, and the unparsed shortfall.

* fix(#3829): a dotted phase number no longer aborts the step

Found by this round's own adversarial review, in its MISSED section: no
finding asked about it, and it is the most serious thing in the round after
the two blockers.

Both callers explicitly accept a dotted phase -- code-review.md:60 and
code-review-fix.md:36 both validate ^[0-9]+(\.[0-9]+)?$ and name "03.1" in
their own error text -- and both fences reconstructed the path with
`printf "%02d" "${PHASE_NUMBER}"`, which cannot format one. Driven with
PHASE_NUMBER=3.1: bash prints `invalid number` and exits 1, and under
`set -euo pipefail` that aborts the step on its FIRST line. An advisory
gate that promises never to block took the phase's entire review report
down with it, and the newly wired fix-path call site inherited the same
defect.

Pad the integer part and carry the sub-number verbatim, so 3.1 -> 03.1 and
3 -> 03, with both arms falling back to the raw value rather than aborting.
Driven: 3.1 now reads 03.1-REVIEW.md and writes
03.1-REVIEW-DISPOSITION.md; the integer path is unchanged.

Two other findings from the same review, both about claims rather than code:

MINOR 2's TEST WAS MIS-NAMED, and the reviewer was right to refute the
claim. It called itself the "reflowed" case while substituting triple
spaces, which is not a reflow. Driven: a genuinely WRAPPED heading is still
not reconciled, because a `###` heading is one line by definition and the
continuation is a separate paragraph. Not widened -- absorbing whatever
follows a heading into the title would swallow arbitrary prose and make the
stale-report check meaningless, and the kept failure mode is the safe one
(a visible mismatch note, never a wrong "fixed"). The test is renamed to
what it covers and the bound is now pinned by its own test.

THE SHELL-SHARING GUARD DID NOT GUARD. Negative-controlling it -- rather
than reading it -- showed that deleting block 2's real REVIEW_FILE
derivation left it GREEN, on the exact defect it was written for. Block 2
prefixes its `node -e` with `REVIEW_FILE="${REVIEW_FILE}" ...` to put the
values in the child's environment, and the detector counted that
self-referential pass-through as a derivation. Pass-throughs are now
excluded, and the control fires. Pre-existing, not introduced here: the
original column-0 anchor matched that same line.

Also worth recording: my first attempt at that control silently patched
nothing and reported clean. Same lesson this PR already learned once.

* fix(#3829): validate the phase number before formatting it, and make the shell guard executable

Three findings from the round review's continuation pass, all confirmed by
driving them.

1. MY OWN DOTTED-PHASE FIX WAS WRONG on the fallback path. `printf "%02d"
   abc` writes `00` to stdout BEFORE it fails, so
   `$(printf ... || printf %s ...)` CONCATENATES the two: `abc` became
   `00abc`, empty became `00`, and a legitimate `08.1` became `0008.1`
   because bash reads the leading zero as octal. An unset PHASE_NUMBER also
   aborted under `set -u` -- in the step that promises never to abort.

   Validate, then format: never format and fall back on failure. Driven
   across every edge the review named -- 3.1 -> 03.1, 3 -> 03, 08.1 -> 08.1,
   09 -> 09, 1.2.3 -> 01.2.3, and abc / empty / -1 / unset carried verbatim
   with exit 0.

2. THE SHELL-SHARING GUARD STILL DID NOT GUARD. Excluding pass-throughs was
   not enough: a structural predicate recognises assignment TOKENS, never
   assignments that derive a usable value, so `REVIEW_FILE=`,
   `REVIEW_FILE=$REVIEW_FILE` and a commented-out assignment all evaded it.
   No regex closes that class.

   The authority moves to execution -- the third time this PR has learned
   that lesson. The real second fence now runs in a fresh shell with nothing
   but the step's two declared inputs and must write the ledger at the
   correct derived path. All four mutations are caught: empty assignment,
   self-reference, commented-out, and deletion. The textual check stays as a
   cheap fast-fail and is labelled as one.

3. THE TITLE-BOUND CORRECTION HAD NOT REACHED THE DOCS. The step comment and
   both docs pages still said a reflowed title reconciles, contradicting the
   bound pinned one commit earlier. Superseded prose left standing reads as
   current to anyone arriving cold, so all three surfaces are rewritten
   rather than annotated, and FEATURES.md regenerated.

Also hoisted `HAS_BASH` to the file's other top-level constants. `const` is
in the temporal dead zone until its declaration runs, and a
`{ skip: !HAS_BASH }` option object is evaluated eagerly, so a bash-gated
test added above the old mid-file declaration threw a ReferenceError that
aborted its whole describe and CANCELLED its siblings -- while the summary
line still read `fail 0`. It caught three separate additions in this round
before I stopped moving tests and moved the constant.

* fix(#3829): refuse an out-of-shape phase number instead of carrying it into a path

Self-found while writing the prompt for the next review pass, which is the
honest provenance: I asked the reviewer whether a path traversal was
reachable through PHASE_NUMBER, then checked before dispatching.

It was, and I had introduced it. The previous commit's fallback carried an
unusable phase number VERBATIM, and PHASE_NUMBER is interpolated into a file
path:

  PHASE_NUMBER='../../etc/passwd'
  -> REVIEW_FILE=/tmp/phase/../../etc/passwd-REVIEW.md

The `printf "%02d"` it replaced had at least mangled that to `00`. A fix
that makes a path more reachable than the bug it replaced is a regression,
whatever it does for the case it was written for.

Both callers already validate ^[0-9]+(\.[0-9]+)?$ (code-review.md:60,
code-review-fix.md:36), so this is defense in depth rather than a live
exploit -- but the step has two call sites now and should not take either
caller's word for its own inputs. It validates the WHOLE value and, on
failure, builds no path at all: PADDED is empty and each fence refuses by
name rather than coercing. Block 1 declines to report counts read from a
path made out of the bad value; block 2 declines to write, which also keeps
it clear of the bare-name ledger defect round 1 closed.

Driven across the shape boundary: 3.1 / 3 / 08.1 / 09 accepted; abc, empty,
unset, 1.2.3, -1, 3., .1, +1, "3 1" and ../../etc/passwd all refused with
exit 0 and a named diagnostic. Reversion control: restoring carry-verbatim
turns the traversal test red.

* fix(#3829): bound the phase number's length, and make the shell guard prove derivation

Third adversarial pass. Two of its three refutations were already closed by
the previous commit (the ../escape and 1/../../escape traversals, and the
unset-input abort); these two were not.

1. A 54-DIGIT PHASE NUMBER WRAPPED SILENTLY. The validator accepted any
   all-digit value, so `$((10#$_int))` overflowed 64 bits and PADDED became
   `-7908320945662590977`. Length-bounded now at 8 digits, exactly as the
   counts already are and for the identical reason -- and the counts guard
   sitting twenty lines away is why this one is embarrassing rather than
   subtle. Driven at the boundary: 8 digits accepted, 9 rejected.

   The bare `${PHASE_NUMBER}` in the suggestion line is hardened to
   `${PHASE_NUMBER:-}` while here. The empty-PADDED guard makes it
   unreachable today, but it is one refactor away from an unbound-variable
   abort under `set -u`, in the step that promises not to abort.

2. THE EXECUTED SHELL GUARD PROVED THE FENCE WORKS, NOT THAT IT DERIVES.
   A single-phase probe is satisfied by a hardcode, and the review
   demonstrated exactly that: replacing the derivation with
   `case ... in 1) PADDED=01 ;; 7) PADDED=07 ;; *) PADDED=07 ;; esac`
   breaks every real phase and passed the entire suite. It now runs two
   distinct phases, 7 and 3.1 -- a hardcode cannot satisfy both, and the
   dotted one additionally pins the integer-part split.

   The claim "given only the declared inputs" was also overstated: the test
   spreads `...process.env` (it needs PATH and HOME). The DERIVED names are
   now explicitly deleted from that environment, so the claim is true rather
   than merely intended.

Also rewrote a comment that had become false: it pinned a describe to the
end of the file because of the HAS_BASH temporal-dead-zone constraint, which
the hoist removed. Superseded prose left standing reads as current to
anyone arriving cold.

The changeset's "the gate stays advisory and never blocks" is now verified
rather than asserted: both fences exit 0 under an unset PHASE_NUMBER and a
traversal-shaped one.

* fix(#3829): validate both inputs, refuse before building a path, and never write through a symlink

Fourth adversarial pass. Four findings, all confirmed by driving them.

1. PHASE_DIR WAS NOT VALIDATED AT ALL. Unset, both fences died with
   `PHASE_DIR: unbound variable` under `set -u` -- the same class as
   PHASE_NUMBER, which I had just spent two commits fixing while its sibling
   input sat one line away. The step declares two inputs; it now validates
   two.

2. THE LENGTH BOUND WAS ON THE WRONG THING. The nine-character glob applied
   to the WHOLE value rather than the integer part, so it falsely rejected
   `12345678.1` (a legal 8-digit phase) while accepting `1.123456`. Each
   component is bounded on its own now; the sub-number is bounded too, since
   it is likewise interpolated into a filename.

3. REJECTED VALUES STILL HAD PATHS BUILT FROM THEM. The refusal guard sat
   AFTER the assignments, so an unusable input still assembled
   `${PHASE_DIR}/-REVIEW.md` and stat'ed it before refusing. The guard is
   now the first thing after validation, and both fences construct paths
   from validated locals rather than from the raw environment.

4. THE LEDGER WRITE FOLLOWED SYMLINKS. From the review's MISSED section, and
   the sharpest thing in it: `fs.writeFileSync` follows a symlink, so a
   pre-existing symlink at the ledger path replaced the contents of whatever
   it pointed at -- outside the phase directory, with the link left intact
   so nothing looked wrong. Driven, and the target's contents were gone.
   This PR introduces the artifact, so it owns the check: an existing ledger
   that is not a regular file is not a ledger, and the advisory gate says so
   and steps over.

The executed shell guard now draws its phases AT RUN TIME. Fixed fixtures
cannot establish derivation -- the review defeated the one-phase version
with a hardcode, then defeated the two-phase version by adding one more arm
to the same case. Any finite sample loses that race. A phase picked per run
cannot be enumerated in advance; the drawn values print in every assertion
message so a failure stays reproducible. Control: the review's three-value
hardcode now fails on three consecutive runs.

Eighth src.includes() converted -- it pinned the literal `${PHASE_DIR}`
interpolation and went red when construction moved to a validated local,
while "writes a REVIEW-DISPOSITION sibling" was untouched. It now asserts
that property, and that REVIEW.md is not written.

The changeset's "stays advisory and never blocks" is verified rather than
asserted: 8 of 8 hostile-input cases across both fences exit 0 -- both
inputs unset, PHASE_DIR unset, a traversal-shaped phase, and a missing
phase directory.

* fix(#3829): check the ledger path before reading it, and pin the write-safety behaviour

Fifth adversarial pass, and the last one this round. Three fixes, three
disclosed residuals.

FIXED

1. A FIFO AT THE LEDGER PATH BLOCKED FOREVER. readFileSync on a FIFO never
   returns, so the step documented as "advisory, never blocks" blocked
   indefinitely -- the literal counterexample to its own headline claim. The
   non-regular-file check ran after that read.

2. THE UNCHANGED-RUN FAST PATH BYPASSED THE CHECK. A symlink whose target
   already matched the rendered ledger read through the link, reported
   `unchanged`, and never reached the refusal.

   Both fixed by the same move: the check is now the FIRST thing the script
   does, before any read or write of that path. Ordering was the defect, not
   the predicate.

3. THE COMMIT TEST FOLLOWED THE LINK the script had just refused. `[ -f ]`
   resolves symlinks, so the guard and its consumer disagreed about the same
   path and the helper could still be handed one. `[ ! -L ]` added.

   And the behaviour shipped with NO regression control -- I hand-drove it
   last commit and did not pin it, which the review caught by grepping for
   the words. Five tests now: symlink, symlink-with-matching-target, FIFO,
   directory, and an ordinary ledger as the negative control so the refusal
   is not a blanket one. mkfifo goes through the process seam like every
   other spawn here.

DISCLOSED, NOT FIXED -- these are stated in the step rather than carried
silently:

- TOCTOU between the lstat and the write. Node exposes no portable
  O_NOFOLLOW write, and an attacker who can write into the phase directory
  mid-run already has what the check would protect. It narrows a real
  accident; it is not a security boundary and the docs claim none.
- A hard link passes isFile() by construction.
- The REVIEW.md and REVIEW-FIX.md reads still resolve symlinks. They are
  reads of files the operator owns, in their own phase directory.

Also narrowed a comment that overclaimed. The randomized guard's domain is
FINITE -- 88 integer and 792 dotted values -- so a mutation enumerating all
880 passes forever, and Math.random() is unseeded, so "reproducible" means
only that the drawn values are printed on failure. Raising the bar is what
it buys; proving derivation is not, and nothing short of reading the fence
is. The previous comment claimed otherwise and was refuted.

* test(#3829): make the write-safety controls portable to the Windows lane

CI caught what neither the local suite nor five adversarial review passes
could: every one of those ran on Linux.

The FIFO test gated on `mkfifo`'s exit code. On the Windows lane mkfifo
EXISTS and exits 0 while producing something that is not a FIFO, so the
guard passed, the test ran against an ordinary path, the ledger wrote
normally, and the assertion failed for a reason unrelated to the behaviour
under test. It now gates on `lstatSync().isFIFO()` -- what was actually
created, not what the command claimed. Control: with the shipped guard
disabled the test still goes red on Linux, where the FIFO is real.

The two symlink tests are skipped on win32, following this repo's existing
convention for symlink-planting tests (tests/settings-jsonc.test.cjs:389
skips the same class; tests/unreachable-guard-drift.test.cjs:726 records the
reason -- symlink creation requires elevated privileges on Windows CI). The
privilege happened to be available on the lane this round, which is exactly
why the convention is not "try it and see".

* fix(#3829): a bare `|` in a deferral reason is prose, not a parse failure

Review round 3, the one blocker. The Source cell is the one field this ledger asks a
human to hand-edit, and "waiting on team A | team B to align" is an ordinary thing to
type there. The prior-row capture admitted a pipe only when escaped, so a bare one
failed the WHOLE line: prior.get() was undefined, the row fell through to `open` with
an empty Source, and the console line read "1 of 1 finding(s) open" — a Critical a
human explicitly deferred, with a documented reason, rendered indistinguishable from
one never triaged, and the reason gone. The exact ambiguity #3829 exists to remove,
reachable by one missing backslash.

The Source cell is the LAST column, so it is now captured through to the end of the
line, less an optional trailing pipe; a bare `|` inside it is prose. The render
escapes a bare pipe on the next write so the table stays a table, and the escaped
form re-parses to itself, so the second run reports `unchanged` — the fixed point
holds. The ledger's own instruction line says so instead of asking the human to
escape.

Why the property never caught it: SOURCE_CELL only ever appended a PRE-ESCAPED pipe,
so the arbitrary built to stress this cell could not reach the one input that broke
it. It now also emits a bare pipe, and the round-trip expectation is the escaped
form of what the human wrote. A fixed regression case drives the reviewer's exact
input through two runs and asserts the decision, the reason, the headline count and
convergence. Negative-controlled: both new tests fail against the previous capture.

The src.includes() pin on the old capture text is retired for the behavioural case —
it was pinning the defect.

* fix(#3829): escape every bare pipe in one write, whatever precedes it

Round 3, found by the adversarial pass over the round's own fix rather than by the
review. The first escape used /(^|[^\\])\|/g, which CONSUMES the character before the
pipe: adjacent bare pipes were escaped one per run (A||B -> A\||B -> A\|\|B, a third
run to converge, breaking the advertised second-run fixed point), and an escaped
backslash before a pipe (A\\|B) hid the pipe behind the wrong parity and left it bare
in the rendered table. The property generator emits at most one bare pipe, which is
the one case the old form got right, so no property reached either.

Scan as pairs instead: an escaped pair (backslash + anything) is kept verbatim and only
a pipe outside one is escaped. One write, then a fixed point. Regression case drives
`A||B and C\\|D` through two runs; it fails against the previous escape.

* fix(#3829): the script leaves by return, so an explicit exit cannot drop its verdict line

Round 3, from the adversarial pass over the round's own fix. The embedded node script
printed its verdict and then called process.exit(0) -- on the 'unchanged' branch only;
the 'recorded' branch fell off the end. Node's "A note on process I/O" documents
process.stdout writes to pipes and sockets as asynchronous on POSIX, and process.exit()
as forcing exit before pending asynchronous stdout writes complete -- so on a POSIX
lane the caller can see exit 0 with no verdict line. This is a hardening against that
documented hazard, not a reproduced defect: the reviewer's empty-second-run stdout,
which first pointed here, turned out to be its own sandbox -- a bare console.log child
printed nothing there either -- and that attribution is withdrawn.

The script now runs inside main() and leaves by return on all four early-exit paths,
so the event loop drains stdout before the process ends. Same exit status either way,
and the || echo fallback is unaffected. A structural test pins the absence of the call
(comment-stripped; dotted, bracketed and whitespace-split spellings). The empty-review
docs-parity pin that asserted the literal process.exit(0) line is retired -- the
round-2 describe drives that property behaviourally.

Also widens the property generator: SOURCE_CELL now reaches adjacent pipes and a
backslash of either parity before a pipe, the two shapes the first render escape got
wrong while passing every input the generator could then produce -- checked against
an independent parity-walk oracle rather than a copy of the render's own scan.

* fix(#3829): record what an --auto iteration fixed, instead of reporting it open

Round 5's major. `record_disposition` runs once, after the whole capped-at-3
`--auto` loop converges — but this workflow keeps ONE final version of REVIEW.md
and REVIEW-FIX.md rather than per-iteration copies, and deletes the .iterN.md
backups on convergence. A finding fixed in iteration 1 was therefore absent from
the final review (it was fixed, so the re-review stopped reporting it) AND from
the final fix report (overwritten by the last iteration), so the row fell back to
the gate's `open` and rendered `open ... (not in the current review)` — the same
bytes a finding that vanished for an unrelated reason produces. That is the one
distinction #3829 exists to make, undone by the artifact built to make it.

The precise site was the two-arm `applied` construction: for an id the current
review does not report, `sameTitle(undefined, h.title)` is false and
`title.has(id)` is false too, so the entry entered NEITHER `applied` NOR
`staleFix`. It was dropped in silence.

Four changes, one defect:

- A third arm. When the review does not report an id at all there is no title to
  disagree with, so this is not the stale-report case — it is what a finding
  looks like once it has been acted on. Record it. The id-reuse hazard stays
  closed by the arm below it: when the review DOES report the id, a title
  mismatch still goes to `staleFix` and is never applied, so a renumbered
  finding cannot inherit an earlier iteration's `fixed`.
- Rows for decided ids the review no longer reports, carried and marked. A
  decision the ledger cannot render is a decision lost — the same silent drop
  the carry-forward loop already refuses for prior rows, one source over.
- The .iterN.md fix-report backups are read alongside the final report, newest
  first, so the most recent statement about an id wins — the precedence a
  duplicate id already gets within one report.
- The shell guard proceeds on a fix report, not only on an existing ledger. A
  direct `/gsd-code-review N --auto` writes no gate ledger, and a converged loop
  leaves `status: clean`, so a fully successful multi-iteration run recorded
  nothing at all.

And the backups now go in `cleanup_iteration_backups`, after the ledger has read
them. #3190's rule is untouched — spent scratch on convergence, retained on
degradation — only the timing moved; deleting them inside the loop erased every
early fix before anything read it. `CONVERGED` does not survive the loop's shell
and is re-derived from the final review's status, which is exactly how the loop
sets it; anything but a proven-clean review retains.

Seven new regression tests plus an ordering test, all eight reversion-controlled
against pre-fix code — every one fires. One is the negative control that matters:
a reused id whose title differs must stay `open`, never inherit `fixed`.

Residual, stated: an id appearing only in an iteration fix report takes its
severity from the id prefix rather than a section heading, because `sectionSev`
is built from the current review. That is the documented fallback for carried
rows, not a new gap.

* test(#3829): pin the two PADDED derivations against a silent desync

Round 5's minor 1. Each fenced block runs in a fresh shell and must derive what
it reads, so the PADDED derivation — the traversal fence between an
attacker-influenceable phase number and a file path, plus the per-component
length bound — is duplicated verbatim. Both copies were independently tested and
nothing asserted they stay in step, which is the shared-parallel-surface shape
CLAUDE.md requires a parity test for, on security-relevant validation logic
rather than incidental repetition.

Compared line by line rather than through a normalizing rewrite: a normalizer
has to be told what may differ, and whatever it is told to tolerate stops being
asserted. Exactly one line may differ — each block refuses by its own name — and
the test names both forms. It also asserts the slice is substantial, since a
parity test over an empty slice passes vacuously.

Control: dropping one `?` from block 2's length bound, which moves that copy's
limit to 7 digits while block 1 keeps 8, turns it red. That is the exact silent
divergence the finding describes.

One correction to the finding's own statement, since it is worth recording: the
cited lines are :324 and ~:480, which are node-script lines; the derivations are
at :48-83 and :211-246. And they are 35-of-36 identical rather than
byte-identical — the refusal message differs, deliberately.

* docs(#3829): state the PHASE_DIR trust boundary instead of carrying it

Round 5's minor 2 asked that the assumption behind PHASE_DIR's validation be
confirmed rather than silently carried forward at the two new call sites. It is
confirmed, and the comment that stood here was wrong about it: "PHASE_DIR is the
step's other declared input and gets the same treatment" describes something the
code does not do.

Both inputs have the SAME provenance — each caller binds them from
`gsd_run query init.phase-op` (code-review-fix.md:7,17; execute-phase.md the
same) — so neither is raw user input and neither is more trusted. The asymmetry
is not about trust. It is that only one of them has a shape: PHASE_NUMBER
carries a documented contract, `^[0-9]+(\.[0-9]+)?$`, asserted by both callers,
so a value outside it is provably wrong and is refused. PHASE_DIR's contract is
"a filesystem path", which admits `..`, absolute and relative forms and
symlinked parents alike; no predicate separates a legitimate planning directory
from an illegitimate one, so a shape check would reject working setups while
proving nothing.

So the emptiness check is adopted as what it actually is — the guard against
`PHASE_DIR: unbound variable` aborting a step that promises never to block — and
the shape check is declined, with the reason written where the next reader meets
it rather than left to be re-derived.

The residual is restated in place rather than left in a PR comment: PHASE_DIR
may itself be a symlink and the ledger is then written through it, outside the
phase directory, deterministically. Left alone deliberately — the write goes
where the caller pointed. Not a security boundary, and nothing here claims one.

* docs(#3829): record why HAS_BASH is a platform assumption, not a probe

Round 5's minor 3 is DECLINED, and the reason is the repo's own contract rather
than a judgement call — written at the constant so the next reader does not
"fix" it and re-enable what the rule exists to prevent.

The gap is real and confirmed: 22 tests carry `{ skip: !HAS_BASH }`, so block
1's bash severity-reporting path has no Windows-lane coverage. But
`local/no-unguarded-nonportable-exec`
(eslint-rules/no-unguarded-nonportable-exec.cjs, DEFECT.WINDOWS-TEST-PORTABILITY)
REQUIRES this guard around `sh -c` / `bash -c` in tests, and its own remedy text
names `if (process.platform !== 'win32')` as the sanctioned form, because these
constructs fail under Windows Git Bash. So the constant is the repo's answer to
this question, not an oversight in this PR.

Swapping it for a runtime `bash` probe would light 22 tests up on a lane the
rule has already determined they cannot pass — trading a legible, rule-encoded
skip for a red matrix. Reversing that is the rule's decision; a change here
belongs with a change there.

* docs(#3829): describe how --auto's iterations reach the disposition ledger

The reconciliation section described the `--fix` path accurately and said
nothing about `--auto`, which is where round 5's major lived. It now states
that the loop overwrites its fix report each pass, that the re-review drops a
finding once it is fixed, that the gate therefore reads the per-iteration
backups newest-first, and that the backups are removed after the ledger has read
them rather than before. It also states the converged-with-no-ledger case: a fix
report on disk is reason enough to record.

FEATURES.md regenerated (176 features / 21 groups). Changeset extended to name
the shipped behaviour rather than only the `--fix` half.

* fix(#3829): clear lint-workflow-shellcheck, a gate the base range added

Not from the review. The rebase onto `next` brought in `lint-workflow-shellcheck`
(#4109), whose baseline was generated before this PR's new step file existed — so
that file's findings are new by construction and `lint:ci` exited 1 on the
rebased head before this round touched anything. The last green CI run predates
the gate. Caught locally rather than by a red push.

Three fixes and one baseline entry, split by whether the finding is real:

- STRUCTURAL (not ShellCheck, not baselineable): the guard's
  `for _f in "…${PADDED}-REVIEW-FIX.iter"*.md` is the bare `for x in $VAR` shape
  that word-splits differently under bash and zsh. Wrapped in
  `$(printf '%s' "$PADDED")`, the linter's own prescribed remedy.

- SC2097/SC2098, and this one was a genuine latent bug rather than a lint nit:
  `FIX_REPORT_FILE="${_pd}/${PADDED}-REVIEW-FIX.md"` sat in the same env-prefix
  list that sets `PADDED`, so its `${PADDED}` expanded the OUTER variable, not
  the one two entries earlier. Both happen to hold the same value here, which is
  exactly why it would have kept being wrong quietly. Built before the command
  now.

- SC2317 ×3 is baselined, not fixed. It fires on
  `return 0 2>/dev/null || exit 0` — the deliberate idiom that lets a fence
  refuse whether it is sourced or executed — and the verdict is a false
  positive: the `exit 0` is reached precisely in the executed case. Rewriting a
  dual-mode refusal to satisfy a wrong unreachability claim trades a real
  behaviour for a clean report. Baseline 207 -> 210.

`lint:ci` exits 0. 173 tests pass across the two touched files.

* fix(#3829): a reused finding id no longer inherits the old finding's decision

Found by this round's own adversarial review, which drove it rather than
reasoned about it — and it refuted the arm I had named as my strongest
suspicion, so it is recorded as a correction, not a discovery.

Finding ids are reused across re-reviews: the --auto loop renumbers. `row()`
inherited a prior decision on an id MATCH ALONE, with nothing checking it was
the same finding. Driven: a prior `CR-01 fixed` row against a review reporting a
brand-new CR-01 rendered the NEW finding `fixed`. A false decision in the
artifact whose entire purpose is telling triaged from forgotten — the same
failure mode round 4's blocker was, reached by the other door.

I had argued this was closed by the stale-report arm. It is not: that arm guards
the FIX-REPORT path only. The PRIOR-LEDGER path had no title check at all.

- The ledger now records each finding's title, in the FRONTMATTER rather than a
  fifth table column: the Source cell is the field a human hand-edits and the one
  that must escape pipes, and a second free-text column doubles that surface for
  no reader benefit.
- A prior decision is inherited only when the recorded title still matches. An
  ABSENT prior title inherits, deliberately — a ledger written before titles were
  recorded carries none, and refusing there would reset every decision in it,
  which is the loss this guard exists to prevent, caused by the guard.
- A decision whose id has been reused is PRESERVED under a `superseded:` key
  rather than dropped. The review's driven refutation was precisely that the
  mismatch was surfaced while the decision was lost. It cannot keep a row — the
  id is taken, and two rows under one id is an ambiguity, not a record — so it is
  carried in the frontmatter, re-emitted every run, deduped by id+title, and
  named on the console.
- And an iteration-derived decision now cites the report it actually came from.
  The Source cell hard-coded the unsuffixed `<NN>-REVIEW-FIX.md`, so a decision
  read out of an iteration backup cited a file that may not exist. A citation the
  reader cannot follow is worse than none. Also the review's finding.

Five new tests. Four fail against the pre-fix step; the fifth — that a ledger
with no recorded title still inherits — is a BACK-COMPAT guard and passes both
ways by construction. It is not a reversion control and is not counted as one.

* fix(#3829): follow the cleanup move through, and stop miscalling a converged run

Three loose ends the earlier cleanup relocation left, two of them found by the
round's own review and one by the suite.

**#3190's own test still pinned the old placement.** T6 asserted the `.iterN.md`
removal lives inside `auto_iteration_loop` — exactly what moving it broke. Its
SEMANTICS are unchanged and still asserted: removed on convergence, retained on
degradation, creation intact. What it now pins additionally is the ordering that
forced the move — the ledger reads the backups BEFORE they are removed — and that
the loop no longer removes what it just wrote. Rewritten rather than deleted: the
assertion was superseded, the guarantee was not.

**`CONVERGED` had become a decoy.** With the removal gone from the loop, the flag
was set in two places and read in none. Deleted, and the prose that still said
"the loop sets it" rewritten to what is true: the loop breaks on exactly one
condition, a clean re-review, which leaves REVIEW.md at `status: clean` — and that
is what `cleanup_iteration_backups` re-derives from.

**A converged final iteration reported the opposite of what happened.** The
post-loop message keyed on the iteration COUNTER alone, so a run that converged ON
iteration 3 exited with `ITERATION == MAX_ITERATIONS` and printed "Reached maximum
iterations. Remaining issues documented in REVIEW-FIX.md" over a run in which
every finding was fixed. Convergence is re-derived from the review the loop left
behind — the same signal the cleanup step reads, so the two cannot disagree.

* docs(#3829): retract two claims this round made and could not support

Both were caught by the round's own adversarial review, both were driven, and
both would have reached the maintainer. Recording the retraction where the claim
was made, rather than only in a PR comment.

**The env-prefix "latent bug" does not exist.** An earlier commit in this round
claimed that `FIX_REPORT_FILE="${_pd}/${PADDED}-REVIEW-FIX.md"`, sitting in the
same `node -e` env-prefix list that sets `PADDED`, expanded the OUTER variable
rather than the one two entries earlier — reading ShellCheck's SC2097/SC2098 as
a defect report. Driven in bash and in dash: assignments in one prefix list take
effect left to right, and the later entry DOES see the earlier one. The warning
is a false positive here. The split is kept, but for readability only; the
comment no longer describes it as a fix.

**The HAS_BASH decline rested on a rule that does not govern these call sites.**
It cited `local/no-unguarded-nonportable-exec` as REQUIRING the
`process.platform !== 'win32'` guard. Checked, and wrong on both halves: the rule
fires only on a file that also chmods an exec bit with an octal literal, and this
file has none — so it never runs here — while
`eslint-rules/lib/platform-guard.cjs` accepts four guard shapes plus
`os.platform()`, not one. A constraint that exists is not a constraint that
applies, and I did not check which.

The decline stands on narrower and honest grounds: whether these fences PASS on
the Windows lane is UNVERIFIED. What evidence there is points at divergence
rather than absence — the rule's subject line is that `bash -c` constructs "fail
on Windows Git Bash", and this PR already measured `mkfifo` existing on that
runner, exiting 0, and creating no FIFO. So a probe would not be a clean win; it
would light 22 tests on a lane whose shell semantics are known to differ and
unknown in detail. That is a measurement to make deliberately, not a change to
make in passing. The gap is real and is now stated as a gap.

* fix(#3829): close four defects the review drove out of the first title fix

The round's own adversarial review re-ran against the reworked tree and refuted
two more claims. Every item below is its finding, verified before acting.

**An iteration-only decision recorded no title, so the reuse guard leaked.**
`applied` stored `{d, src}` and the row took its title from the current review —
which does not report the finding at all. The row shipped with no title, and the
next review reusing that id hit the title-ABSENT back-compat exception and
inherited the old `fixed`. The exact defect the title machinery exists to close,
surviving through the hole opened for legacy ledgers. `applied` now carries the
title it was decided under.

**A changed decision was dropped in favour of the obsolete one.** The dedupe was
a has()-guard, so re-superseding a finding whose decision had since changed left
the older record standing. It now replaces.

**Re-spaced titles double-recorded.** The dedupe keyed on the raw title while
`sameTitle()` collapses whitespace; the key now agrees with the comparison.

**And the frontmatter was not valid YAML.** `title: Parser: loses data` is
rejected outright by a real reader, and the `superseded:` line format was not
YAML at all. Values are emitted as JSON scalars — YAML 1.2 is a JSON superset —
and superseded records are properly nested. Round-tripped through js-yaml in the
tests.

One more, self-inflicted while fixing the above: the parse registered each
carried superseded record TWICE, once at `- id:` under an empty-title key and
again at `title:`. Records doubled on every run. They are collected during the
walk and registered once, complete.

**T6 was vacuous.** The review flipped `= "clean"` to `!=` in the cleanup and the
rewritten T6 still passed — it greps for `FINAL_STATUS`, `rm` and "retained"
occurring somewhere, never wiring them to a branch. T6b now EXECUTES the fence in
both directions against real files. It fails on that exact mutation.

**And a converged final iteration printed two success messages** — the loop's
break already reported it. This branch now stays silent and exists only to
withhold the degradation warning.

Three CI gates the base range brought in, all tripped by this round's own text:

- `/gsd-code-review` in a comment — runtime workflow artifacts take the colon
  form. Now `/gsd:code-review`.
- The preamble-ordering parity test: my PHASE_DIR comment wrote the literal
  `gsd_run` before the shim preamble. Reworded.
- Prompt-stuffing: the file passed 50K. I trimmed 5.8K of my own commentary
  first; even removing every added comment leaves the added CODE over the line,
  and the file entered this round at 44,523 — 89% of the budget. Added to
  SIZE_ONLY_WORKFLOWS with the same reasoning the two existing entries carry, and
  the same acknowledgement: splitting is the real fix.

* test(#3829): extract the cleanup fence without an ad-hoc markdown regex

T6b's helper used `/```bash\n([\s\S]*?)\n```/`, which trips two of the repo's own
rules: `local/no-adhoc-markdown-parsing` (use the sectionizer, not a hand-rolled
fence regex) and `local/no-crlf-fragile-split` (a bare `\n` against readFileSync
content is wrong under Windows autocrlf).

Line-scanned now, CRLF-normalized first — the same shape `bashFences()` in
tests/code-review-pipeline-regression.test.cjs already uses, which solved this
first. `npm run lint` is clean and T6b still fails on the inverted-branch
mutation it exists to catch.

* fix(#3829): withdraw the superseded-decision store; keep the identity guard

Three adversarial passes over this round each found real defects, and passes 2
and 3 were entirely inside the `superseded:` block added in pass 1 — a second
identity scheme, keyed on (id, title), living beside the row store keyed on id.
Pass 3 refuted it on three separate counts: a legacy title that merely looked
like JSON lost its quotes and fabricated a record; a finding that was deferred,
superseded, then returned and fixed left an active row and an obsolete
superseded record standing together, reporting `unchanged` forever; and my own
test for the replacement path never passed the earlier ledger in, so it guarded
nothing.

The construct had no terminal state. It is withdrawn.

**What survives is the safety property.** The ledger records each finding's
title, and a recorded decision is carried forward only while the id still names
the same finding. That is what stops a renumbered `CR-01` inheriting an earlier
`CR-01`'s `fixed` — a false decision in the artifact whose purpose is telling
triaged from forgotten, and the same class as round 4's blocker.

**What is given up, and it is disclosed rather than hidden.** On a detected
reuse the earlier decision loses its row. The drop is reported on the console
naming the id and what had been decided, the previous ledger is committed so the
row remains in git, and docs/features/code-review-pipeline.md states the
limitation.

Two defects from pass 3 are fixed rather than deleted, because they are in the
guard and not the store:

- **Known-empty and NOT-KNOWN were conflated.** `### CR-01:` yields an empty
  title; that is a title. While it emitted no `title:` key it read back as a
  pre-format ledger and inherited across a reused id — the same leak, three
  passes running. Emitted whenever the title is known, empty included; a carried
  row no source knows stays absent, which is the legacy-compatible read.
  Underneath it was a falsy fallback: `(act && act.t) || priorTitle.get(id)`
  discards `''`. Now a typeof check.
- **JSON.parse ran on legacy values.** A pre-format ledger whose bare title was
  written `"quoted"` was parsed and lost its quotes, so the decision stopped
  matching. The frontmatter now declares `titles: json` and the parse is gated on
  it; a ledger without the marker keeps its scalars.

One defect from pass 3 is NOT mine and is not fixed here: a converged run prints
a success message from the loop break AND another from `present_results`. Both
predate this round. My earlier claim that "the duplicate is gone" was true only
of the pair I introduced; the pre-existing pair stands, and widening this round
into `present_results` is not warranted.

188 tests pass. The three new tests fire against the pre-simplification step.
`lint:ci` exits 0. The step file is 55,590 chars, down from a 62,220 peak.

* docs(#3829): stop the ledger promising a preservation it no longer makes

Fourth review pass. No machinery defects this time — both findings are claims in
text this step SHIPS, which is the class this whole stack exists to prevent.

**The rendered ledger still said "Re-running the gate preserves every row and
every disposition."** That was true until the same round gave the step an
intentional drop for a reused finding id, and then it was false in the artifact's
own user-facing footer. It now states what the step does, including the one
exception, where a reader actually meets it.

**And the console asserted "the previous ledger is in git."** Committing the
ledger is gated on `commit_docs`, and a failed commit is swallowed — so under
`commit_docs=false` the overwritten decision may exist nowhere. The note reports
the drop and stops there; asserting a recovery path that may not be there is the
same overclaim in a smaller font.

Two residuals from the same pass are DECLINED and documented rather than fixed,
because both would need the second identity scheme just withdrawn:

- A pre-titles ledger carries no titles, so its decisions inherit on the id
  alone. Refusing there resets every decision in every existing ledger, which is
  the loss the guard exists to prevent.
- Two genuinely distinct findings sharing both an id and a title are
  indistinguishable to an (id, title) key.

The pass also refuted the `titles: json` marker on a ledger written by
`b86ea6065^`, which emitted JSON titles before the marker existed. Declined:
that revision is an intermediate commit on this unpushed branch and has never
been released. The PR's published head writes no titles at all, so a real ledger
is either pre-titles (unmarked, bare — handled) or written by the shipped version
(marked). The unmarked-JSON state cannot reach a user.

Test pinned, and it fails against the pre-correction step.

* docs(#3829): fix four wrong citations and one false size justification

All four came out of a claim-audit of this round's own response comment — an
audit of the text, not the code, which is where the remaining errors were.

- **The caller citation was wrong.** The in-code note said both inputs bind from
  `gsd_run query init.phase-op`. `execute-phase.md:85` uses `init.execute-phase`;
  only `code-review-fix.md:22` uses `init.phase-op`. The substantive point is
  unchanged — both are orchestrator-derived, neither is raw user input — but the
  citation was not checked.
- **A leftover "the prior row is in git."** Removed from the console note last
  commit, left standing in the comment two lines above it.
- **The docs still carried the promise the ledger had just dropped.** The
  rendered footer was corrected; the same sentence in
  `docs/features/code-review-pipeline.md` was not.
- **The SIZE_ONLY_WORKFLOWS justification was false.** It claimed the added CODE
  alone exceeded the threshold. Removing every round-added comment leaves 47,148
  chars against a 50,000 limit, so the file CAN fit — the claim was wrong, and an
  exemption defended on a wrong premise is worse than no exemption.

So the entry is re-justified on what is actually true, and earned first: another
**10,188 chars** of this round's own commentary are cut (62,220 → 52,032, from a
44,523 baseline that was already 89% of the budget). Fitting under is possible
only by stripping essentially all remaining explanation from logic three review
passes found defects in. That is the wrong trade in a file whose house style is
heavy in-fence documentation, and the entry says so rather than implying the
file had no choice.

One measurement corrected while checking: the Windows-lane skip count is **37**,
not the 22 the review cited nor the 26 I first counted. Twenty-two and 26 count
`{ skip: !HAS_BASH }` CALL SITES; a skip on a `describe` cancels its subtests.
Forced the constant false and counted what actually skips.

236 tests pass. `lint:ci` exits 0.

* fix(#3829): the drop report is conditional, and two published claims were not

A fifth adversarial pass, run against the two commits that went out AFTER the
fourth pass and were never reviewed, refuted three claims this round published.

1. The drop is NOT reported unconditionally. `row()` reports only a RECORDED
   decision (`was.d !== 'open'`); a prior row still at `open` is replaced in
   silence. The behaviour is right — `open` records no decision to lose — but
   the shipped ledger legend and BOTH feature docs asserted the report happens
   every time. Text corrected in all three places, which is the same defect
   class this round already corrected once for the preservation promise.

2. The test guarding that console wording was VACUOUS: it ran with no prior
   ledger, so no reuse occurred and its `is in git` assertion could not have
   failed however the console was worded. Driven through a real drop now, with
   the drop asserted as a precondition. A new test covers the `open` arm and
   fails on the pre-fix legend.

3. The HAS_BASH gap is now MEASURED rather than assumed, on native Windows with
   Git Bash 5.2.37 / MINGW64 first on PATH, node v25.2.1:

       HAS_BASH left alone:  179 tests, 127 pass,  0 fail, 52 skipped
       HAS_BASH forced true: 179 tests, 140 pass, 24 fail, 15 skipped

   So 37 of the skips are this guard's, confirming the count the round
   published — and unskipping is NOT a clean win: 24 fail, clustered on
   `bash -c` quoting and spawn failures, exactly the divergence the eslint
   rule's subject line names. The guard stays; it now documents a measured gap.
   The stale "the count is 22" comment is gone.

4. The size-exemption justification was wrong a second time. The overshoot is
   ~2.3K normalized chars, not "essentially all remaining explanation": the
   round's committed peak was 59,246 chars (not 62,220, which was never
   committed), and it entered at 44,466 chars, not 44,523 — both earlier
   figures mixed bytes into a character measurement. Rewritten to the numbers
   the scanner actually produces.

Also: the shipped comment said both callers validate the phase shape without
naming that they validate PADDED_PHASE, not the raw PHASE_NUMBER this step is
handed.

* fix(#3829): renumber this PR's two REQs, which #3661 took while the branch sat

The rebase onto current `next` surfaced a REQ-number collision, not a text
conflict. #3661 landed `REQ-REVIEW-08` (`workflow.code_review_point`) on
`docs/features/code-review-pipeline.md` while this branch also claimed 08 and
09 for severity surfacing and the per-finding disposition. Two different
requirements under one identifier is the kind of thing that reads as correct in
both diffs and is wrong in the merged tree.

Base numbering wins, because it shipped: `REQ-REVIEW-08` stays #3661's. This
PR's two become **REQ-REVIEW-09** (severity surfacing) and **REQ-REVIEW-10**
(per-finding disposition). Swept the whole tree rather than the conflict hunk —
two references sat in files git merged cleanly and never flagged:

- `gsd-core/workflows/code-review-fix.md:450`, the prose stating why
  `record_disposition` is the step's only reachable call site.
- `tests/code-review-pipeline-regression.test.cjs:1782`, the comment on the
  test that pins that call site.

`docs/FEATURES.md` is regenerated from the fragment rather than hand-edited;
`node scripts/gen-features.cjs --check` is green (178 features, 21 groups) and
`lint:generated-sync` exits 0.

Two things stated rather than quietly carried. The `Emitted-Drift-Ack-Growth`
trailer on the round-2 commit still reads `REQ-REVIEW-09` for what is now
REQ-REVIEW-10 — it is a historical acknowledgment of that commit's growth, and
its purpose is unaffected, so it is left rather than rewritten across 52
replayed commits. And `docs/INVENTORY-MANIFEST.json` appeared stale immediately
after the replay, reporting two missing `cli_modules/` entries; that was the
lane's pre-rebase build output, not manifest drift. Rebuilding in the replayed
lane and re-checking shows it in sync and unmodified. Regenerating before the
build would have committed the deletion of two base-added entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH

* test(#3829): reach the title round-trip with a generator that can break it

Round 6's only finding. The round-5 title tracking introduced a fresh parser
(the `titles: json` / `  - id:` / `    title:` frontmatter walk) and a fresh
bijective contract (`JSON.stringify(oneLine(t))` out, `/^    title: (.*)$/`
plus `JSON.parse` back in), and `tests/code-review-disposition.property.test.cjs`
was untouched since round 4 with no reference to `title` at all. Every heading
the generator built was `'### <id>: finding number <i>'` — never a colon, a
quote, a backslash, or the empty string.

You were right that this is the round-3 shape again, and I would rather
demonstrate that than assert it. Two mutations to the shipped step, each a
plausible edit rather than a contrived one:

  A. render `titles: raw` instead of `titles: json`, so the re-parser never
     JSON.parses and stores the quoted scalar as the title;
  B. `yv = (t) => oneLine(t)` — the bare scalar, no JSON at all.

    mutation A — new generator: FAIL     old generator: pass (3/3)
    mutation B — new generator: FAIL     old generator: pass (3/3)

Both ship past the pre-round suite. The gap was reachable, not theoretical.

What changed:

- `TITLE`, a new arbitrary drawn from the class the render's own comments say
  the escaping is for — `:` (why `yv()` exists), `"` and `\` (what
  stringify/parse must round-trip), the empty string (the known-empty vs
  not-known distinction the render draws explicitly) — plus scalars that MIMIC
  the ledger's own frontmatter grammar (`findings:`, `titles: json`, a nested
  `    title: ` line, `  - id: CR-99`), unicode, surrounding whitespace, and one
  title long enough to outrun a scanner assuming short scalars.
- `FINDINGS` now carries a title per id, so all four properties run the cycle
  over the title contract instead of over a constant. `IDS` keeps the old
  id-only shape it is built from.
- A fourth property asserting the round trip in the two places it is observable:
  the stored scalar must `JSON.parse` back to the trimmed heading title, and a
  hand-recorded decision must survive the next run.

The second half is the one that matters, and its construction is the point.
The decision is made by EDITING THE RENDERED LEDGER IN PLACE, never by writing
a bare row the way the existing properties do. A bare row carries no
frontmatter, so `priorTitle` is empty, `sameFinding()` returns true through its
`!priorTitle.has(id)` back-compat arm, and the title contract is never
consulted — the property would pass over a completely broken round-trip. Both
mutations above go green against the bare-row form. That collapse is why the
property is written this way, and the comment says so in place.

So the assertion is the consequence, not the JSON: a lossy round-trip does not
corrupt a title, it makes `sameFinding()` false and resets a human's `deferred`
to `open` with the reason gone — this PR's own founding failure mode, reached
through the field the round-5 work added.

BOUND, stated rather than quietly omitted: the generator emits no CR or LF. A
`###` heading is one line by definition, so a newline is not an input the
heading parser can be handed; `oneLine()` guards the value's other producers,
not this one.

Two things found while writing it, both corrected here rather than left:

- `runOnce` now returns stdout. The reuse report is a CONSOLE note, not a
  ledger key, so my first draft's `assert.doesNotMatch(ledger, /^reused:/m)`
  was vacuously true forever — a test that cannot fail.
- `expectedTitle` is a TRIM, not a `\s+` collapse. Collapsing is `sameTitle`'s
  COMPARISON rule; `oneLine()` is the STORAGE rule and preserves internal
  whitespace. The collapse form fails on an internal tab against entirely
  correct code, which is how a test gets weakened instead of believed the first
  time it goes red.

The file header claimed "two properties" while three were running; it now
states four, one line each.

239 tests pass across the four pipeline files, 0 skipped. `lint:ci` exits 0
(`lint-workflow-shellcheck`: 203 baseline findings, 0 new).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH

* test(#3829): the prefix census guard now says four sites, because round 5 added one

Self-found, from re-deriving the round-1 finding-id census this round rather
than carrying the round-1 verdict forward.

The census guard's comment says the prefix set is "written out three times —
the heading matcher, the ledger re-parser, and (by its keys) the severity map".
That was true when it was written. Round 5's title tracking added a fourth
copy: the frontmatter `- id: ((?:CR|BL|WR|IN)-\d+)` matcher that rebuilds
`priorTitle`.

The guard itself did not fall behind, and the reason is worth keeping visible:
`idAlternations()` scans the extracted script by PATTERN rather than walking a
fixed list of sites, so the new alternation was absorbed with no edit. Verified
by running the extractor at this head — three alternations found, one distinct
set, severity map keys `CR,BL,WR` with `IN` on the documented `info` default,
0 domain members not reached.

Only the prose fell behind. Corrected, with the pattern-scan rationale stated
in place so the next reader does not helpfully convert it into the hand-listed
enumeration it deliberately is not — which would be exactly the defect this
guard exists to catch, in the guard.

Census discharge for this round: re-derived at the rebased head over the
extracted shipped script, 3 enumeration sites reached, 0 not reached; the
domain (the prefixes `gsd-code-reviewer.md` can emit, walked across both its
heading template and its prose Label-equivalence paragraph) is unchanged since
round 1 at 4 of 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH

* test(#3829): catch a duplicate REQ id in a fragment, since nothing did

Not from your review — this is the test the round owed itself, and I would
rather say why than let it look like scope creep.

The renumber commit earlier in this round has no reversion control without it.
I reverted that fix to check, and the first attempt LOOKED controlled: reverting
only the fragment turned `gen-features --check` red. That is the generated-sync
gate noticing the projection went stale, not anything noticing the collision.
Reverting CONSISTENTLY — fragment plus a regenerated `docs/FEATURES.md` — is
silent:

    gen-features --check   rc=0
    lint:ci                rc=0
    pipeline suite         rc=0

with two `REQ-REVIEW-08` entries standing in one requirement list. Nothing in
the repo reads REQ ids at all, so there was no second place for it to be caught.

The failure this guards is a MERGE, not an edit, which is why review does not
see it: two PRs open at once each append "the next" REQ number to the same list,
and whichever lands second is rebased onto a list that already used it. git
merges them as different lines of one file and reports nothing. Neither PR's
diff shows a collision — each is correct against the tree it was written on.
That is exactly how #3661 and this PR both ended up claiming REQ-REVIEW-08.

Scope, stated because it is the part that could be wrong: the check is WITHIN a
fragment, never across the corpus. Two different features legitimately both
carry `REQ-REVIEW-01..07` — the cross-AI review feature and the code-review
pipeline — so corpus-wide uniqueness would be false on the committed tree and
would have to be weakened the day it first ran. A requirement list belongs to
its feature; that is the scope of the identifier.

It lives in `describe('the committed docs/features/ corpus')` because it is an
invariant over the committed corpus, which is that block's stated job, and it
pins no count — the file's own header rules out counts as shared mutable cells
that every feature PR would have to edit.

Control: green on the committed tree (no fragment carries a duplicate today);
red on the restored collision, naming the file and the id. 85 tests pass in
this file.

Happy to drop this if you would rather the round stayed inside the review's
four corners — but then the renumber ships uncontrolled, and I would rather put
that choice in front of you than make it quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH

* test(#3829): finish the census comment correction, which stopped one line short

Found by this round's own pre-push adversarial review, which refuted the claim
the previous commit made about itself.

`7f019d985` said the census comment correction was complete. It corrected one
site and left two, both in the helper block twelve lines above the test it
belongs to:

- `severityMapKeys`' header still read "The THIRD copy: the severity map's
  keys". With three alternations the map is the FOURTH copy, and has been since
  round 5.
- `idAlternations`' header said "adding a prefix to only two of them is silent",
  written when there were two alternations and never updated to three.

This is the defect the original correction was ABOUT, committed inside the
correction: a fragment of prose carries no supersession marker, so a reader
landing on line 2810 gets the dead count stated as current fact, and the fixed
comment eighty lines down does not reach them. Fixing one surface and leaving
its neighbour is not a partial fix, it is the same fix not done.

The region is now consistent end to end, and both headers say the thing that
actually matters — the scan is by PATTERN, not a fixed list of sites, which is
why round 5's new matcher needed no edit here and why converting it to an
enumeration would reintroduce exactly the drift it guards.

WHILE HERE, a disclosure that was narrower than the truth. `7a6680e8f` said the
`Emitted-Drift-Ack-Growth` trailer still names REQ-REVIEW-09 for what is now
REQ-REVIEW-10, and left it deliberately rather than rewrite 52 replayed
commits. That is right, but it is not the whole set: the message BODIES of
`c94106568` ("wire the disposition ledger into the fix path") and `06282f668`
("migrate the emitted-drift ack") both state "REQ-REVIEW-09 was unreachable in
every shipped path", meaning the disposition requirement, which is now
REQ-REVIEW-10.

Same decision, stated at its real size: three historical references, not one.
They are commit history rather than living documentation — git is the record of
what was believed when — and rewriting the branch to correct a number in a
message would cost every review round its correspondence to the commits it
reviewed. The TREE carries no stale reference; `docs/`, the workflows and the
tests all read REQ-REVIEW-09 for severity surfacing and REQ-REVIEW-10 for the
disposition.

Regression file: 181 tests pass, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH

* test(#3829): the third stale count, and a disclosure that over-counted itself

Both found by re-running this round's pre-push review after the last fix. It
refuted the commit that claimed the region was consistent — for the second time
in a row — and it was right again.

**The third site.** `:2917` said "And the third copy, which is not an
alternation" and `:2919` said "Without this, both regexes can gain a prefix".
Written when there were two alternations; there are three, so the map is the
fourth copy and it is three regexes that can drift.

Worth saying how it survived two passes, because the mechanism is the point and
it is the same one this PR keeps re-learning. Both earlier passes VERIFIED with
a grep built from the strings I had just fixed — `THIRD copy`, case-sensitive,
plus a handful of phrasings I expected. `the third copy` in lowercase matched
none of them, and `both regexes` was not a phrasing I thought to look for. A
grep returns what you already thought of; that is not a verification of prose,
it is a re-statement of your own assumption. The region is now checked by
reading it end to end, and all four count statements agree: three alternations
(heading matcher, ledger row re-parser, frontmatter `- id:` matcher), with the
severity map as the fourth copy.

**And the disclosure over-counted.** The previous commit widened the historical
REQ-REVIEW-09 references from one to three. Three is wrong. There are TWO
underlying statements:

  - `c94106568`'s message body, and
  - the `Emitted-Drift-Ack-Growth` trailer on `06282f668`.

I counted `06282f668` twice — once as "the trailer" and once as "a body" — when
its only mention IS that trailer (`git show -s --format=%B 06282f668 |
grep -c REQ-REVIEW-09` outside the trailer line: 0). Over-counting is the safe
direction and it is still a wrong number in a message, which is the thing this
round has been correcting all along.

The decision is unchanged: both are commit history rather than living
documentation, and rewriting the branch to fix a number in a message would cost
every review round its correspondence to the commits it reviewed. The TREE
carries no stale reference — 08 is #3661's `workflow.code_review_point`, 09 is
severity surfacing, 10 is the per-finding disposition.

Comment-only in one test file; no assertion, regex or extracted-script
expectation moved. Regression file: 181 tests pass, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mgqVNP3rqTVMgBNnBMnGH

* fix(#3829): join the disposition-step dispatch so REQ-LANG-04 inheritance is provable

`lint-response-language-coverage` (#2529, which landed on `next` after this PR was
approved) reported `execute-phase/steps/code-review-disposition.md` as having no
response-language coverage. The step does inherit it: `execute-phase.md` imports
`references/execute-phase-response-language.md` and dispatches the step with
`Read and execute`. The dispatch stub wrapped, leaving the verb at the end of one
line and the path at the start of the next, and `namesFragmentAsEntryPoint` matches
within a single line — so a genuine inheritance was unprovable to the linter.

Rejoining the verb and the path restores it: `namesFragmentAsEntryPoint` goes
false -> true and the lint reports `OK (165 workflows covered)`. Only line breaks
move — the word stream is identical to the previous revision, and the file is
unchanged at 93,390 bytes, so no growth acknowledgment is owed.

This takes the third coverage form the lint documents — inheritance — rather than
the inline directive the CI message names first. Where inheritance is provable the
lint's own comments say a second copy "buys no coverage and adds a sentence that
can drift", and the step file already sits over the prompt-stuffing threshold.

Swept all 76 fragments in the catalog: this is the only one whose parent's previous
line ends with a dispatch verb. The 17 others that are mentioned without a provable
entry point are table-routed or bare prose references carrying no dispatch verb at
all, and correctly hold the pinned inline directive instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgX6QQmygeZnQqbc3o8RNC

* chore(#3829): regenerate derived artifacts after rebase onto next

The rebase onto current `next` conflicted on the 19 install-tree goldens and
`docs/FEATURES.md`. Those are generated, so the conflicts were resolved
arbitrarily and the generators re-run (`npm run regen:derived`) rather than
hand-merged — a clean textual merge of a generated file attests the merge, never
the content.

Reconciled per artifact against the base's own committed copy rather than against
the pre-regen tree, because the pre-regen tree is the arbitrary resolution:

  - all 19 `tests/fixtures/install-tree/*.json` now differ from
    `upstream/next` by exactly one key,
    `gsd-core/workflows/execute-phase/steps/code-review-disposition.md`;
  - `docs/FEATURES.md` differs by exactly REQ-REVIEW-09/10 and this PR's own
    reference section;
  - `docs/INVENTORY-MANIFEST.json` differs by exactly the same one step file,
    and needed no regeneration to get there.

Nothing the base added was dropped by the arbitrary resolution: the restored
entries (the `gsd-core/agents/` and `gsd-core/commands/gsd/` families, the
compact templates, the `detail/elaboration.md` files, `gsd-secret-read-guard.js`)
are all base-owned and came back through the generator, which is what the
resolve-arbitrarily-then-regenerate discipline is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV

* fix(#3829): repair the rebase's conflict resolution in the regression suite

The rebase onto current `next` hit one add/add conflict in this file: #4209's
external-reviewer-evidence describe and this PR's #3829 block were added at the
same insertion point. Resolving it by keeping both sides was correct in
substance and wrong in mechanics — the conflict boundary cuts through two open
blocks that the SHARED trailing `  });\n});` closes, so each side carries +2
unbalanced braces on its own and concatenating them left the file with 683 `{`
against 680 `}`.

`node --check` fails outright, so the whole file deregistered rather than
failing a test — 188 tests silently stopped existing. Rebuilt the region as a
real three-way merge (ancestor a262ad6b6, ours upstream/next, theirs 77ee739c3)
and closed the first side explicitly before the second begins.

Both feature blocks are present exactly once, braces balance 683/683, and the
file runs 188/188 locally. The sibling markdown file resolved the same way is
unaffected and was checked rather than assumed: prose has no block structure to
unbalance, and it differs from the base by 112 added lines with zero removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV

* test(#3829): inject the unreadable-review failure in a way root cannot bypass

Round 9 finding 1. `runShippedGateCounts({ mode: 0o000 })` does not simulate an
unreadable review under root: root bypasses POSIX read permission bits, so the
fence's `[ -f "$REVIEW_FILE" ] && [ -r "$REVIEW_FILE" ]` guard stays true, the
fixture is read, and the assertion sees a real breakdown where it expects
silence. Reproduced as reported — `node:24-slim`, euid 0:

    not ok 18 - an unreadable REVIEW.md leaves the counts empty and does not abort
    actual: 'Code review: 4 findings — 1 critical, 2 warning, 1 info.\n...'

**The prescribed remedy does not reach this site, so this adapts it rather than
applying it.** Stubbing `fs.readFileSync` to throw EACCES is the right fix where
the read happens in-process; here the read is performed by a spawned `bash`, so
node's `fs` is not on the code path and the stub would change nothing.

What the guard actually has is two legs, and only `-r` is defeated by root:

  - the `-r` leg keeps the mode-bit fixture and declares the lanes it cannot
    bind on (`win32`, `euid 0`), which is exactly what
    tests/plan-review-convergence.test.cjs:2326 does for its own shell-side
    `-r` arm — the repo's existing precedent for this shape;
  - the `-f` leg is new and root-immune: a DIRECTORY at the review path fails
    `-f` for every euid, reaching the same non-reporting arm with the same
    observable. It binds on the bench lane where the first test is skipped.

Skipping the first without adding the second would have traded a false failure
for lost coverage on the only lane that found this.

Reversion control, run as root: reverting this commit fails exactly
`an unreadable REVIEW.md leaves the counts empty and does not abort` and its
enclosing describe `#3861 round 1 — the counts mirror is asserted against the
shipped shell`, with no other change to the failure set. **That answers the
round's open question** — the review flagged the describe as possibly a second
root cause; it is the first one's rollup, and there is no second.

Local (euid 1000): 189/189, both tests run.
Root: 179 pass / 1 skip, the skip naming its reason, the directory test running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV

* chore(#3829): refresh the compact-content benchmark baseline for the gate's edit

Self-found in this round; not raised in review. The base range landed #4139's
compact-content benchmark, whose committed baseline records per-workflow token
counts. This PR replaces a 12-line bash block in `execute-phase.md`'s
code_review_gate with a 5-line dispatch paragraph, which moves that workflow's
measured counts by 17 tokens — so the baseline the base just added drifts
against a tree it was measured before.

    DRIFT: split "execute-phase": off 25631 -> 25614 (-17), on 23380 -> 23363 (-17)
    DRIFT: aggregate: off 106923 -> 106906, on 90275 -> 90258

Refreshed with the remedy the script itself names
(`node scripts/benchmark-compact-content.cjs --write`). The regenerated diff
touches only the `execute-phase` entry and the aggregate — every other
workflow's numbers are byte-identical, which is the reconcile this PR's edit
predicts.

Attributed rather than assumed: `tests/benchmark-compact-content.test.cjs` is
27/27 at `upstream/next` with no PR content, and was 26/27 on this head. So the
drift is this PR's, not base noise — and it is invisible to a diff-scoped sweep,
because the PR never touches the baseline file and the base range is what
created it. The sibling `benchmark:compact-content-variants` was checked in the
same pass and reports up to date, so this is the only one of the pair affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV

* test(#3829): cover an EMPTY REVIEW.md, a case the body claimed and no test reached

Found by this round's own adversarial audit of the PR body, not by review. The body
has said since round 1 that the suite covers "a REVIEW.md that is missing, empty,
or a directory". Two of those three were true. The empty one was not.

Every `reviewText: ''` call in this file also passes `writeReview: false`, which
makes the file MISSING, not empty — so the arm the body named had no test at all.
They are genuinely different paths through the shipped fence: a missing file never
gets past `[ -f ]`, while an empty one passes both `[ -f ]` and `[ -r ]` and is
actually opened and read.

Probed the shipped fence directly against a real empty file before asserting
anything: exit 0, empty stdout. So the behaviour was already correct and only the
coverage claim was false — which is the same "documented as covered, not covered"
shape this PR exists to make visible in the review gate, found in its own body.

The explanatory comment is deliberately precise about WHY the scan yields nothing,
because the plausible reading is wrong and a later reader would inherit it: it is
not the `NR==1{if($0!="---") exit}` guard. A zero-byte file gives awk no record, so
that action never runs (NR stays 0); the output is empty because `closed` is never
set. The comment also states what the test does not prove on its own — its
observable is identical to the missing-file case, so "the file was read" rests on
the harness and the fence, not on the assertions.

189 -> 190 tests in this file, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjyHFtRTtHDD2tM6V6aUV

* chore(#3829): regenerate derived artifacts after rebase onto next

The rebase onto current `next` conflicted in the 19 install-tree goldens,
`docs/INVENTORY-MANIFEST.json` and the compact-content benchmark baseline.
Those are generated, so they were resolved arbitrarily and regenerated
with their own producers rather than hand-merged: every golden now differs
from `next`'s committed copy by exactly the one PR-owned entry
(`gsd-core/workflows/execute-phase/steps/code-review-disposition.md`), the
manifest by the same entry, and the benchmark baseline by the
`execute-phase` split plus the aggregate.

* chore(#3829): regenerate the platform-conformance tier for the added property test

`next` gained the conformance-tier classifier (#4591) and its CI gate after
this branch was cut. The branch adds `tests/code-review-disposition.property.test.cjs`,
so the generated tier list was one file short (546 != 547). Regenerated
with `node scripts/gen-platform-conformance-tier.cjs --write`; the macOS
tier (`--target macos --check`) already matched.

* chore(#3829): regenerate the two conflicted generated artifacts after the rebase

`next` moved 6 commits past the previous base and conflicted in exactly two
files, both generated:

- `tests/fixtures/compact-content-benchmark-baseline.json` — #4208 (`4cc2a466b`)
  and #4619 (`db4d8a9ba`) both moved the measured token counts, and this branch
  moves the `execute-phase` split too.
- `scripts/lib/platform-conformance-tier.generated.cjs` — #4641 (`4d65c248e`)
  made test-conformance the sole Windows selector and narrowed the tier to
  28.5%, and #4568/#4619 re-ran it after.

Both were resolved arbitrarily during the replay and then regenerated with
their own producers rather than hand-merged, per the generated-artifact rule:
`node scripts/benchmark-compact-content.cjs --write` and
`npm run regen:derived` (which runs `gen-platform-conformance-tier.cjs
--write` for both the default and the macOS target).

Reconciled against `next`'s own committed copies rather than the pre-regen
tree:

- the benchmark baseline differs from `next` by exactly the `execute-phase`
  split (`offTokens` 25827 -> 25810, `onTokens` 23576 -> 23559 — the 17-token
  delta this PR's step-file extraction has carried since round 5) plus the
  `aggregate` that sums it;
- the conformance tier differs from `next` by exactly one added entry,
  `tests/code-review-fix-pipeline-regression.test.cjs`. Under the narrowed
  28.5% selector that is the file the classifier now picks from this PR's
  test set. The arbitrary resolution had carried 282 stale lines computed
  under the pre-#4641 selector (`--numstat` on this commit: 3 insertions,
  282 deletions), and regeneration collapsed them.

The full derived sweep was run, not just the two named producers: all 19
install-tree goldens, `docs/INVENTORY-MANIFEST.json`, `docs/FEATURES.md`,
the macOS conformance tier and the exit-code registries regenerated
byte-identical, so nothing else drifted under the new base.

* fix(#3829): accept N-segment phase ids, and bound length per component

The base range added `scanMarkdownSingleSegmentPhaseRegex` (#4568,
`a2331c01f`), which refuses the single-optional-segment phase regex on
phase-carrying markdown lines under three roots — `gsd-core/workflows/`,
`gsd-core/references/` and `agents/` (`lint-phase-id-drift.cjs:301`,
`:373-387`). It flagged two lines in this step file. Chasing the flag
turned up two real defects behind it, so this commit is those rather than
the comment edit the flag literally asked for.

## Defect 1 — the step refused ids both its callers accept

The comments asserted that both callers validate `^[0-9]+(\.[0-9]+)?$`.
#4568 had widened those two call sites to `^[0-9]+(\.[0-9]+)*$`, so the
prose was stale. Correcting only the prose would have shipped a comment
promising N-segment support over code that refused it, because the step
carried a third `case` arm:

    *.*.*)        _ok=0 ;;   # more than one dot: not the documented shape

`23.1.2` took the refusal arm, `PADDED` came back empty, and the step
printed `Code review reporting skipped (unusable phase number ...)` and
wrote **no ledger** — for a phase id both of its callers accept.

It degraded loudly rather than silently; there is a diagnostic on stdout.
The traversal-fence test asserted that refusal as *correct*, listing
`1.2.3` among the values that must be rejected, so an arity bound and a
shape bound sat folded into one `case` arm with a test pinning the pair.

The arity arm is gone. Deleting it alone would have left the step
**wider** than its callers in one direction — `1..2` has an empty
segment, which `^[0-9]+(\.[0-9]+)*$` refuses and the retired arm had been
masking — so a third arm replaces it:

    *..*)         _ok=0 ;;   # EMPTY SEGMENT

## Defect 2 — the length bound was not per-component, though its comment said so

Removing the arity arm made a second defect reachable. The bound read:

    case "$_pn" in *.*) case "${_pn#*.}" in ?????????*) _ok=0 ;; esac ;; esac

`${_pn#*.}` is the whole tail after the first dot — one component only
while an id has at most two. With N-segment ids accepted, that form
rejects `1.1234567.1`, whose every component is a legal 7 digits, purely
because the tail measures 9 characters. The comment directly above it has
read **"LENGTH-BOUND EACH COMPONENT SEPARATELY"** since before this PR,
and had itself named the composite bound as "too strict" — the same
mistake, surviving one level up.

Both fences now walk the segments and bound each:

    _rest="$_pn"
    while [ -n "$_rest" ]; do
      case "$_rest" in
        *.*) _seg="${_rest%%.*}"; _rest="${_rest#*.}" ;;
        *)   _seg="$_rest";       _rest="" ;;
      esac
      case "$_seg" in ?????????*) _ok=0 ;; esac
    done

The `$((10#...))` overflow guard is preserved, per component: bash
integers wrap at 2^64, so an unbounded integer segment would silently
become a negative padded phase.

**The remaining divergence from the callers is a CLASS, not a list:** any
id carrying a component of nine or more characters is caller-accepted and
fence-refused — `123456789`, `1.999999999`, `1.123456789.1`,
`123456789.1`, `1.1.123456789` and so on. That narrowing is deliberate
and is the overflow guard. An earlier draft named two examples as though
they were exhaustive; that wording is withdrawn.

## What is NOT claimed

- The canonical grammar is `PHASE_NUMBER_TOKEN_SOURCE` in
  `src/phase-id.cts:65`, `\d+[A-Z]?(?:\.\d+)*`, added by **#2128**
  (`09be501eb`, 2026-07-10). An earlier draft dated it to #865
  (2026-06-08); that was the first commit to touch the *file*, not the
  one that added the constant, and it is withdrawn.
- #4568 gave the six shell sites **segment-count** parity with that
  grammar, not textual parity: the canonical source permits an optional
  `[A-Z]`, and the shell literals remain digit-only. Driven: this step
  and both callers all refuse `23A.1`, so they agree with each other and
  are jointly narrower than `src/phase-id.cts`. That is a question about
  the six sites rather than about this step, and it is not touched here.
- **#4619 does not produce N-segment ids.** It only transforms an
  already-supplied `{phase_number}` so `$((10#...))` does not abort on
  one. An earlier draft cited it as the producer; that is withdrawn, and
  is stated rather than silently swapped so a reader can see it was
  corrected.
- That the folded `case` arm is *why* nothing caught this is an
  observation about the test's shape, not an established cause.

## Tests

- `an N-SEGMENT phase number reports counts, exactly as its callers
  accept it` — drives `23.1.2` **and** `1.2.3.4`: the retired guard was
  arity-shaped, so a bound merely moved from two dots to three would pass
  a three-segment-only test.
- `the length bound is PER COMPONENT, not over the whole tail after the
  first dot` — drives an 8-char and a 9-char **middle** segment, the
  position the old form got wrong.
- `the fence agrees with its callers across a probed set spanning both
  boundaries` — example-based, and says so: a finite probe cannot prove
  congruence over an infinite language, and one review pass demonstrated
  that by injecting a `2) _ok=0` arm this test still passed. It is a
  regression pin over the values that actually broke.
- The traversal list loses `1.2.3` (legal at this base) and gains `1..2`
  and `1.2.` — the malformed-dot cases the arity guard had masked.

**Negative controls, re-measured against reconstructed fences:**

    fence state              N-seg   probed   per-comp
    fully pre-fix            FAIL    FAIL     FAIL
    shape-fix only           PASS    FAIL     FAIL
    this tree                PASS    PASS     PASS

Two tests, not one, catch Defect 2: the caller-agreement probe includes
`1.1234567.1`, so the whole-tail bound breaks it too. An earlier draft
claimed the per-component test failed alone — that table was written
before `1.1234567.1` was added to the probe and was not re-measured
afterwards. It is corrected here from a fresh run. The N-segment test
correctly does not fire on Defect 2; it predates the bound work and is
insensitive to it.

Found by this round's own adversarial review passes.

* docs(#3829): name this step's two dispatchers correctly, in code as well as in the PR body

`code-review.md` is not a call site of this step. It carries an identical
`^[0-9]+(\.[0-9]+)*$` validator, which is why it kept getting cited as one, but
it never dispatches `code-review-disposition.md`. The two dispatchers are
`execute-phase.md` (`code_review_gate`) and `code-review-fix.md`
(`record_disposition`) -- and only the second validates anything.

This round corrected that in the PR body and simultaneously wrote the old
conflation into the shipped comments, so the file asserted at line 24 what the
body denied in public, and contradicted its own line 48. Four false assertions,
each duplicated because the fenced block is emitted twice:

- "Both callers explicitly accept ... (code-review.md:63, code-review-fix.md:39)"
- "#4568 widened both of this step's callers" -- it widened the one that
  validates; the other has no validator to widen
- "Both callers already validate ... (code-review.md:63, code-review-fix.md:39)"
- "a SHAPE (..., asserted by both callers)" -- asserted by one

The same conflation had propagated into three comments in
`code-review-pipeline-regression.test.cjs`; corrected there too.

Adds the one fact that follows from naming the dispatchers correctly and that
nothing else in the tree records: `execute-phase.md` applies NO shape gate, so
this fence is not mirroring an upstream guarantee -- it IS the guarantee. A
later reader who believes the caller validates will "simplify" it away.

Comments only. The executable shell is byte-identical to 03adf9474 (verified by
stripping comment lines and diffing). 200/200 regression + property, 47/47
prompt-injection security scan, eslint and lint:generated-sync clean.

The wider [A-Z]-axis divergence between these sites and the canonical
`PHASE_NUMBER_TOKEN_SOURCE` is tracked separately as #4660 and deliberately not
restated here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xdw628PpLYveWfJu7kDyvZ

* chore(#3829): regenerate the two conflicted generated artifacts after the rebase onto next

Both conflicted during the replay onto `eb49ff98d` and were resolved arbitrarily, then
regenerated with their own producers (`npm run regen:derived`,
`node scripts/benchmark-compact-content.cjs --write`) rather than hand-merged. Reconciled
against next's committed copies: the conformance tier differs by the one entry this PR
adds, the benchmark baseline by the `execute-phase` split (the same 17-token delta this
PR's step-file extraction has carried since round 5) plus the aggregate that sums it. The
rest of the derived sweep — 19 install-tree goldens, INVENTORY-MANIFEST, FEATURES, the
macOS tier, the exit-code registries — regenerated byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe

* fix(#3829): a carried row keeps the severity the ledger recorded, instead of re-inferring it from the prefix

The ledger always wrote a severity for every row (table cell and frontmatter key) and
nothing read either back: the prior-row regex skipped the cell as `[^|]*`, the frontmatter
walk collected only titles, and a carried row was rebuilt through sev() from the id prefix,
because sectionSev holds only the findings the CURRENT review reports. So a WR-04 the
reviewer filed under `## Critical Issues` was recorded critical, a human deferred it, and the
next run -- the review no longer reporting it -- silently re-recorded it warning. The one
artifact whose purpose is remembering a finding's severity lost it on the second run, in the
unsafe direction (round 11, reproduced by executing the shipped script twice).

Both persisted copies are now read back, enum-validated (ADR-227, as the disposition column
already is): the table cell first, the frontmatter `severity:` as the fallback for a
hand-mangled cell. Severity precedence is the current review's SECTION, then the RECORDED
value, then the id PREFIX, and the recorded value is inherited only while the id still names
the same finding -- the identity rule the disposition already obeys -- so a reused id starts
from its own review. sev() moves below sameFinding() because it now depends on it.

Tests: a new describe drives the reviewer's exact case (WR-04 under `## Critical Issues`,
deferred by hand, dropped by the next review -> stays critical) plus five controls: recorded
outranks prefix under no recognized section; the current section still outranks recorded; a
REUSED id does not inherit; a mangled cell falls back to the frontmatter and a mangled pair
to the prefix; a bare pre-severity row still infers. A fast-check property assigns each
finding a section independent of its prefix, carries every row through an empty review, and
asserts the section severity survives and the third run reports unchanged.

Negative control, measured against the pre-fix step: the two carry tests, the mangled-cell
test and the property fail; the three precedence/back-compat controls pass at both ends, as
they pin behaviour that predates the fix. Every prior carried-row test used CR-01/IN-01,
whose prefix already matched, so the lossy path had returned the right answer by coincidence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe

* fix(#3829): a malformed REVIEW.md is reported as unparsed, not passed over as clean

A REVIEW.md with three criticals and an unterminated frontmatter yielded REVIEW_STATUS='',
and the counting arm then printed nothing -- byte-identical to a clean review. The guard
that scopes the frontmatter scan was right to yield no values from an unterminated block; the
reporting arm was wrong to treat 'no status' as 'no review'. Block 2 said `status: none`
rather than `clean`, which is why a careful reader could still separate them (round 11,
Minor).

Both fences now record whether the file was actually READ, separately from what it yielded.
A read file with no parseable status -- unterminated frontmatter, no frontmatter, no
`status:` key, a zero-byte file -- prints `Code review status unparsed: ...` with no
breakdown (there is none to trust) and no --fix suggestion (nothing proves there are
findings). Absent, directory and unreadable stay silent: nothing was read, so nothing is
described. Block 2's skip line names the same distinction, `status: unparsed` vs `none`.

The counts mirror follows the shell: a mirror is always handed a text, so its empty-status
arm is the unparsed one, and the existing 'unterminated frontmatter' and 'no frontmatter at
all' parity fixtures now bind the new message on both sides. The EMPTY-file test from round 9
changes its assertion deliberately: its observable is no longer identical to the missing-file
case, which is the point. Five new tests drive the arm, its three shapes, the three shapes
that stay silent, and block 2's wording.

Negative control, against the previous step: the unterminated, no-status, no-frontmatter and
empty-file tests fail, both parity fixtures fail (the mirror moved and the shell had not),
and block 2's `unparsed` assertion fails; the stays-silent controls pass at both ends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe

* docs(#3829): name the unlocked read-modify-write and reference #3780 rather than solving it

The ledger is rendered whole from a prior read with nothing serializing two writers, and
this step has two dispatchers plus an invited hand-edit, so the window is real. It is the
shape #3780 reported for WINDOWS.md under parallel executors, which #4681 closed with a
cross-process lock in src/broken-windows.cts. Not taken here, deliberately: the step is a
shell-embedded script with no dependency on the compiled tree, and adopting the lock module
is its own change. Stated at the write site and as a residual in the feature doc; no lost
update has been reproduced (round 11, Minor).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe

* docs(#3829): cross-reference the two "review disposition" ledgers in both directions

ADR-3806 canonizes a `## Review Dispositions Ledger` section inside PLAN.md for reviews-mode
planning: append-only per round, over REVIEWS.md findings. This PR's
`<NN>-REVIEW-DISPOSITION.md` is a sibling file beside REVIEW.md for the code-review pipeline,
rewritten idempotently with rows carried. Adjacent names, opposite durability rules, and
neither document mentioned the other -- the round-11 review checked the ADR gate against
3806, cleared it, and flagged exactly that mis-read hazard.

An in-place dated amendment section on ADR-3806 (contributor-standards "Amending an accepted
ADR", pattern 1) and a paragraph in the pipeline feature doc, each naming the other and the
axis on which they differ. docs/FEATURES.md regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe

* chore(#3829): regenerate the compact-content benchmark baseline after the rebase onto next

`next` moved three commits while the round was in flight and the baseline conflicted again;
resolved arbitrarily during the replay and regenerated with its own producer. It differs from
next's copy by the `execute-phase` split this PR has carried since round 5, plus the aggregate.
The rest of the derived sweep regenerated byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe

* fix(#3829): accept letter-variant phase ids, matching the canonical grammar #4744 widened the dispatchers to

#4744 (#4660) landed on `next` while this round was in flight: it widened the six
shell/markdown phase-number mirrors -- `code-review-fix.md:39`, this step's validating
dispatcher, among them -- to the canonical grammar's letter axis (`12A`, `3A`, `23A.1.2`),
and added a `lint-phase-id-drift` ratchet that flags any digit-only mirror left in the
workflow tree. Rebased onto that base, this step was the one it flagged (two fences, two
sites): `12A` was refused by name and wrote no ledger, for a phase id its own dispatcher
now accepts -- the round-10 class ("the step refused phase ids its validating dispatcher
accepts") re-opened by the base. Found by running the base range's modified gates against
the rebased tree, not by the review.

Both fences now admit an uppercase letter in the character class and pin WHERE it may sit --
only as the last character of the integer part, at most once -- so `23a`, `A23`, `2A3`,
`23AB` and `23.1A` stay refused. The per-component length bound is on the DIGITS (the letter
is one character the `$((10#...))` overflow guard has no stake in, so `12345678A` is within
it exactly as `12345678` is), and the letter is carried verbatim after the padded digits,
`3A` -> `03A`, as `src/phase-id.cts` pads it. The two fences stay line-identical except for
their refusal message (the parity test holds), and every comment literal of the old shape
reads the canonical one.

Tests: a new fixture drives `12A`, `3A`, `23A.1.2` and `12345678A` through the shipped
fence to the padded path; the traversal-fence list gains the five wrong placements; the
caller-agreement probe's regex gains the letter axis with both-direction cases, and
`123456789A` joins the deliberate over-bound narrowing. Negative control against the
pre-widening step: the letter-variant test, the caller-agreement probe and the base's
`scanMarkdownLetterlessPhaseMirror` gate all fail; the traversal-fence test passes at both
ends (the five new placements were already refused, by the narrower class).

Also re-anchors the fence and test comments' `code-review-fix.md` / `code-review.md` citations by
content (the validator, not a line number): the line numbers had drifted by one against the rebased
base, and drift again on every rebase.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9FANs6AXuXNa7fCSahTQe

* chore(#3829): regenerate the two conflicted generated artifacts after the rebase onto next

Both files conflicted during the replay and were resolved arbitrarily rather than
hand-merged, then regenerated with their own producers — `npm run regen:derived`
and `node scripts/benchmark-compact-content.cjs --write`.

Reconciled against next's own committed copies rather than against the pre-regen
tree, because a clean textual merge of a pinned-number file attests the merge and
not the numbers:

- `tests/fixtures/compact-content-benchmark-baseline.json` differs from next by
  exactly the `execute-phase` split (offTokens 26264 -> 26200, onTokens 24013 ->
  23949) and the `aggregate` that sums it. That is the step-file extraction this
  PR has carried since round 5, re-measured against the new base; no other entry
  moved.
- `scripts/lib/platform-conformance-tier.generated.cjs` differs from next by
  exactly one added entry, `tests/code-review-fix-pipeline-regression.test.cjs` —
  the file the classifier picks from this PR's test set.

The full derived sweep was run, not just the two named producers: all 19
install-tree goldens, docs/FEATURES.md, docs/INVENTORY-MANIFEST.json, the macOS
tier and the exit-code registries regenerate byte-identical under the new base,
so nothing else drifted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* fix(#3829): stop block 2 computing an unparsed shortfall from a self-contradicting findings block

Round 12, Minor. Confirmed, and the premise is slightly stronger than stated: block
2 does not merely skip block 1's `critical + warning + info == total` cross-check —
it never derived the three severity counts at all, so the check's inputs were
absent. It bounded `total` for digits and length only and handed it to the
`unparsed:` reconciliation.

So a REVIEW.md whose `findings:` block disagrees with itself (`total: 10` beside
`critical: 1, warning: 1, info: 1`) made block 1 print the countless form —
breakdown suppressed as untrustworthy — while block 2 still computed a shortfall
from that same untrusted number. Two trust models for one field, one fence apart,
with the weaker one downstream. It fails in the safe direction, which is why the
review did not raise it as a blocker; it is still a real inconsistency.

Block 2 now derives `critical`/`blocker`, `warning` and `info` through the same
`findings:`-anchored filter block 1 uses, with the same digit-and-length bound and
the same `10#` on every operand, and blanks `total` when the three disagree with it.

**Adapted, not applied verbatim — and the divergence is the point.** The finding
says to re-apply block 1's cross-check. Block 1's gate is `REVIEW_COUNTS_OK`, which
demands all four counts be numeric, because block 1 DISPLAYS all four and
`6 findings —  critical` is the half-filled line that rule exists to prevent. Block
2 displays none of them; it uses `total` alone, against the number of headings the
row parser matched. Applied verbatim, the all-four rule blanks a perfectly usable
`total: 5` on a review carrying no severity keys and SILENTLY DROPS an `unparsed:`
shortfall this step reports correctly today — trading a safe-direction over-report
for a silent under-report, which is the wrong way round and is the exact failure
class the `unparsed:` key was added to close. Only the CONTRADICTION ports: absent
counts are not a disagreement, because there is nothing to disagree with.

Driven against the shipped fence, not a mirror:

  consistent 1+1+1=3          -> total 3       CONTRADICTION total:10 -> withheld
  blocker: alternation        -> total 2       counts absent          -> total 5 (kept)
  leading zeros 01+01+01=03   -> total 03      one count absent       -> total 5 (kept)
  no findings block           -> withheld      non-numeric count      -> total 5 (kept)

Six regression tests drive the second markdown fence end to end through `runHook`
under bash, asserting on the rendered ledger. Negative controls fire in OPPOSITE
directions, which is what pins the narrowing rather than only the fix:

- revert the fence fix          -> `a contradicting findings block yields no
                                   shortfall` and the `blocker:` twin go red
- apply the VERBATIM all-four   -> `a total with NO severity keys still reconciles`
  prescription instead             and the partial/non-numeric case go red

Restored tree: 214/214.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* docs(#3829): state the one input that produces no unparsed shortfall

The reference page said the shortfall "is stated" whenever `total:` exceeds the
parsed headings. After the round-12 fix that is conditional, and a doc asserting
the unconditional form describes behaviour the step no longer has.

Names the boundary in both directions, because the narrowing is the part a reader
would otherwise get wrong: a `findings:` block whose three severities are all
present, numeric and do not sum to `total` produces no key — the same input on
which the console line already withholds the breakdown — while counts that are
merely absent, partial or non-numeric are not a disagreement and still reconcile
from `total` alone.

`docs/FEATURES.md` regenerated; `gen-features --check` green (182 features, 21
groups).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* fix(#3829): close two holes the round's own adversarial pass found in its first attempt

Neither is from the maintainer's review. Both were found by the pre-push adversarial
pass over this round's own claims, which refuted them by execution.

**1. A malformed severity could suppress a real shortfall.** The sibling frontmatter
reads use `cut -d: -f2 | tr -d ' '`, and `tr -d` deletes INTERNAL spaces, so
`critical: 1 0` arrives as the perfectly numeric `10`. That is long-standing in those
reads — its mirror is pinned as a fixture from round 1 — and it was INERT in block 2
until this round made that block read the severities at all. At that point a repaired
number could satisfy the new sum test and suppress an `unparsed:` shortfall that is
genuinely owed. Driven, pre-fix: `critical: 1 0 / warning: 0 / info: 0 / total: 5`
against three parsed headings emitted no `unparsed:` key where `unparsed: 2` was
correct.

The three severity reads now trim the ends only, so an internal space survives into
the digit check and fails it — `_sum_ok=0`, nothing is suppressed. Fail-safe in the
only direction that matters: when the frontmatter is malformed the step declines to
suppress rather than trusting a repaired number.

**Scope, stated:** only the SUPPRESSION inputs are strict. `REVIEW_TOTAL`'s own read
still uses `tr -d ' '`, unchanged and identical to block 1's — narrowing it would
change the `unparsed:` computation itself, which is pre-existing behaviour and wider
than this round. So `total: 1 0` is still read as `10` by both blocks, as before.

**2. The repointed #4748 gate could not see a later rebinding.** Its derivation slices
stop AT the first anchored assignment, so inserting the canonical lookup and then
overriding it with `REVIEW_FILE="${_pd}/WRONG-REVIEW.md"` left every assertion green —
the slice pins a line, not the path the fence actually consumes. A new test pins the
whole file instead: the only `REVIEW_FILE=` bindings permitted are the canonical
lookup (exactly twice, once per fence, each being a fresh shell) and the identity
pass-through that hands it to the embedded node script as an env prefix.

Negative controls, both the adversarial pass's own mutations, against the restored
tree at 215/215 and 164/164:
- restore `tr -d ' '` on the severity reads -> the internal-space test reds
- insert the WRONG-REVIEW override after the lookup -> the rebinding test reds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* fix(#3829): one parser for every count block 2 reads, and widen the rebinding guard

A second adversarial pass, run against the first pass's own fixes, refuted three of
them by execution. Fixes to review findings are the class most likely to carry a new
defect, which is why that pass exists; all three were real.

**1. `cut -d: -f2` takes the SECOND FIELD, not the scalar.** So `critical: 1: junk`
arrived as the perfectly numeric `1`, and `1+0+0 != 5` was read as a contradiction
that SUPPRESSED a shortfall genuinely owed. The previous fix trimmed the ends but
still cut at the wrong place, so it closed the internal-space shape and left this one
open. `-f2-` keeps everything after the first colon; the malformed scalar stays
malformed and `total: 5` still reconciles.

**2. The deliberate asymmetry was wrong, and it FABRICATED.** The previous fix parsed
the severities strictly and left `total` lenient, on the reasoning that narrowing
`total` was out of scope. Driven: `critical: 5 0` with `total: 1 0` repaired only the
total to `10`, rejected the severity, skipped the contradiction check, and invented
`unparsed: 7` against three parsed headings. Both uniform policies behave sanely —
strict rejects the malformed total, lenient detects `50 != 10`. A field is either
trustworthy or it is not; parsing one leniently and its sibling strictly is the shape
that fabricates. Every count this block reads now goes through one parser.

**Scope, restated because it moved:** the previous commit said `REVIEW_TOTAL`'s read
was deliberately unchanged. That is no longer true and the reasoning behind it did not
survive contact — the asymmetry it protected is what produced the fabrication. Block
1's reads are still untouched; its own all-four gate runs over consistently-parsed
values, so it has no equivalent split.

**3. The rebinding guard missed an indented or exported assignment.** `^REVIEW_FILE=`
let both `  REVIEW_FILE=...` and `export REVIEW_FILE=...` through, and each executes
exactly like a bare one. The predicate now absorbs leading whitespace and an optional
`export` before the accept-list decides.

Driven after the fix, against three parsed headings:

  critical: '1: junk'  total: 5      -> total 5 kept, unparsed: 2 reported
  critical: '5 0'      total: '1 0'  -> total rejected, no unparsed key
  critical: '1 0'      total: 5      -> total 5 kept (unchanged)
  consistent / contradiction / blocker / leading-zero / absent — all unchanged

Negative controls, each the adversarial pass's own mutation, against 381/381:
- `-f2-` back to `-f2`            -> the second-colon test reds
- `total` back to lenient `tr -d` -> the fabricated-shortfall test reds
- an INDENTED rebinding           -> the rebinding guard reds
- an `export` rebinding           -> the rebinding guard reds

Residual, disclosed: a duplicate `critical:`/`blocker:` key is still resolved by
`grep -m1` taking the first match. Duplicate keys are invalid YAML and the same
first-match rule is long-standing in the sibling reads; detecting them is a wider
change than this round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* fix(#3829): one parser for the whole step, and prove a contradiction from a partial sum

A third adversarial pass, run against the second pass's fixes. Three findings, plus
one this round's own negative control caught afterwards.

**1. An absent severity still bounds the sum from below.** Counts are non-negative,
so a missing one can only ADD: when the severities that ARE present already sum to
MORE than `total`, the block disagrees with itself whatever the absent value is.
Requiring all three before comparing missed that — driven: `critical: 4`,
`warning: 4`, no `info:`, `total: 5` reconciled against a total the present counts had
already refuted. The comparison is two-armed now: EQUALITY when all three are known, a
LOWER BOUND when they are not. An UNDERshoot stays reconcilable, because that is
exactly what the absent count explains.

**2. Block 1 now uses the same parser, so the console and the ledger cannot
contradict each other.** Tightening block 2 first left the two fences disagreeing about
the same bytes. Driven: `critical: 1 0` with `total: 1 0` repairs to 10 and 10, which
SUM — so block 1 reported `10 findings — 10 critical, 0 warning, 0 info.` from a
`findings:` block containing no such numbers, while the ledger recorded three rows and
no shortfall. Block 1's reads move to `cut -d: -f2-` plus an end-trim; both fences now
take the countless arm on that input. The counts mirror moves with them — its whole job
is modelling the shipped pipeline, and it modelled the retired one.

This is wider than the review's finding and I want that visible: the finding was about
block 2 alone. But a disclosed divergence between a console line and a ledger is the
confusion this PR exists to remove, so it is fixed rather than documented.

**3. `REVIEW_FILE+=-wrong` executes and was missed.** The rebinding guard matched only
`=`; `+=` appends (driven: `REVIEW_FILE=good; REVIEW_FILE+=-wrong` prints `good-wrong`).

**4. My first negative control for (2) was VACUOUS, and that is the reason for the new
`BLOCK 1 withholds a breakdown built from REPAIRED counts` test.** Reverting block 1's
parser left the suite green: on every fixture that existed both parsers landed on the
same arm, so parity could not see the difference. A SELF-CONSISTENT repaired breakdown
separates them, and the test pins it directly rather than through parity.

**Correction to the previous commit's claim.** It said moving `total` to a strict read
"changes nothing for a well-formed review". That is false: `total:\t5\t` is valid YAML
(`yaml.parse` returns 5) which the old `tr -d ' '` rejected and the new trim accepts.
The change is an improvement, not a no-op, and the claim was the wrong shape.

Also from the third pass's MISSED: the fixtures exercised malformed `critical` and
`total` only, so they did not pin the four-field symmetry the fix claims. Every field
now gets every malformed shape.

Negative controls, against the restored tree at 383/383 (220 in the pipeline file):
- revert block 1's parser        -> the repaired-counts test reds (was vacuous; now fires)
- revert the overshoot arm       -> the absent-severity contradiction test reds
- a `+=` rebinding               -> the rebinding guard reds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* fix(#3829): finish the mirror update, and give status the same parser as the counts

A fourth adversarial pass. The important finding is that the PREVIOUS commit's mirror
update was HALF APPLIED, and the suite could not see it.

**1. The counts mirror was still on the retired parser.** That file carries TWO
helpers: `first`, used for `status:`, and `firstIn`, used for ALL FOUR counts. The
previous commit updated `first` and the comment above it, and left `firstIn` on
`split(':')[1].replace(/ /g,'')` — so the shipped block had moved to `-f2-` + end-trim
and the mirror had not, while the parity assertion stayed green.

It stayed green for the same reason this round's earlier negative control was vacuous:
on every fixture that existed, both parsers reach the COUNTLESS arm, so the rendered
message is identical and parity cannot see the divergence. Two fixtures now separate
them — `a self-consistent repaired breakdown` (10 == 10+0+0, so the retired parser
renders a full breakdown from a `findings:` block containing no such numbers) and
`tab-separated counts` (valid YAML the retired `tr -d ' '` made non-numeric). Reverting
`firstIn` reds both.

This is the same shape this PR's round-3 reply already recorded about itself: a fix
verified with a grep built from the strings just fixed. The region is checked by
reading it end to end now.

**2. `status:` kept the retired parser after the counts moved off it, and it is the
read where truncation costs most.** `cut -d: -f2` turned the valid YAML scalar
`status: clean:junk` into the bare `clean`, so an unusable status took the CLEAN arm
and suppressed BOTH the console report and the ledger. Driven, both parsers side by
side. The whole scalar matches no arm now, so the step reports. One parser for every
scalar this step reads, in both fences.

**Two claim corrections, no code change:**

- The previous commit implied block 1's console output was preserved for every
  well-formed review. It is not: `critical:\t1` is valid YAML that the retired
  `tr -d ' '` left non-numeric (countless form) and the trim now reads (full
  breakdown). That is an improvement, and the claim was the wrong shape. The
  `tab-separated counts` fixture pins it.
- "Block 1 and block 2 can no longer contradict each other about counts" was
  overstated. It is true of the PARSER, which is what changed. They can still differ
  when the body carries MORE findings than `total:` declares: the reconciliation
  reports a shortfall only, and the excess direction is deliberately clamped so a
  review under-declaring its own total cannot render `unparsed: -1` — pinned by the
  round-2 test `a total SMALLER than the rows is not reported as a negative shortfall`.
  That is pre-existing and out of this round's scope; stating it rather than widening
  scope again.

Negative controls, against the restored tree at 223/223 (387 across both files):
- revert the mirror's `firstIn`  -> both new parity fixtures red
- revert the `status:` parser    -> the clean-arm suppression test reds

`npm run lint:ci` exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* fix(#3829): pin the reads to LC_ALL=C, so the parser cannot depend on the machine

A fifth adversarial pass. It refuted the claim that the shipped reads and their JS
mirror are equivalent, and the counterexample is a locale.

**The POSIX character classes are locale-defined, and glibc's C.UTF-8 disagrees with
both C and en_US.UTF-8.** Driven, same sed, same input, three locales:

    LC_ALL=C          clean<U+2003>  ->  clean<U+2003>   (kept)
    LC_ALL=C.UTF-8    clean<U+2003>  ->  clean           (trimmed)
    LC_ALL=en_US.UTF-8 clean<U+2003> ->  clean<U+2003>   (kept)

C.UTF-8 classifies U+2003 — and U+1680, U+2000-U+200A, U+205F, U+3000 — as BOTH
[[:space:]] and [[:blank:]]. So `status: clean<U+2003>` trimmed to the bare `clean`,
took the CLEAN arm, and silently suppressed both the console report and the ledger —
but only on machines whose locale said so. That is the same suppression the previous
commit fixed for `clean:junk`, with a machine-dependent trigger instead of a parse one.

`[[:blank:]]` is NOT the fix — it is locale-defined too, and C.UTF-8 puts U+2003 in it
as well. Nor is `[ \t]`: POSIX bracket expressions provide no escape at all, so `\t` there is a
backslash and a `t`. GNU sed's default reading of it as TAB is an extension, and the
same GNU sed asked for conformance shows the other reading on this host:

    sed -E          's/[ \t]+$//'  draft  ->  draft
    sed --posix -E  's/[ \t]+$//'  draft  ->  draf

A POSIX-conforming sed is therefore expected to truncate `status: draft` to `draf`.
That expectation is derived from POSIX plus the `--posix` demonstration above; it was
NOT driven against a BSD/macOS sed, because this host has none. The portable
fix is to pin the locale: under C the class is exactly {space, tab, NL, VT, FF, CR},
which is precisely what the mirror already spells out literally. The two now agree by
construction rather than by coincidence of the machine.

All 20 read sites (10 `grep`, 10 `sed`, both fences) are pinned. The mirror's four
anchors move from JS `\s` to the same literal class, closing the divergence in the
other direction — `\s` matches a U+2003 indent that the pinned `grep` does not.

**A second gap, found by this round's own control rather than by the reviewer.** The
mirror has two helpers, and the previous commit proved `firstIn` (the counts) was
pinned by a fixture. `first` (the status) was NOT: reverting it left the suite green.
Every pre-existing status fixture left both parsers on the SAME arm — `issues:found`
truncates to `issues`, which is no more `clean` than `issues:found` is — so the status
mirror could drift unseen, exactly as `firstIn` had. The fixture that separates them
is one where truncation FLIPS the arm: `status: clean:junk`.

That is the third time this round a mirror edit was invisible to the fixtures that
existed, and the question that finds it every time is: what input actually separates
the two versions?

**Two prose corrections in the step**, which had gone stale rather than wrong-headed:
the block-2 comment still said "the sibling reads use `tr -d`" after they had all been
moved off it, and the mirror's class comment claimed an equivalence it did not yet have.

Negative controls, each driven against the committed tree:
- drop LC_ALL=C from the shipped seds  -> 5 red, incl. both locale-invariance tests
- revert the `first` status mirror     -> `a status whose truncation would flip the arm` reds
- revert the mirror anchors to `\s`    -> `locale-invariant on a unicode-space indented count key` reds

The locale-invariance tests are the durable guard: the parity fixtures only run under
whatever locale the suite inherits, so they can catch this only on a machine that
already has the bug. These drive the same input under both locales and assert the
shipped fence does not care.

238/238 across the two pipeline files. `npm run lint:ci` exits 0, with
lint-workflow-shellcheck reporting 212 pre-existing findings and 0 new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* fix(#3829): pin the awk selectors too, and assert the invariant instead of claiming it

A sixth adversarial pass, and it refuted the previous commit's central claim. That commit
pinned all 10 `grep` and all 10 `sed` reads to LC_ALL=C and then said the parser no longer
depends on the machine. It does: the `findings:` MAPPING SELECTOR is an `awk`, and both
copies of it were left unpinned.

`awk '/^findings:[[:space:]]*$/{f=1; next} f&&/^[^[:space:]]/{exit} f'` resolves its two
character classes through the ambient locale exactly as grep's and sed's did. Driven, on
`findings:<U+2003>`:

    fence 1, LC_ALL=C        Code review found issues.
    fence 1, LC_ALL=C.UTF-8  Code review: 1 findings — 1 critical, 0 warning, 0 info.
    fence 2, LC_ALL=C        TOTAL=''
    fence 2, LC_ALL=C.UTF-8  TOTAL=1

Under C.UTF-8 the opener matched and the mapping opened; under C it did not. The same
review rendered a breakdown on one machine and the countless message on another, with
every grep and sed already pinned.

**Why it was missed is the more useful part.** The previous commit's census counted
`grep` and `sed` sites and reported zero unpinned — because it SEARCHED FOR THE TOOLS IT
HAD JUST EDITED rather than for the tools that were there. That is the same shape as this
round's other three misses: a check built from the thing just changed cannot see what the
change forgot. So this commit does not just add the fourth and fifth pins; it replaces the
claim with an assertion the next edit cannot fool:

  `every locale-sensitive tool in the step is pinned to LC_ALL=C` walks the step file and
  fails on ANY unpinned `grep`/`sed`/`awk`, naming line and call. `cut -d: -f2-` and
  `tr -d '\r'` stay exempt, and the exemption is principled rather than residual: neither
  resolves a character class or a collation — one splits on a single ASCII byte, the other
  deletes one literal byte.

The mirror's block boundary moves to the same literal classes, for the same reason the
anchors did last commit — `/^findings:\s*$/` and `/^\S/` model neither pinned side.

**A correction to the previous commit's message, made in place.** It asserted that BSD sed
reads `[ \t]` as a literal backslash and `t`, stated as driven fact. It was not driven —
this host has no BSD sed. The claim is now stated as what it is: POSIX bracket expressions
provide no escape, GNU's TAB reading is an extension, and GNU sed asked for conformance
demonstrates the other reading here (`sed --posix -E 's/[ \t]+$//'` turns `draft` into
`draf`). The conclusion is unchanged; the evidence class was overstated.

Also measured while establishing that LC_ALL=C is safe for non-ASCII, and worth recording
because it makes the pin a strict improvement rather than a wash: on a REVIEW.md carrying a
single invalid UTF-8 byte, GNU grep under C.UTF-8 reports `binary file matches` and emits
nothing, blanking EVERY read; under C the value parses and is rejected on its merits. Valid
UTF-8 is untouched either way — the C space class is entirely bytes < 0x80, which no UTF-8
multibyte sequence contains, so the trim cannot split a character.

Negative controls, each driven and restored:
- unpin the four `awk` selectors  -> 3 red, incl. the new invariant test naming both lines
- revert the mirror block boundary to `\s`/`\S` -> both `findings:` opener tests red

241/241 across the two pipeline files. `npm run lint:ci` exits 0 — after it caught a real
defect in the new test itself: `split('\n')` on readFileSync content is banned here
(DEFECT.WINDOWS-CRLF-TEST-PORTABILITY), and it now uses `splitLines()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* test(#3829): make the pin invariant see calls that are not piped

The invariant added in the previous commit keyed on `| grep|sed|awk`, which is true of
every call in the step today and is exactly the wrong thing to rely on. A guard written
around the shapes that happen to exist cannot see the shape a later edit introduces —
`awk '...' < "$f"` or `$(grep ...)` would have walked straight past it, which is the same
property that let the two awk selectors sit unpinned through a commit claiming the parser
was locale-independent.

It now blanks the PINNED calls and treats anything still naming one of the three tools on
a non-comment line as an offender, so the check is "every call is pinned" rather than
"every piped call is pinned". Comment lines stay exempt: the step's prose names unpinned
forms while explaining why they were retired.

Driven both ways against the invariant alone:
- inject a NON-PIPED unpinned `awk '...' < "$REVIEW_FILE"` -> reds (the old form did not)
- unpin the four piped `awk` selectors                     -> still reds (no regression)
- unmodified tree                                          -> green

241/241 across the two pipeline files, `npm run lint:ci` exits 0. No shipped behaviour
changes in this commit; it only widens what the test can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* test(#3829): close the invariant's path-qualified hole, and state the limit it keeps

A seventh adversarial pass. It confirmed the shipped fix — census clean at 24 pinned calls,
whole-fence output identical under C and C.UTF-8 on every input built to separate them, and
both fences byte-identical on a well-formed review carrying `café 東京` and `naïve résumé` —
and then refuted the claim I made about the GUARD, not the feature.

`/usr/bin/awk '/[[:space:]]/{exit}' < "$REVIEW_FILE"` passed the invariant. The preceding-
character class shielded any match preceded by `/` or `.`, so a path-qualified call was
invisible. A path-qualified call is still a call; the class no longer shields either. The
substrings that motivated the exclusion are unaffected — `parsed`, `passed` and `awkward`
have a word character on one side or the other, so the boundary still rejects them.

**The rest of that finding is disclosed rather than fixed, deliberately.** `$AWK "$f"` and a
command name computed inside the embedded `node -e` block also evade the check, and they are
not closable by this mechanism: it scans text, not shell or JavaScript command structure. The
same pass that found them showed that widening the regex further only trades those false
negatives for false positives on quoted strings and awk program text. So the test now STATES
its boundary instead of implying it has none — the previous comment claimed a census "a future
edit cannot fool", which was exactly the kind of overclaim this round has been correcting.
What it catches is enumerated there, driven, along with which direction its false answers go.

Driven against the invariant alone, each mutation reporting its own substitution count:
- `/usr/bin/awk ... < "$f"` (the exact evasion) -> reds; before this commit it did NOT
- `env awk ... < "$f"`                          -> reds
- `LC_ALL=C.UTF-8 awk ... < "$f"` (wrong pin)   -> reds
- unmodified tree                               -> green

241/241 across the two pipeline files, `npm run lint:ci` exits 0. No shipped behaviour changes
in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* test(#3829): stop the guard's comment promising a closed list of what it misses

An eighth adversarial pass. It confirmed the delta was test-only and that both fences are
byte-identical across it — exit, stdout, stderr and rendered ledger — and then refuted the
comment again, with two more evasions: a command name fragmented in shell (`a''wk`), and an
executable command substitution on a physical line starting with `#` inside a multiline
quoted argument, which the comment exemption skips.

Both are real. Neither is the point. Three passes running have each found one more evasion of
a TEXT scan, which is the actual finding: **the list cannot be closed.** A comment that
enumerates residuals is false the moment someone is cleverer than the enumeration, and fixing
it by appending the newest example just resets the clock.

So the comment no longer claims an inventory. It says what this is — a regression guard against
the accident that has now happened twice in this round, a read added or edited without its pin
in a file where every other read has one — and what it is not: a proof. The examples are marked
as illustrations. The operative instruction is the one that survives any future evasion: treat
anything it reports as real, and never treat its silence as proof a new read is pinned.

No logic changed; the guard catches exactly what it caught before. 241/241 across the two
pipeline files.

`npm run lint:ci` exits 0 — after it caught this commit twice over, which is worth recording
because both were in prose I had just written to be careful:
- the previous message's example path `docs/grep.md` read as a genuine docs reference from this
  file, and lint-docs-guard-registration demanded a baseline entry for a path that does not
  exist. The illustration is now `bin/grep-wrapper`, and the reason is stated inline.
- an earlier commit's `split('\n')` on readFileSync content tripped the CRLF-portability rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* test(#3829): the guard's two directions are not symmetric, so stop saying they are

A ninth adversarial pass. It confirmed the delta before it was comment-only with the guard's
logic byte-identical, and that all six advertised forms are still caught — and then found the
one absolute the rewrite left behind.

The comment said "treat anything this guard reports as real" three lines above admitting the
guard produces loud false positives. Driven: `echo ok # grep is discussed` is reported, and
labelled `(unpinned)`, though it is prose and no unpinned read exists. Both sentences were
mine, in the same comment, written in the same edit that was supposed to remove overclaiming.

The instruction is now the accurate one, which is that the two directions are NOT symmetric:
a REPORT is cheap to adjudicate — read the line, a trailing comment or a path is obvious — while
SILENCE proves nothing, because the known evasions are silent and so is any evasion nobody has
thought of yet. Investigate every report; never read silence as proof a new read is pinned.

Comment-only. Guard logic untouched, `gsd-core/` byte-identical to cac648aab — three consecutive
passes have now confirmed the shipped behaviour unchanged. 241/241 across the two pipeline files,
`npm run lint:ci` exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* test(#3829): a report needs its context to adjudicate, not just its own line

A tenth adversarial pass, and the last one this round runs. It confirmed `gsd-core/` is the
SAME TREE OBJECT as at cac648aab (105b5ed9b) with the guard's caught and silent sets unchanged,
and refuted one more sentence of the same comment.

"A report is cheap to adjudicate (read the line)" is false, driven: the identical reported
physical line `  grep` is a COMMAND after `:` and an ARGUMENT after `printf '%s\n' \`. The guard
reports both, and the reported line alone does not distinguish them — the preceding line is what
settles it. The comment now says so, with that counterexample in it.

This is the fourth consecutive pass to find a defect in this one comment and none in the shipped
code, which is itself the result worth recording: the shipped fix has been frozen since cac648aab
and confirmed byte-identical by four passes, while the prose describing a best-effort text scanner
took four attempts to stop overclaiming. Writing an accurate description of what a heuristic does
NOT do turns out to be harder than the heuristic.

Comment-only; guard logic untouched. 241/241 across the two pipeline files, `npm run lint:ci`
exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* test(#3829): re-home the lookup's phase-id gate beside the step, not in a block upstream removed

#4781 (#4628) removed #4748's letter-axis work from `tests/nsegment-phase-grammar.test.cjs`,
including the `#4748 — the REVIEW.md lookup` describe block. This PR had four tests living in
that block, because that is where the gate was when #3829 moved the lookup out of
`execute-phase.md` and into the lazily-read step file.

Those four tests assert properties of THIS PR's step file, not of #4748's sites. Rebasing onto
the removal would have deleted them silently — the branch would still be green, with its own
coverage quietly gone. They move here instead, unchanged in substance, beside the step they
guard: an unrelated upstream revert can no longer take this PR's coverage with it.

One assertion did NOT come along. The old block also checked that `execute-phase.md`'s init
parse list names `padded_phase`; #4781 removed that field from the list, and the assertion is a
property of #4748's site rather than of this step. Carrying it here would only have pinned
someone else's revert to this PR.

The step never depended on that field in the first place — it computes PADDED itself, validating
PHASE_NUMBER for shape and traversal and padding the digit run through `10#` while carrying an
optional letter verbatim. That self-containment is why the removal costs this PR nothing but the
tests' address.

5 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* chore(#3829): regenerate the derived artifacts the round-12 rebase invalidated

The base moved from 092d9256b to 003d982c8 (six merges) while this round was in flight, so the
branch was rebased and the derived state had to be re-derived rather than hand-merged.

`platform-conformance-tier.generated.cjs` regains `tests/configured-entrypoint-validation.test.cjs`,
added upstream by #4249. Resolving the conflict hunk-by-hunk in favour of this branch had dropped
that entry; `lint:generated-sync` caught it, which is what that check is for.

`compact-content-benchmark-baseline.json` carries the measured values at the new base rather than
this branch's stale pair: split "execute-phase" off 26200 -> 26242, on 23949 -> 23952
(8.59% -> 8.73%); aggregate off 108243 -> 108285, on 91595 -> 91598 (15.38% -> 15.41%). The
benchmark reports drift and exits 0 either way, so a stale baseline does not announce itself here
— it announces itself in CI.

**The growth acknowledgment is back, and the reason is worth stating.** Against the previous base
this PR left `execute-phase.md` a net -103 bytes: the extraction removed more than the dispatch
paragraph added, so the file ended up smaller than the base's copy and the
`Emitted-Drift-Ack-Growth` trailer became false and was dropped. #4781 then rewrote that file
upstream, and against the new base the same extraction nets +36 bytes (93421 -> 93457) — which is
the figure this PR originally reported at round 2. The size delta was never a property of this
change alone; it is a property of this change against whichever base it sits on, and it has now
been both signs in one round. The trailer was restored then. Round 14 rebased onto `029acd915`, where #4830's
re-land moved `execute-phase.md` again and the same extraction is -103 once more
(93564 -> 93461), so the trailer is false a second time and this commit no longer
carries it. Third sign flip, same reason each time.

`lint:generated-sync` and `benchmark-compact-content --check` both clean afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9qJPShuor4FqtZSX25jks

* chore(#3829): regenerate derived artifacts after rebase onto next

The rebase onto `c9a5cc3e1` conflicted in the 19 install-tree goldens.
Those are generated, so they were resolved arbitrarily and regenerated
with their own producers (`npm run regen:derived`, then
`benchmark-compact-content.cjs --write`) rather than hand-merged — a clean
textual merge of a generated file attests the merge, never the content.

Reconciled per artifact against the base's own committed copy rather than
against the pre-regen tree, because the pre-regen tree is the arbitrary
resolution:

- every install-tree golden now differs from `c9a5cc3e1`'s copy by exactly
  one entry, `gsd-core/workflows/execute-phase/steps/code-review-disposition.md`,
  which is this PR's own new step file;
- the compact-content benchmark baseline by the `execute-phase` entry
  (offTokens 26184 -> 26167) and the aggregate that sums it.

`docs/INVENTORY-MANIFEST.json` was in the at-risk set but regenerated
byte-identical, so it carries no change here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJPv9LNaPV3Cd3NCpfFKGb

* chore(#3829): regenerate derived artifacts after rebase onto 029acd915

The rebase onto current `next` conflicted in two generated files — the
compact-content benchmark baseline and the platform conformance tier. Both
were resolved arbitrarily and regenerated with their own producers
(`npm run regen:derived`, then `benchmark-compact-content.cjs --write`)
rather than hand-merged: a clean textual merge of a generated file attests
the merge, never the content.

Reconciled per artifact against the base's own committed copy rather than
against the pre-regen tree, because the pre-regen tree is the arbitrary
resolution. Every differing key belongs to a file this PR actually touches:

- `tests/fixtures/install-tree/claude.json` differs from `029acd915`'s copy
  by exactly one entry,
  `gsd-core/workflows/execute-phase/steps/code-review-disposition.md`, this
  PR's own new step file;
- `scripts/lib/platform-conformance-tier.generated.cjs` by exactly one
  entry, `tests/code-review-fix-pipeline-regression.test.cjs`, a test this
  PR adds;
- `tests/fixtures/compact-content-benchmark-baseline.json` by the
  `execute-phase` entry (offTokens 26344 -> 26280) and the aggregate that
  sums it, which this PR moves by editing `execute-phase.md`.

The other 18 install-tree goldens, `docs/FEATURES.md` and
`docs/INVENTORY-MANIFEST.json` were regenerated too and came back
byte-identical, so they carry no change here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP

* test(#3829): follow #4748's REVIEW.md-lookup gate to the step that now owns the lookup

Self-found while rebasing onto `029acd915`, not raised in review.

#4830 (`8a5166598c`) re-landed #4768's letter-suffix work on `next`, restoring the
`#4748 — execute-phase.md resolves the REVIEW.md path from init's padded_phase`
block in `tests/nsegment-phase-grammar.test.cjs`. That block had been removed by
#4781, which is why round 13 re-homed this PR's own four tests out of it. The
restored block anchors on a line this PR deletes:

    expected 1 line(s) containing "REVIEW_FILE=\"${PHASE_DIR}/${PADDED}-REVIEW.md\"", found 0

It fails at the describe level, so all four of its tests go with it. It was green
before this rebase only because the base did not carry the block yet.

Putting the line back is not available. #3829 moved the lookup into the lazily-read
step file because `execute-phase.md` did not fit under ADR-857's frozen pre-phase-6
ceiling (93600); the parent is at 93461, and the three lines this gate anchors on
cost 183 (measured, not computed: `git show 029acd915:... | sed -n '1168,1170p' | wc -c`). The block would also be dead code — the step performs the lookup.

So the gate follows the lookup. Two of its four assertions are properties of the
lookup and are re-pointed at the step file: that no fence hands PHASE_NUMBER to
`printf "%02d"`, and that both lookups are preceded by a PADDED binding that pads
the digit run through `10#` and carries the letter verbatim. The regression control
on the lookup line itself comes along, now over both fences. The init-parse-list
assertion stays on `execute-phase.md`, which still names `padded_phase`.

Two assertions do NOT come along, and they are the two that were properties of the
INLINE site rather than of the lookup: the `PADDED="{padded_phase}"` literal binding
(the step derives PADDED itself, validating PHASE_NUMBER for shape and traversal
first), and the composition run over the three live lines. The step's executable
coverage — a composition run plus a padding-agrees-with-the-canonical-normalizer
matrix over letter ids — already exists in `tests/code-review-pipeline-regression.test.cjs`
under "#3829 — the step's REVIEW.md lookup resolves a letter-suffixed phase without
a shell re-pad". Mirroring it here would be a second implementation of one grammar.

That coverage is NOT equivalent, and the difference is worth stating rather than glossing. At its
original site #4748's gate was a DATAFLOW pin: `execute-phase.md` bound init's own
`{padded_phase}`, so the lookup could not disagree with the canonical normalizer because it never
computed anything. The step reconstructs the value in shell, so that pin is not available at this
address and AGREEMENT with the normalizer is what replaces it. A follow-on commit adds a
fast-check property asserting that agreement over generated ids, because the existing 13-shape
matrix samples 2 of 26 letters and cannot see a divergence outside its own points.

One consequence is disclosed rather than absorbed: `padded_phase` is now parsed but unused in
`execute-phase.md` (`:95`). Removing it from that parse list is #4830's call on its own site, not
this PR's, so the assertion that it is still named stays.

#4748's property is unchanged: a letter-suffixed phase resolves its own REVIEW.md,
and an already-padded `08` does not read as octal.

Negative-controlled rather than asserted. Against the shipped step: 162 pass, 0 fail.
Dropping `$_let` from both PADDED bindings reds "every lookup is preceded by a PADDED
binding that carries the letter run"; dropping `10#` reds it too. The step file was
restored byte-identical after each control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP

* test(#3829): assert the step's padding against the canonical normalizer by property, not by 13 points

From this round's own pre-push adversarial review, not from the maintainer's.

The prior commit re-points #4748's REVIEW.md-lookup gate at the step that now owns the lookup. The
review's finding was that this is not coverage-equivalent, and it is right: at the original site the
gate was a DATAFLOW pin — `execute-phase.md` bound init's own `{padded_phase}`, so the lookup could
not disagree with the canonical normalizer because it never computed anything. The step reconstructs
the value in shell, so agreement with the normalizer is what has to replace the pin.

That agreement was already asserted, but over a 13-shape matrix. Its words: "future canonical
grammar changes could therefore diverge without this gate detecting them." Correct — the matrix
samples 2 of 26 letters and a bounded set of segment shapes, and this PR has twice been told that a
generator which cannot reach the interesting input is a fixture with extra steps (round 3's
SOURCE_CELL, round 5's title generator). Same defect, third address.

So the agreement is now a property over generated ids: a digit run inside the step's own 8-digit
bound, an optional single A-Z, and up to two dot segments, asserted equal to
`normalizePhaseName(id)` through the shipped shell derivation of BOTH fences. Milestone `N-N` forms
are outside the step's accepted domain and are asserted nowhere here rather than silently passed.

Negative-controlled on two mutations, and the second is the one that justifies the property rather
than the matrix:

  %02d -> %03d                      matrix RED,   property RED
  drop the letter when outside {A,B} matrix GREEN, property RED

The second is the added coverage, demonstrated rather than argued: the matrix is structurally
unable to reach a letter it does not enumerate. The step file was restored byte-identical after
each control.

numRuns is 25 — each case spawns bash twice through the process seam, once per fence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP

* fix(#3829): pad the phase id as a string, so the step agrees with the canonical normalizer

Found by the property the previous commit added, on its second adversarial pass. This is a real
divergence in shipped behaviour, not a test-only correction.

`normalizePhaseName` (src/init.cts, via gsd-core/bin/lib/phase-id.cjs) left-pads a phase id's digit
run to a MINIMUM of two and otherwise PRESERVES it — `padStart(2, '0')`. The step re-derived the same
value arithmetically, `printf "%02d" "$((10#$_dig))"`, which does not preserve: it collapses every
leading-zero run longer than two.

  id          normalizePhaseName   the step (before)
  8           08                   08
  08          08                   08
  008         008                  08     <-- diverges
  0008        0008                 08     <-- diverges
  00000008    00000008             08     <-- diverges
  0008A       0008A                08A    <-- diverges

Consequence: for such an id the gate resolves `08-REVIEW.md` while init emits `008-REVIEW.md`, so it
finds no review and says so — advisory, and therefore silent. That is the failure class #4748 exists
to close, reached by a different road: not a letter this time, but a leading-zero run.

The fix is a string pad that implements `padStart(2, '0')` exactly, at both fences:

  case "${#_dig}" in 1) PADDED="0${_dig}${_let}${_sub}" ;; *) PADDED="${_dig}${_let}${_sub}" ;; esac

It is strictly less machinery than what it replaces. `10#` existed only to stop bash reading a
leading zero as octal inside `$(( ))`; with no arithmetic there is no octal hazard to guard, so the
remedy is retired rather than kept. `scripts/lint-phase-id-drift.cjs` — which exists to flag
unsanctioned `printf "%02d"` re-pads of phase-carrying variables — is green, and now has one less
re-pad to tolerate.

Two dependent assertions move with it, and both are now stated as the PROPERTY rather than as one
spelling of the remedy: the round-14 gate in `tests/nsegment-phase-grammar.test.cjs` asserts the
binding does no arithmetic and carries both `${_dig}` and `${_let}`, and the regression pin in
`tests/code-review-pipeline-regression.test.cjs` follows the new form.

The property's generator is widened in the same commit, because its first cut could not have found
this: it built the digit run with `String(fc.integer(...))`, which can never produce a leading zero,
so it had silently LOST the `08`/`09` coverage the 13-shape matrix beside it already had. The run is
now generated as a digit string, and segment depth goes to four (the repo exercises `1.2.3.4`). The
step's grammar is unbounded in depth; four is a stated bound, and it is this property's residual.

Negative-controlled, and the matrix is the control's control — it stays GREEN on both:

  revert to the arithmetic pad          matrix GREEN, property RED
  drop the letter when outside {A,B}    matrix GREEN, property RED

The step file was restored byte-identical after each. 407 pass / 0 fail across both test files;
lint:ci, gen-features --check, gen-platform-conformance-tier --check, benchmark-compact-content
--check and gen-install-tree-fixtures all clean, with no regenerated artifact moving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP

* test(#3829): hold #4748's gate by execution, and retire the comments the string pad made false

Third adversarial pass on this round. Two findings, both fair, neither a correctness defect.

FIRST — the gate pinned a SPELLING, not the property. Its objection was concrete: an equivalent
multi-line string pad would have failed a regex that matches one `case ... esac` line. That is a
false-positive generator, and a guard that false-fires is a guard that gets deleted.

The split is now honest about what each layer can hold. A STATIC gate can hold the DEFECT SHAPE —
no arithmetic in the binding above each lookup — and that is all it asserts. Correctness is held by
EXECUTION: the base gate regains a composition test that runs the shipped derivation slice of both
fences against `normalizePhaseName`, over `3A 8 9 08 008 0008A 23A.1.2`.

That composition test is the one I removed two commits ago, and removing it was the weaker call. At
#4748's ORIGINAL site the gate could be static because the property was a literal binding of init's
own `{padded_phase}`; nothing could disagree, because nothing computed. At this site the step
derives the value, so the property is behavioural and only execution holds it. `008` is in the list
because it is the case the arithmetic pad got wrong and no prior fixture covered.

Controlled three ways, and the middle one is the finding being answered:

  arithmetic pad restored            4 fail   caught
  EQUIVALENT multi-line string pad   0 fail   no false positive
  drop the letter outside {A,B}      1 fail   semantic drift caught

SECOND — the step carried comments the fix had made false, in four places. Two explained `10#` as
part of the live phase derivation; two justified the eight-digit bound by bash integer overflow.
Neither described the code any more. They are rewritten to current truth rather than annotated,
because a fragment carries no supersession marker and overturned prose reads as canon:

  - the octal rationale now says the pad performs no arithmetic and needs no `10#`, and notes that
    `10#` survives in this step only on the severity COUNTS, which really are numbers being added;
  - the length bound now states that overflow is unreachable since the pad stopped converting, and
    that the bound stays for the reason it always also had — every component is interpolated into a
    filename, and filesystem components are finite.

408 pass / 0 fail across both files. lint:ci, lint-phase-id-drift, gen-features --check,
gen-platform-conformance-tier --check, benchmark-compact-content --check and the
prompt-injection-scan security suite are all clean, and no regenerated artifact moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP

* docs(#3829): correct four comments, including one this round's own rewrite got wrong

Fourth adversarial pass. Comment-only; no code, no test logic, no regenerated artifact moves.

ONE OF THESE IS MY OWN ERROR, introduced two commits ago. Rewriting the length-bound rationale, I
replaced the dead integer-overflow justification with "the bound stays because every component is
interpolated into a filename and filesystem components are finite". That is false, and it was driven
false: the bound is PER SEGMENT, every segment is joined into ONE filename component, and depth is
unbounded. Thirty 8-digit segments yield a 278-character PADDED and a 294-character name against a
NAME_MAX of 255. Replacing a dead rationale with a wrong one is worse than leaving the dead one, so
the comment now states what the bound actually does and names the composite-length gap as a residual
of this validator that predates the pad change. It is not fixed here; it is stated.

The other three are stale rather than wrong:

- `overflow guard` named the length check in two places. Nothing overflows any more -- the pad does
  no arithmetic -- so it is the digit bound, and is called that.
- The `#4748` block header in `tests/nsegment-phase-grammar.test.cjs` still said the step pads
  through `10#`, still said the composition run did not survive the move, and still said the
  executable coverage was "cited rather than copied" -- while the composition test sat twenty lines
  below it. All three were true when written and none survived this round. The header now records
  why a STATIC assertion cannot hold a BEHAVIOURAL property, and that the PR's fast-check property
  is a different instrument over the same contract rather than the same test twice.
- The severity-count comment said a base-inference failure "takes the whole advisory step down under
  `set -e`". It does not: the arithmetic sits inside an `if` condition, a TESTED context, where
  `set -e` is inert, and the consistency check is SKIPPED instead -- which the regression suite
  already records. `10#` stays; only the account of what it prevents is corrected. This one predates
  the round and is corrected because it is adjacent and factually wrong, not because it blocked
  anything.

408 pass / 0 fail across both files; lint:ci, lint-phase-id-drift, gen-features --check,
benchmark-compact-content --check and the prompt-injection-scan security suite all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nAJFVZcCZHtSgYmWM5WNP

* chore(#3829): regenerate the compact-content benchmark baseline after the rebase onto fac0e9de8

The rebase onto current `next` conflicted on this generated fixture, as it has in every
recent round: the base regenerates it for its own token deltas and this branch regenerates
it for `execute-phase.md`'s, so both sides rewrite the same keys. Resolved arbitrarily
during the replay and regenerated with its own producer
(`scripts/benchmark-compact-content.cjs --write`), never hand-merged.

Key-level drift against the base's committed copy is exactly two entries and both are this
PR's own: `splits.execute-phase` (the workflow this PR edits) and `aggregate`, which is the
sum over the splits and therefore moves whenever any split does. Zero foreign keys moved.

The full generator sweep was re-run after the replay -- gen-features, gen-inventory-manifest,
gen-platform-conformance-tier (both targets), gen-install-tree-fixtures and the benchmark --
and this fixture is the only artifact that moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5xnzzipRHPHq8Z3doQvn3

* chore(#3829): regenerate the compact-content benchmark baseline after the rebase onto b956bb7c6

`next` moved again while this round was running -- #4902 landed at 06:27Z and touches this same
generated fixture -- so the branch went back to CONFLICTING within minutes of the previous push.
This is the second rebase of the round, not a correction of the first.

Resolved arbitrarily during the replay and regenerated with its own producer
(`scripts/benchmark-compact-content.cjs --write`), never hand-merged. Key-level drift against the
new base's committed copy is again exactly `splits.execute-phase` and `aggregate` (the sum over
splits) -- zero foreign keys.

The full generator sweep was re-run after this replay as well; this fixture is the only artifact
that moved. Build inputs were untouched by the base range, so the lane's existing build stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5xnzzipRHPHq8Z3doQvn3

* fix(#3829): refresh the launcher preamble in place, without hoisting it above the guard

Found by this round's own post-rebase validity gate, not by review: a reader flip-test over the 85
tests that read the two at-risk workflow files was clean before the replay and failed after it, on
`runtime-launcher-parity (#373)` invariant (B).

The cause is a real interaction. #4902 landed on `next` mid-round and rewrote the canonical launcher
preamble; invariant (B) counts occurrences of that exact snippet, so this step's older copy matched
zero times even though it sat in the right place. The remedy the invariant names --
`node scripts/sync-runtime-launcher.cjs` -- fixes the count but also HOISTS the preamble to the top
of the block, and that is wrong here: it moved the shim ahead of the status guard, and six of this
PR's own tests exist to pin that ordering (`runDispositionGuard` asserts the block opens with its
guard, then the shim). Running the tool verbatim turned one red into seven.

So the preamble text is refreshed to the current canonical snippet IN PLACE, at the offset it
already occupied. Both constraints hold at once, verified by execution rather than by reading:
invariant (B) sees exactly one canonical occurrence and it precedes the first `gsd_run` call, while
the guard still opens the block (shim at offset 19897 of the second fence, and the test wants > 0).
`runtime-launcher-parity` + `code-review-pipeline-regression` together: 282 tests, 281 pass.

Not fixed here, and not ours: `(K2) end-to-end: the resolved local tool honors
git.allow_default_branch_commits (#4834)` -- the one remaining failure -- fails identically on a
detached worktree at pristine `b956bb7c6` carrying none of this PR's content (37 tests, 36 pass,
same single failure). Reported rather than chased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5xnzzipRHPHq8Z3doQvn3

* fix(#3829): record the shortfall when NO finding in a review parses

Found by this round's own pre-push adversarial review, not by the maintainer -- and it is the
same defect class round 2 raised as Blocker 4, surviving in the one corner that round's fix did
not reach.

The unparsed reconciliation exists so a finding the CR|BL|WR|IN heading parser cannot match is
SURFACED rather than dropped. It reported faithfully whenever SOME findings parsed. It reported
nothing at all when NONE did, on a phase with no prior ledger and no fix report: a REVIEW.md
declaring two Criticals, both written under a prefix the alternation does not carry, produced no
ledger, no console line and no diagnostic. That is precisely the silent drop this reconciliation
was added to close, reachable exactly where the evidence is weakest -- the run in which not one
finding was understood.

Two exits discarded it, and the first one is the one that actually fired. The shortfall was
derived beside the render, while the exit that stands down for "nothing to record" keys on
order.length and sits ~200 lines earlier; it returned before the value existed. The later
rows.length exit had the same hole but was unreachable for this input. So the derivation moves
above the earlier exit -- order is final from the heading walk and never grows again, so the value
is unchanged -- and both exits now decline to fire while a shortfall is outstanding. The result is
a zero-row ledger carrying an unparsed key: an honest record that the review declared findings and
none of them were understood, which is strictly better than the file not existing.

Scoped, not removed. A genuinely clean review is untouched: a declared total of 0 is not greater
than order.length, so unparsed is 0 and both returns still fire exactly as before. The new test's
companion pins that, and it is why the exit was relaxed conditionally rather than deleted.

Driven at every step rather than reasoned about. Before the fix, three cases through the shipped
script: two unmatched findings on a first run wrote NOTHING; two unmatched plus one matched
reported unparsed: 1; two unmatched against an existing ledger reported unparsed: 2. Only the
first was silent, which is why the mechanism read as covered. After the fix the first renders a
ledger with unparsed: 2 and names it on the console, and the other two are byte-unchanged.

The new regression test was negative-controlled against the pre-fix step file and goes red there
(1 pass / 1 fail over the pair); post-fix both pass. Its companion clean-review control is green
on both sides, so the pair is not passing by accident.

The PR's six test files: 554 tests, 554 pass, 0 fail, 0 skipped. lint:ci exits 0. The generator
sweep still produces no drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn

* test(#3829): pin the SECOND exit that discarded the shortfall, and prove the pair kills it

Self-found by the round review of the previous commit, not by the maintainer: that commit added
the !unparsed conjunct to BOTH exits but tested only one of them. Deleting the later one left
every new test green -- a surviving mutant, which is coverage in name only.

The two exits are reached by different inputs, which is why one fixture cannot pin both. The
earlier exit stands down the moment a fix report exists, so an input carrying one sails past it
and lands on the later rows.length return. The fixture therefore needs a fix report that
contributes NO row: an id the alternation CAN match becomes a carried row, rows.length is 1, and
the later guard never decides. The first draft of this test used CR-99 and was vacuous for exactly
that reason -- it passed with the guard deleted. It names SEC-03 now.

Mutation-controlled in all three directions, since a test that kills no mutant pins nothing:

  earlier exit loses !unparsed   -> test 1 RED,  test 2 green, test 3 green
  later exit loses !unparsed     -> test 1 RED,  test 2 RED,   test 3 green
  both exits neutered (over-fire)-> test 1 green,test 2 green, test 3 RED
  unmutated                      -> all three green

Every test kills at least one mutant and no mutant survives all three, so the pair covers both
roads to the drop and the clean-review control covers the over-fire the relaxation could have
introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn

* test(#3829): close the fourth cell — the later exit's own over-fire

Self-found again by the round review, which drove the mutant set rather than trusting the matrix
the previous commit asserted: neutering ONLY the later exit survived all three tests. The previous
commit's matrix was accurate and incomplete, which is the more dangerous shape -- it reads as a
closed argument.

The guards form a 2x2 and only three cells were pinned. T1 pins the earlier exit's under-fire, T2
the later exit's under-fire, T3 the earlier exit's over-fire. Nothing pinned the LATER exit's own
over-fire, and it is reachable: a fix report -- even one naming no matchable id -- makes the
earlier exit stand down, so control arrives at the later exit carrying a genuinely clean review and
no shortfall. Neuter that exit and a phase with nothing to report grows a zero-row ledger reading
"0 of 0 finding(s) open", with all three earlier tests green through it. T4 is that case.

Five mutants driven over the four tests:

  earlier exit loses !unparsed        -> T1 RED
  later exit loses !unparsed          -> T1 RED, T2 RED
  both exits -> if(false)             -> T3 RED, T4 RED
  ONLY later exit -> if(false)        -> T4 RED          (the survivor this commit kills)
  ONLY earlier exit -> if(false)      -> all four green

The last one is reported as an EQUIVALENT mutant rather than an open cell, and the distinction is
the point of stating it: the earlier exit's over-fire condition is a strict subset of the later
exit's -- it adds only fixReports.length === 0 -- so removing it is subsumed and changes no
observable behaviour. A test cannot kill a mutant that does not alter output, and pretending
otherwise would mean writing one that asserts on internals.

The PR's six test files: 556 tests, 556 pass, 0 fail, 0 skipped. lint:ci exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn

* fix(#3829): keep the embedded record-builder inside a Windows command line

Block 2 runs the disposition record-builder as `node -e "<script>"`, so the entire
script is a single argv entry. Windows caps a command line at 32767 characters
(CreateProcess) and Node surfaces the overflow as ENAMETOOLONG from spawn -- the
process never starts. Linux's ~2 MB ARG_MAX cannot see that cliff at all.

The previous fix (bd83bc88e, hoisting the shortfall derivation above the earlier
exit) grew the extracted script from 31941 to 33353 characters. There were 826
characters of headroom; it spent 1412. On the next CI run ubuntu and macOS stayed
green and `conformance test (windows-latest, 24, shard 1/3)` went red with 83
failures -- 79 reporting `spawn_failed` out of runShippedDisposition, the other 4
asserting on a ledger that was never written. That same shard was SUCCESS at
596968aa0, the head before that commit.

Measured on native Windows (node v25.2.1), bisected: the largest `-e` argument that
still spawns is 32728 characters; 32729 fails. Both payloads driven directly:

    pre-fix   33353 chars  ->  SPAWN-FAIL ENAMETOOLONG
    post-fix  15916 chars  ->  SPAWN-OK

Note the shape of it: the fix that makes this gate report a silently-dropped finding
was itself silently dropped on Windows, because the whole script stopped launching.

What changes here is placement -- not content, not behaviour. 17 long rationale
comment blocks move out of the quoted payload into a new "Design notes for the
embedded record-builder" section in this file's prose, each anchored to the code line
it preceded so the pairing survives the move. Comment runs shorter than five lines
stay inline, where adjacency is cheap. Nothing is deleted.

    extracted script  33353 -> 15916 chars (16812 under the measured limit)
    executable code   byte-identical at 10853 bytes, verified by diffing the payload
                      with all comment lines stripped from both sides
    this file         78542 -> 79538 bytes -- the prose moved, it did not grow

Verified green: this PR's own test file at 250 tests across all 36 suites, 0 fail;
changeset-lint, docs-lint, default-flip-documentation, lint:ci, gen-emitted-baseline,
workflow-size-budget (133 tests), and lint-workflow-shellcheck (204 pre-existing
findings, 0 new).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn

* test(#3829): pin the embedded script under the Windows command-line budget

Nothing guarded the size of the `node -e` payload, so the regression the previous
commit fixes was invisible to every Linux gate and surfaced only as 83 Windows
failures that said `spawn_failed` and never mentioned length. Without a guard the
next addition to the script re-breaks Windows exactly the same way, and finds out
the same expensive way.

The test asserts the extracted script stays under a 24 KiB budget -- the 32767
CreateProcess cap less roughly 8 KiB of deliberate headroom, so the script has
somewhere to grow before this fires.

It is bounded from BELOW as well, and that half is the point: a pure length
assertion passes when the extractor returns '', which is exactly what a moved fence
or a renamed delimiter would produce. A guard that reports a comfortable 0 bytes is
the vacuous-oracle shape. The lower bound makes a broken extractor fail loudly here
instead of reporting success.

Negative-controlled rather than assumed. Against the PRE-fix step file the test goes
red on the real payload (33353 > 24576); against the fixed file it passes at 15916.
A new test that has only ever been run against fixed code can be green because it
hit a branch the bug never lived on.

This PR's own test file: 250 tests, 250 pass, 0 fail, 0 skipped, across all 36
suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn

* test(#3829): say what the command-line guard does not prove

The round review's MISSED, adopted. The guard counts the extracted JavaScript, but the
32767 cap applies to the whole serialized command line -- executable path, quoting and
backslash escaping included -- so a quote-heavy payload expands on the way out, and the
32728 figure it cites is one host's measured threshold rather than the CI runner's.

The budget is unchanged and still correct; only the claim around it moves. 24576 leaves
roughly 8 KiB for both effects, which is a practical margin, not a proof that everything
the guard admits will spawn. Stating that in the test is cheaper than having a future
reader infer a guarantee the assertion cannot make.

Comment-only. No assertion, no budget and no behaviour changes.

This PR's own test file: 221 tests, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfaJkgoq87837ZqWtTxqqn

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-22 21:04:09 -04:00
Tom Boucher
b956bb7c67 fix(#4834): gate the launcher PATH arm on runtime identity and prefer config-home installs (#4902)
* test(#4834): failing-first launcher hijack regressions

* fix(#4834): gate the launcher PATH arm on runtime identity and prefer config-home installs

A gsd_run on PATH that cannot prove it is @opengsd/gsd-core (a foreign package, or a
release older than the runtime-identity verb) is no longer accepted by the launcher
snippet's PATH arm; resolution falls through to the hard error when no path-based
candidate matches. The runtime-config-home arm now precedes the PATH arm, restoring
the documented prefer-local-over-PATH order, so an installer-managed install wins
even against a genuine global. The 16-home probe list is factored into _gsd_homes()
and the identity gate into _gsd_id_ok(), keeping the per-copy delta at +141 bytes.

The files whose frozen ceilings had no headroom (gsd-executor, gsd-plan-checker,
gsd-verifier, gsd-planner, execute-phase, execute-plan) now load the resolver by
@-include from gsd-core/references/gsd-run-resolver.md (the onboard.md pattern)
instead of carrying an inline copy. Propagated to all other inlined workflow/agent
copies via scripts/sync-runtime-launcher.cjs; the resolver reference re-copied
byte-equal (parity B2); the hard-error text, docs/how-to/diagnose-a-foreign-gsd-tools.md,
and the CONTEXT.md launcher predicate updated to match (#4834); the quick-batch row-48
guard gains the canonical-preamble sweep carve-out (#4834, per its own #3730/#2529
precedents); the compact-content benchmark baseline regenerated.

Emitted-Drift-Ack-Growth: add-backlog.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: add-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: add-tests.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: add-todo.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: ai-integration-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: audit-fix.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: audit-milestone.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: audit-uat.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: autonomous.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: check-todos.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: cleanup.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: code-review-fix.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: code-review.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: complete-milestone.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: debug.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: diagnose-issues.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: discuss-phase-assumptions.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: discuss-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: do.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: docs-update.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: edit-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: eval-review.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: explore.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: extract-learnings.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: fast.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: forensics.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: graduation.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-code-fixer.compact.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-code-fixer.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.compact.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-debugger.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-eval-auditor.compact.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-eval-auditor.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-intel-updater.compact.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-intel-updater.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-phase-researcher.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-project-researcher.compact.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-project-researcher.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-research-synthesizer.compact.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-research-synthesizer.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-ui-researcher.compact.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: gsd-ui-researcher.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: health.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: import.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: inbox.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: ingest-docs.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: insert-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: list-seeds.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: list-workspaces.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: manager.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: map-codebase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: milestone-summary.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: mvp-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: new-milestone.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: new-project.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: new-workspace.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: next.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: pause-work.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: plan-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: plan-review-convergence.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: plant-seed.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: pr-branch.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: profile-user.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: progress.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: quick-batch.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: quick.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: remove-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: remove-workspace.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: resume-project.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: review.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: scan.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: secure-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: settings-advanced.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: settings-integrations.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: settings.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: ship.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: sketch-wrap-up.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: sketch.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: smart-entry.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: spec-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: spike-wrap-up.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: spike.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: stats.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: sync-skills.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: thread.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: transition.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: ui-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: ui-review.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: ultraplan-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: undo.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: validate-phase.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)
Emitted-Drift-Ack-Growth: verify-work.md — launcher snippet resolution hardening propagates via sync-runtime-launcher (#4834)

* docs(#4834): backfill the changeset PR number

* test(#4834): regenerate the compact-content benchmark baseline after the rebase

---------

Co-authored-by: sim <sim@local>
2026-09-21 02:27:36 -04:00
Tom Boucher
eea9247c93 enhance(#4095): checkpoint:decision auto-selection is opt-in via auto_select (#4912)
* enhance(#4095): checkpoint:decision auto-selection is opt-in via auto_select

Auto-mode used to auto-select a checkpoint:decision's first <option>
unconditionally, making a decision checkpoint's safety depend on option
presentation order rather than an authored choice. Add an optional
auto_select="<option-id>" attribute on the <task> tag: absent, auto-mode
now escalates to a human exactly like gate="blocking-human" does; present,
it names the option auto-mode selects; naming an id with no matching
<option id> is a hard structural-validation error at plan-parse time
rather than a silent fallback to the first option. gate="blocking-human"
continues to win over everything, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4095): anchor auto_select/id attribute regexes past hyphenated decoys

An isolated adversarial review of the auto_select work found that both new
attribute regexes used \b as their left anchor, which is a word boundary,
not a "start of attribute name" boundary. A decoy attribute ending in the
same word (e.g. data-id="...") sitting before the real id="..." on the
same <option> tag matched first, silently corrupting the extracted option
id. Anchor on (?:^|\s) instead so only the real attribute name can match.
Adds a regression test reproducing the exact decoy-attribute shape, plus a
Unicode option-id test from the same review pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#4095): register auto-select-attribute.test.cjs in the docs-guard lane

lint-docs-guard-registration failed: the new test reads docs/reference/
plan-md.md but was not registered, so a future edit to that doc could
silently desync from the test without the guard catching it on the PR
that changed the doc. Registered alongside its direct precedents
(precondition-element.test.cjs, reversibility-tagging.test.cjs), which
read the same file for the same reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4095): fit the decision bullet under execute-phase.md's frozen byte ceiling

The remote gsd-test run caught what local checks missed: execute-phase.md
carries a frozen ADR-857 Phase-6 byte ceiling (93600) with only 36 bytes
of headroom before this change, and the original checkpoint:decision
wording pushed it to 93772 (over the ceiling). Cascaded into failures in
phase6-capstone-conformance, execute-phase-completion-reconciliation,
claude-orchestration, and the compact-content drift-report test.

Also caught: tests/package-legitimacy-gate.test.cjs anchors a
"decision is conditional, not unconditional" safety check on the literal
phrase "first option" in the decision bullet — which #4095 deliberately
removes, since there is no more unconditional first-option pick. The test
was asserting an assumption this change intentionally makes obsolete;
re-anchored on tokens that still identify the bullet ('decision',
'auto-spawn') without weakening what the test actually verifies (the
bullet must still carry a blocking-human carve-out).

Also fixed a word-order mismatch between my own new test's regex and the
actual doc text it was asserting against (tests/auto-select-attribute.test.cjs).

Regenerated the compact-content benchmark baseline
(tests/fixtures/compact-content-benchmark-baseline.json) to match the new
byte counts.

Emitted-Drift-Ack-Growth: gsd-executor.md — +7 bytes (49138 -> 49145), from the auto_select carve-out added to the checkpoint:decision auto-mode bullet; already trimmed once to fit the 49152 hard cap.
Emitted-Drift-Ack-Growth: execute-phase.md — +12 bytes (93564 -> 93576), from the same carve-out in the orchestrator's decision bullet; kept 24 bytes under the frozen 93600 ADR-857 ceiling after two rounds of trimming for clarity vs. margin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4095): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:47:01 -04:00
0xdhx
8a5166598c fix(#4830): re-land #4768's letter-suffix phase-id fix and its lint-phase-id-drift ratchets on current next (#4873)
* fix(#4830): re-land #4768's letter-suffix phase-id fix and its lint-phase-id-drift ratchets on current next

Commit 740ba0d8a (#4781) removed every change #4768 had merged for #4748:
the first-non-digit split at execute-phase.md's two arithmetic sites, the
init-emitted `padded_phase` the REVIEW.md lookup binds instead of
`printf "%02d"`, the canonical-grammar extractions in autonomous.md and
plan-review-convergence.md, the `.changeset/zesty-wolves-tumble.md`
fragment, and the three lint-phase-id-drift ratchets with their tests.
The guard and the code it guarded left together, so nothing went red.

This is a cherry-pick of 092d9256b onto current `next`, resolved against
the #4683 threat-id fields on execute-phase.md's Parse-JSON line, with the
changeset `pr:` reset to the placeholder and the compact-content benchmark
baseline regenerated against the current base.

(cherry picked from commit 092d9256b8)

Emitted-Drift-Ack-Growth: autonomous.md — restores #4768's canonical-grammar extraction and its explanatory comment for --from/--to/--only
Emitted-Drift-Ack-Growth: execute-phase.md — restores #4768's first-non-digit split at two arithmetic sites and the padded_phase binding for the REVIEW.md lookup
Emitted-Drift-Ack-Growth: plan-review-convergence.md — restores #4768's canonical-grammar phase extraction and its comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX7LXxc3uAkGki6iaYiAMP

* chore(#4830): set changeset fragment pr to 4873

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-20 00:01:21 -04:00
Tom Boucher
d435723c95 fix(#4782): claude's agents kind skips compact variants — consumed only by the non-claude persona gate (#4878)
* test(#4782): failing-first — claude install must not stage compact agent variants (and must prune stale ones)

* fix(#4782): claude's agents kind skips compact variants — they are consumed only by the non-claude persona-fallback gate

Emitted-Drift-Ack-Hash: agents/gsd-advisor-researcher.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-ai-researcher.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-assumptions-analyzer.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-code-fixer.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-code-reviewer.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-codebase-mapper.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-debug-session-manager.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-doc-classifier.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-doc-synthesizer.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-doc-verifier.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-doc-writer.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-dom-verifier.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-domain-researcher.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-eval-auditor.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-eval-planner.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-framework-selector.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-integration-checker.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-intel-updater.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-mempalace-curator.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-nyquist-auditor.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-pattern-mapper.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-project-researcher.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-research-synthesizer.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-roadmapper.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-security-auditor.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-ui-auditor.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-ui-checker.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-ui-researcher.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies
Emitted-Drift-Ack-Hash: agents/gsd-user-profiler.compact.md — #4782: claude never selects compact (the init agent-skills persona gate is non-claude-only), so the claude agents kind no longer stages name-shadowing copies

* chore(#4782): changeset fragment

* chore(#4782): backfill changeset PR number (4878)

---------

Co-authored-by: sim <sim@local>
2026-09-19 17:25:21 -04:00
Tom Boucher
c9a5cc3e12 fix(#4683): detect cross-plan threat-ID duplicates before execution (#4828)
admin_reason: missing-secondary-reviewer — self-authored overnight sweep; two orthogonal agent reviews ran (isolated adversarial REQUEST-CHANGES with all six findings dispositioned, plus a bypass/consumer-lens APPROVE) and the sha-pinned bench passed 46362/0 on the merged head.
2026-09-17 19:06:55 -04:00
Tom Boucher
2bfff17ff8 fix(#4682): route stale verification to the verifier regeneration path (#4818)
* test(#4682): add failing-first coverage for stale verification routing

* fix(#4682): route stale verification to the verifier regeneration path

The stale routing entry sent users to /gsd-verify-work — but verify-work
never rewrites VERIFICATION.md (its only write is the human_needed
canonicalization), so following the advice re-ran UAT, reached the same
stale check, and looped. init's projector and execute-phase's generic
next_command presentation both mirror this entry, so the dead end appeared
on three surfaces.

The stale entry now routes to execute-phase, and execute-phase's
all-plans-complete resume tree gains a stale arm (as a steps/ part, keeping
the spine under its frozen ADR-857 ceiling) mirroring the missing route:
skip cross_ai_delegation/execute_waves/checkpoint_handling, continue at
aggregate_results, and let verify_phase_goal re-dispatch the gsd-verifier —
regenerating VERIFICATION.md and its digest, marked phase or not. The
non-stale fall-through. Staleness detection, the digest format (#4623),
every other routing entry, and the #3684 resume arms are untouched.

Emitted-Drift-Ack-Growth: verify-work.md — stale stop rewritten to dispatch the verifier and re-check (#4682)
Emitted-Drift-Ack-Growth: execute-phase.md — VERIFY_STATUS == stale resume arm added to condition 3 (#4682)

* test(#4682): register the stale-reverification part and align projected commands

The new steps/ part must be registered in the inventory manifest and the
per-runtime golden install trees (regen:derived); the projected stale
next_command is /gsd-execute-phase <phase> (formatGsdSlash prefixes the
runtime surface), the human_needed bare-report probe keeps routing to
verify-work (unchanged semantics), and init-manager's recommended action
follows the new command.

* test(#4682): prefix the remaining stale routing assertions with the runtime surface

Nine stale next_command assertions and the human_needed bare-report probe
still carried the unprefixed or flipped forms from the earlier line-number
edit; all now assert the shipped /gsd-execute-phase <phase> projection,
with the human_needed probe reverted to its unchanged verify-work routing.

* test(#4682): align the last stale projection assertions with the execute-phase route

* docs(#4682): backfill changeset PR number

* test(#4682): refresh the compact-content baseline after the rebase

The rebase onto the #4670 squash brought verify-work.md's bounded
reconciliation text into this branch; the committed compact-content
baseline now reflects the post-rebase split sizes. Local --check is
clean; the previous bench drift (+243) was the baseline, not the diff.

* fix(#4682): carry the response_language directive in the stale-reverification part

The new steps/ part is its own coverage unit for lint-response-language-coverage;
it takes the shared canonical directive line like its sibling execute-phase
parts.

---------

Co-authored-by: sim <sim@local>
2026-09-17 02:23:03 -04:00
Tom Boucher
651511d1e3 fix(#4670): bound the commit-claim window to the plan's own history (#4813)
* test(#4670): add failing-first coverage for the bounded commit-claim window

* fix(#4670): bound the commit-claim window to the plan's own history

The reconciliation measured plan_head_before..HEAD — a window that grows
with every later plan's task and SUMMARY commits plus execute-phase's own
phase-completion commit — so an honest plan flagged commit_claim_mismatch
as soon as anything landed after it (real project: claims 3/5/2 measured
20/10/5).

The executor now also records plan_head_after (HEAD at its measurement
moment, after the last task commit, before the SUMMARY commit), and
verify-work reconciles exactly against plan_head_before..plan_head_after
with a merge-base ancestry check; SUMMARYs without the anchor fall back to
the legacy warning path instead of an unsound BLOCKER. Both #3968 failure
modes (claimed commits never made; task commits lost) still block, driven
by the issue's own fixture scenarios.

Emitted-Drift-Ack-Growth: verify-work.md — reconciliation gains the bounded window and legacy fallback (#4670)
Emitted-Drift-Ack-Growth: gsd-executor.md — plan_head_after anchor documented in the measurement protocol (#4670)

* fix(#4670): name the history-rewrite case and keep the executor under its cap

Review findings: the BLOCKER enumeration named only the two #3968 causes,
so an honest plan whose recorded window was rewritten afterwards (rebase,
amend, cherry-pick) got a mislabeled diagnosis — the clause now names that
case with the manual-recount remedy. The executor's growth crossed the
LARGE-tier hard cap (49152), so the plan_head_after documentation is
compressed to the minimal capture + frontmatter write (verify-work.md
carries the semantics), the #2751 PROSE_ALLOWLIST entry is re-pointed at
the shifted line (#4670 moved it from 823 to 825), the compact-content
baseline is regenerated, and the changeset records the two un-established
edges (shared-base waves, subrepo ledgers).

Emitted-Drift-Ack-Growth: gsd-executor.md — plan_head_after anchor documented in the measurement protocol (#4670)

* docs(#4670): backfill changeset PR number

* docs(#4670): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-09-16 22:06:03 -04:00
Tom Boucher
85545a77a5 fix(#4663): gate the canonicalization on the uat-passed predicate (#4809)
* test(#4663): add failing-first contract coverage for the blocked-uat canonicalization gate

verify-work.md's complete_session step flips VERIFICATION.md to passed on
'zero issues' alone, so a session whose every UAT row is blocked (a session
that observed nothing) canonicalizes the report. Pins the deployed contract
the fix must satisfy: the flip runs the unflagged phase uat-passed predicate
inside the human_needed branch, frontmatter.set sits inside a passed==true
guard, a refusal message carries the blocker count and keeps
human_needed, and an indeterminate pre-check fails closed. All four new
assertions are RED until the workflow grows the guard.

* fix(#4663): gate the canonicalization on the uat-passed predicate

complete_session flipped VERIFICATION.md to passed whenever the session
recorded zero issues and the status was human_needed — but blocked rows are
not issues by this workflow's own rule, so a 0-passed / 0-issues / N-blocked
session (one that observed nothing) rewrote the canonical report to passed.
Every later reader (transition.md's preliminary check, resume paths,
validate-phase, verification.status) then inherited the unearned pass while
the phase-close predicate correctly refused it.

The flip now runs the phase-close predicate in a new --uat-only form before
canonicalizing: UAT rows evaluated (at least one pass, no
pending/blocked/failed/unexplained-skip row), VERIFICATION-status blockers
skipped — they must be, because the report still reads human_needed at
pre-check time and that status is itself a blocking verification entry, so
the full predicate could never pass there and the flip would deadlock
(found by isolated review, probed). The --require-verification call stays
the transition gate; the refusal branch reports the blocker count and keeps
human_needed; an indeterminate pre-check fails closed.

Emitted-Drift-Ack-Growth: verify-work.md — canonicalize block gains the uat-only pre-check and refusal branch (#4663)

* test(#4663): align the canonicalize pre-check needles with the shipped line

The workflow line carries a 2>/dev/null redirect the needles did not
include, so both pre-check assertions fail against the committed fix
(fixed-string grep verified). Reviewer-found; needle and message aligned.

* fix(#4663): reword the canonicalize prose and refresh its size baseline

The rationale paragraph mentioned the flagged transition-gate call by its
flag, putting a --require-verification literal before the first
phase uat-passed occurrence and breaking the existing ordering pin; the
prose now describes it without the literal. verify-work.md's growth also
drifted the committed compact-content baseline; regenerated via
benchmark-compact-content.cjs --write (derived artifact, report-not-gate
contract).

Emitted-Drift-Ack-Growth: verify-work.md — canonicalize block gains the uat-only pre-check and refusal branch (#4663)

* docs(#4663): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-09-16 17:26:24 -04:00
Tom Boucher
740ba0d8a3 fix(#4628): expose DAG-ready plans and restrict dispatch to them (#4781)
Emitted-Drift-Ack-Growth: execute-phase.md — #4628 consumer wiring: ready_plans parse pointer, not-ready named skip, and waiting condition 2b reference to the ready-wave-gate step file

Co-authored-by: sim <sim@local>
2026-09-16 02:38:43 -04:00
0xdhx
092d9256b8 fix(#4748): carry a letter-suffixed phase id through the seven shell sites that aborted or truncated it (#4768)
* test(#4748): pin the letter-axis defect at the seven shell sites outside #4660's six

Extends tests/nsegment-phase-grammar.test.cjs one class over: for each of the
seven sites the live shell lines are read off disk by anchor and executed in
bash against a letter-suffixed fixture. The four `$((10#$PHASE_INT))` split
sites must yield PHASE_N without a shell error for `03A` / `12A` / `3A` /
`03A.1.2` and the commit-scope ERE they build must match both `feat(3A-01):`
and `feat(03A-1):`; the review-file lookup must bind init's `padded_phase`
rather than re-pad in shell; the `--from`/`--to`/`--only` and
plan-review-convergence extractions must return `12A` / `23A.1.2` (and
`23.1.2`) whole; the legacy normalizer must pad `3A` to `03A` and must not
mangle an already-padded `08`. Every pre-existing shape (`06`, `08.5`,
`23.1.2`, `36.14`) is a regression control.

tests/init.test.cjs asserts `init execute-phase` emits `padded_phase` for a
directory-backed `03A`, a ROADMAP-only `4B` (→ `04B`), the existing ROADMAP
fallback `1` (→ `01`), and `null` when the phase is not found.

Negative control against the unfixed tree: 41 failures in the grammar file,
exactly the "(fails before the fix)" cases and the three derived from them
(scope ERE, three-flag extraction, the `08` octal trap); 2 in init.test.cjs,
both the new assertions. Every regression control already green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* fix(#4748): carry a letter-suffixed phase id through the seven shell sites that aborted or truncated it

The canonical phase-number grammar (src/phase-id.cts) is digits, an optional
uppercase letter, then dotted segments — `12A`, `3A`, `23A.1.2` are documented
shapes that `init`, `phase-id.cts` and `phase remove` renumbering already
round-trip. Seven shell sites in shipped workflows and references still
assumed digits-and-dots. Four classes, one fix each:

Class 1 — `PHASE_INT=${PHASE_NUMBER%%.*}; $((10#$PHASE_INT))` (execute-phase.md
×2, completion-reconciliation.md, tdd.md). The post-#4619 split stops at the
first DOT, so on `03A` the "integer" is `03A` and bash aborts with `value too
great for base`. Split at the first NON-DIGIT instead (`%%[!0-9]*`): the
integer half is a pure digit run, and the letter rides along in the rest the
way the dotted fraction already did — `03A.1.2` → PHASE_N `3A\.1\.2`, so the
#4003 zero-pad-tolerant scope ERE matches both `feat(3A-01):` and
`feat(03A-1):`. Byte-identical output for every id that worked before.

Class 2 — `PADDED=$(printf "%02d" "${PHASE_NUMBER}")` before the REVIEW.md
lookup (execute-phase.md). `printf` cannot pad a letter id (prints `03`,
exits 1) — and cannot even re-pad an already-padded `08`, which bash reads as
an invalid octal and prints as `00`, so the lookup resolved phases 08 and 09
to `00-REVIEW.md` today. The disk path hands the workflow the directory's
padded number but the ROADMAP fallback hands it the heading's bare one, which
is why the re-pad existed. `cmdInitExecutePhase` now emits `padded_phase`
through `normalizePhaseName`, exactly as the plan-phase and code-review inits
do, and the workflow binds `{padded_phase}` instead of re-deriving.

Class 3 — `grep -oE '[0-9]+\.?[0-9]*'` (autonomous.md `--from`/`--to`/`--only`,
plan-review-convergence.md). Stops at the letter, so `--from 12A` ran from
phase 12 with no error. Now the canonical ERE `[0-9]+[A-Z]?(\.[0-9]+)*`, which
also closes the single-segment dot-axis gap the same shape carried (`23.1.2`
→ `23.1`, #4568's class in a spelling neither lint saw).

Class 4 — the legacy manual normalizer (phase-argument-parsing.md, reached
from mvp-phase.md). Its two branches (`^[0-9]+$`, `^[0-9]+\.[0-9]+$`) left
`12A` unpadded and never padded `3A` to the `03A` a directory carries; its
integer branch also hit the same `printf` octal trap on `08`. One branch for
the whole canonical token now, padding the digit run via `$((10#…))`.
Whether this legacy surface should instead be retired in favour of `init`'s
normalization is the maintainer call the issue names; extending it keeps the
documented contract true either way.

Driven end to end: `init execute-phase 3A` on a fixture with a
`03A-letter-variant/` directory emits `phase_number: "03A"` and now
`padded_phase: "03A"`; on a ROADMAP-only `### Phase 4B:` it emits `"4B"` /
`"04B"`. The issue's own evidence line claimed `padded_phase` was already in
the execute-phase init output — it was not; that key is emitted by the
code-review / plan-phase inits, which is where the claim was read from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* chore(#4634): extend lint-phase-id-drift with three ratchets for letter-hostile phase-id consumers

The rules that landed with #4619, #4568 and #4660 police grammar MIRRORS —
regexes that describe a phase id. The #4748 sites are CONSUMERS of one, and
every existing rule reported clean on them: the shell-arithmetic rule's
`_INT` escape trusts a NAME the dot-only split did not earn on `03A`; the
`[0-9]+\.?[0-9]*` shape is neither the bounded form the single-segment rule
bans nor the unbounded form the letterless rule inspects; and nothing looked
at `printf "%02d"` at all. Three narrow additions, one per shape:

- findDotOnlyIntegerSplitDrift — `X_INT=${<phase-var>%%.*}`; the safe split
  is `%%[!0-9]*`. Keys on the SOURCE variable being phase-carrying.
- findLooseDottedPhaseRegexDrift — `[0-9]+\.?[0-9]*` / `\d+\.?\d*` on a
  phase-carrying line; the canonical form is `[0-9]+[A-Z]?(\.[0-9]+)*`.
  Disjoint from the two sibling regex rules by construction.
- findShellPhasePrintfPadDrift — `printf "%0Nd" …` whose arguments name a
  phase-carrying, non-`_INT` variable; a pad of an `_INT` via `$((10#…))`
  and a `{padded_phase}` binding are the sanctioned shapes.

Same `<!-- phase-id-owner: … -->` sanction, same scan roots as their nearest
sibling (shell idioms over workflows + references, the regex shape over
workflows + references + agents), same documented limit of a per-line
textual scan. The post-#4619 comment that described the `_INT` convention
as proven by `%%.*` is corrected to name the digit-run split. Confirmed
against the base commit: each rule fires on exactly its own unfixed sites
(2+1+1, 3+1, 1+1) and zero violations remain on the fixed tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* docs(#4748): add Fixed changeset

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* chore(#4748): refresh the compact-content benchmark baseline and acknowledge emitted growth

The three top-level workflow files below grew by the letter-aware split, the
canonical extraction ERE, the `{padded_phase}` binding, and the comment lines
that name the grammar each site now honours. The committed compact-content
benchmark moved with them; refreshed with `benchmark-compact-content.cjs
--write` (aggregate reduction 15.47% -> 15.45%).

Emitted-Drift-Ack-Growth: execute-phase.md — #4748: first-non-digit PHASE_INT split at the plan-selection and TDD-gate sites, `{padded_phase}` binding at the REVIEW.md lookup, and the comments naming why (482 bytes)
Emitted-Drift-Ack-Growth: autonomous.md — #4748: canonical `[0-9]+[A-Z]?(\.[0-9]+)*` at the --from/--to/--only extractions plus one comment naming the grammar (249 bytes)
Emitted-Drift-Ack-Growth: plan-review-convergence.md — #4748: canonical `[0-9]+[A-Z]?(\.[0-9]+)*` at the phase extraction plus one comment naming the grammar (160 bytes)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* fix(#4748): name padded_phase in execute-phase.md's init parse list

A `{field}` token inside a workflow bash block is substituted from the init
JSON only for fields the workflow tells the model to parse. `phase_number`
is on that list; `padded_phase` was not, so the `PADDED="{padded_phase}"`
binding at the review lookup would have been a literal — for every phase,
not only letter ones. Found by the pre-file adversarial review (claim 2, the
author's own named suspicion); the test now asserts the parse list carries
the field beside `phase_number`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* chore(#4634): key the dot-only split rule on its source and widen the printf rule to any %d form

Two false negatives from the pre-file adversarial review of the three #4748
ratchets: `PHASE_PREFIX=${PHASE_NUMBER%%.*}` escaped the split rule because
the destination did not end in `_INT` (the defect is the split, not the
name it lands in), and `printf '%02d'` / `printf "%2d"` escaped the printf
rule because it required double quotes and the zero flag (`%d` cannot parse
a letter id under any width). Both rules now key on the phase-carrying
SOURCE alone; base-site firing counts are unchanged (2+1+1, 1+1) and the
fixed tree stays at zero. The `[[:digit:]]` spelling and the `/phase/i`
heuristic remain the sibling rules' documented limits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* chore(#4748): refresh the compact-content benchmark baseline after the parse-list edit

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* test(#4748): compose init's emitted padded_phase through the live REVIEW.md lookup

The Class 2 site is a `{padded_phase}` template token, which no test can
execute as written. This substitutes the value init emits
(`normalizePhaseName`) into the three live lookup lines and runs them
against a fixture, so the emitted value, the binding, the path construction
and the status extraction are exercised together — `03A-REVIEW.md` and
`08-REVIEW.md` each resolve to their own status. Suggested by the resumed
adversarial review pass (claim C).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* test(#4748): move the #4619 and #4003 source-parity pins to the letter-safe split

tests/execute-phase-decimal-arithmetic.test.cjs and
tests/safe-resume-gate-anchoring.test.cjs pin the four Class 1 sites'
snippet byte-for-byte, so the first-non-digit split reddened both in the
whole-suite run (scripts/ci-test-scope.cjs does not select either file for
a workflow edit — the scoped run was green). The pinned snippet is now the
shipped one, and the behavioural half of the #4619 file gains the letter
case (`03A` → `3A`, `23A.1.2` → `23A\.1\.2`) beside its decimal cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* chore(#4634): key the dot-only split rule on the _INT destination again, tolerating the quoted spelling

Keying on the source alone (the previous commit's widening, from a review
probe) flags `PARENT_PHASE="${PHASE_NUMBER%%.*}"` in
gap-closure-artifacts.md — a correct derivation that wants everything
before the first dot, letter included. The defect this rule polices is a
dot split INTO the name the shell-arithmetic rule trusts as an integer, so
`_INT` is the discriminator on purpose; the quoted spelling that site uses
is now tolerated so the same shape into an `_INT` cannot hide behind it.
Base-site firing unchanged (2+1+1), zero on the fixed tree, and the
parent-phase line is pinned as a silent case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* test(#4748): use t.after() for the composition test's fixture cleanup

CONTRIBUTING forbids try/finally inside a test body; the per-test cleanup
form is `t.after(() => cleanup(dir))`. Flagged by the filing driver's
test-ruleset gate before the PR was created.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

* chore(#4748): set changeset fragment pr to 4768

* chore(#4748): refresh the compact-content benchmark baseline after rebasing onto next

Regenerated with `node scripts/benchmark-compact-content.cjs --write` on the
rebased tree (base 0d6bf19bf); `--check` confirms it matches the live recompute.
Only the execute-phase split and the aggregate totals differ from next's copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cjzdZtYjcBAa3Lqh2VrLK

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-15 23:31:36 -04:00
Tom Boucher
cbbde6786a fix(#4546): deferred UAT follow-ups no longer block completion and promote to the backlog (#4769)
* test(#4546): failing-first tests for deferred uat follow-ups

* chore(#4546): regenerate derived lists for the deferred-promotion suite

The new verify-work-deferred-promotion suite changes the tests/ tree the
macOS conformance-tier classifier tracks and is a novel file under the
verify prefix in the test-file-count ratchet; both derived lists are
regenerated/registered per their own guards' instructions.

* fix(#4546): deferred uat follow-ups no longer block, and get promoted

Two halves of one disconnect (#1921's deferral design vs the completion
predicate):

- uat-predicate: the item parser now captures the block's reason: line
  alongside result:. A skipped item whose reason carries the
  verify-work writer's 'Deferred follow-up:' template is a deliberate
  deferral -- non-blocking, flagged deferred in the report. Quote-
  tolerant (the writer wraps the value) and case-insensitive. A
  reasonless skip, a non-deferral reason, pending/blocked/issue/
  failed/missing all still block, exactly as before.
- verify-work complete_session: when the Deferred Follow-Ups section is
  non-empty, offer to promote the items to a ROADMAP.md 999.x backlog
  entry reusing next.md's prior_phase_completeness entry shape, with a
  --files-scoped commit. Offer, not auto-mutation -- matches the
  workflow's interactive convention and next.md's own prompt style.

* chore(#4546): refresh compact-content benchmark baseline

verify-work.md grew (the #4546 deferred-follow-up promotion offer in
complete_session); the registered split's token counts moved with it.
Baseline recomputed with the script's own --write.

Emitted-Drift-Ack-Growth: verify-work.md — complete_session gained the deferred-follow-up promotion offer (detection, [P]/[K] choice, the next.md-shaped 999.x entry template, and the --files-scoped ROADMAP.md commit); the growth is the new contract text, not duplication

* fix(#4546): gate/audit agreement and review fixes for deferred follow-ups

- src/uat.cts categorizeItem: a skipped item carrying the deferred
  follow-up template reason now categorizes as 'deferred' (the category
  already existed for deferred-items.md entries) instead of being
  misfiled into the blocked families by keyword match -- the gate/audit
  agreement #3078-CR expects, restored in the permissive direction the
  #1921 design intends. Checked BEFORE the keyword families so '...
  on the release build next version' is not build_needed.
- verify-work.md promotion step: numbering scans for the smallest free
  999.n (count races + non-contiguous history), one backlog entry per
  deferred follow-up, ROADMAP.md-absent behavior specified, idea text
  newline-flattened, Deferred at placeholder harmonized with next.md.
- DEFERRED_REASON_RE: trust assumption documented (authoring contract,
  not a security boundary; non-matching spellings block fail-closed).
- tests: the property now drives evaluateUatPassed and derives
  expectations from the input spec (never restates the matcher),
  includes the no-result-line branch, and pins its seed; the parity
  test drops try/finally for the approved pattern, uses createTempDir,
  sites its allow-test-rule marker at the suppression site, and asserts
  the literal [P]/[K] choices.

* fix(#4546): close promotion-test docstring, drop fc replay-path misuse, refresh baseline

The final matrix run caught three defects in my own review-fix commit:
the parity test file's JSDoc was left unterminated (the whole file
parsed as one comment -- zero tests registered, hence the file-level
'test failed' the runner reported); fast-check's replay-path parameter
was misused as a label (invalid path at replay); and the workflow-text
ambiguity fixes re-drifted the compact-content benchmark baseline.

* docs(#4546): add Fixed changeset for deferred follow-up coverage

* docs(#4546): backfill changeset PR number

* fix(#4546): use the pattern seam escapeRegex for shape-marker matching

The hand-rolled metacharacter escape in the shape-marker assertion
tripped local/no-adhoc-regex-escape, whose named remedy this adopts.

---------

Co-authored-by: sim <sim@local>
2026-09-15 07:30:38 -04:00
BeeHiggs
0967358b8b enhance(#3638): render bracket phase IDs on progress, stats, manager and statusline surfaces (epic #612 PR-5) (#4111)
* enhance(#3638): render bracket IDs on display surfaces

Gate progress, stats, manager, and statusline projections on the bracket convention; validate phase_id_convention and single-source the convention card.

Forward note: the uat.cts bracket co-change remains deliberately deferred to its owning slice.

* chore(#3638): point the changeset at PR #4111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3638): close bracket display review gaps

* docs(#3638): register phase display modules

* chore(#3638): re-trigger CI after macOS shard SIGTERM

`full test (macos-latest, 24, shard 3/3)` failed on 20ce98cd1 in
`tests/lint-compiled-artifact-sync.test.cjs` — the spawned
`scripts/lint-compiled-artifact-sync.cjs` was killed at 60024ms
(`exited null (signal SIGTERM)`, stdout and stderr both empty), 24ms past
the test's own `TSC_COMPILE_TIMEOUT_MS`. That is the failure mode the
constant's comment already documents ("under CI shard load that compile
can exceed the budget, dying to a SIGTERM with empty piped stdout").

No content change; this empty commit exists only to re-run the matrix,
since re-running a job needs write access on the upstream repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-15 04:47:47 -04:00
0xdhx
48271de43f fix(#4660): widen the 6 shell/markdown phase-id mirrors to the canonical grammar's letter axis (#4744)
* test(#4660): pin the letter-axis parity defect across all 6 shell/markdown phase-id sites

Extends tests/nsegment-phase-grammar.test.cjs (#4568) one axis over: for each
of the six sites, reads the live regex off disk and asserts it agrees with
src/phase-id.cts's PHASE_NUMBER_TOKEN_SOURCE on the letter axis in BOTH
directions — accepts `12A` / `3A` / `03A` / `23A.1.2`, still rejects `3a`,
`3AB`, `A3` and the other canonical-invalid shapes — and that the two
extracting sites return the full letter-suffixed token rather than its digit
prefix (or nothing).

Negative control against the unfixed tree: 22 failures, exactly the
"(fails before the fix)" cases; every reject-parity case already green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

* fix(#4660): widen the 6 shell/markdown phase-id mirrors to the canonical grammar's letter axis

Adds `[A-Z]?` after the leading digit run at all six sites #4568 widened —
the ERE translation of src/phase-id.cts's `\d+[A-Z]?(?:\.\d+)*` — so a
documented, canonical-valid id like `12A` or `23A.1.2` is no longer refused
by the four validating sites (code-review.md, code-review-fix.md,
gsd-code-fixer.md, gsd-code-fixer.compact.md) or truncated to its digit
prefix by the two extracting sites (execute-plan.md's plan-filename grep,
plan-phase.md's --research-phase capture). Behaviour is byte-identical for
every id that matched before; the adjacent comment and error-message text
now names the grammar it mirrors.

Driven: `init code-review 3A` on a fixture with a `03A-slug/` directory and
a `### Phase 3A:` heading emits `padded_phase: "03A"`, which the old regex
rejects and the widened one accepts — nothing upstream of the validator
mangles the id.

At execute-plan.md the trailing `-[0-9]+` is the PLAN number and stays
digit-only; plan and milestone dimensions are out of scope per the brief.
`CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE` derives from the canonical source
by a literal `.replaceAll('A-Z', 'A-Za-z')`, so src/phase-id.cts is
deliberately untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

* chore(#4634): extend lint-phase-id-drift to ban a letter-less phase-id mirror in workflows/ and agents/

Adds findLetterlessPhaseMirrorDrift — the letter-axis twin of the #4568
single-segment rule — flagging the unbounded-segment shape
`[0-9]+(\.[0-9]+)*` (and its \d / doubled-backslash near-variants) whose
digit run is NOT followed by the `[A-Z]?` class, on any phase-carrying line
across gsd-core/workflows/**/*.md, gsd-core/references/**/*.md and
agents/**/*.md. Sanctioned the same way (`<!-- phase-id-owner: ... -->`),
tolerates the case-flexible `[A-Za-z]?` directory-scanning variant so it
cannot force that separate axis to narrow, and is wired into scanAll.
Confirmed zero violations against the real tree post-#4660 fix, and one
violation when a single site is reverted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

* docs(#4660): add Fixed changeset

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

* chore: regenerate conformance-tier manifests for the extended grammar test

tests/nsegment-phase-grammar.test.cjs now requires the compiled
gsd-core/bin/lib/phase-id.cjs (to assert the canonical grammar agrees with
each site's live regex), which moves it to a different platform-conformance
tier; `gen-platform-conformance-tier.cjs --check` in lint:ci flagged the
macOS manifest as stale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

* test(#4660): reword a comment that tripped lint-docs-guard-registration

The comment mentioned `docs/CONFIGURATION.md` between two backticked
tokens, which the lint's template-literal detector read as a docs/ path
expression. The test reads no docs/ file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

* chore(#4660): refresh the compact-content benchmark baseline and acknowledge emitted growth

plan-phase.md grew by 4 bytes (`[A-Z]?`), which moves the committed
compact-content benchmark; refreshed with `benchmark-compact-content.cjs
--write`. The six shipped files below grew by the widened regex literal plus
the comment and error-message text that now names the canonical grammar.

Emitted-Drift-Ack-Growth: code-review.md — #4660: `[A-Z]?` at the PADDED_PHASE validator plus a comment/error message naming the canonical grammar and the `12A` example
Emitted-Drift-Ack-Growth: code-review-fix.md — #4660: `[A-Z]?` at the PADDED_PHASE validator plus a comment/error message naming the canonical grammar and the `12A` example
Emitted-Drift-Ack-Growth: gsd-code-fixer.md — #4660: `[A-Z]?` at the padded_phase sink validator plus the defense-in-depth comment and error message updated to the canonical grammar
Emitted-Drift-Ack-Growth: gsd-code-fixer.compact.md — #4660: `[A-Z]?` at the padded_phase sink validator plus the comment and error message updated to the canonical grammar
Emitted-Drift-Ack-Growth: execute-plan.md — #4660: `[A-Z]?` in the plan-filename phase extraction (6 bytes)
Emitted-Drift-Ack-Growth: plan-phase.md — #4660: `[A-Z]?` in the --research-phase capture (6 bytes)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

* chore(#4660): set changeset fragment pr to 4744

* chore: re-trigger Validate Branch Name

The required check-branch context was cancelled on this head by the
workflow's cancel-in-progress group when the changeset pr-field backfill
push landed three seconds after the PR opened; no completed run exists for
the current head, and a fork contributor cannot re-run it. Empty commit to
re-run it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLtEbRc1Qfbe95HRMNqwp3

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-14 19:32:12 -04:00
Tom Boucher
eb49ff98df fix(#4728): stop presenting the retired Gemini CLI as a supported runtime (#4743)
* fix(#4728): stop presenting the retired Gemini CLI as a supported runtime

#1928 removed the Gemini CLI runtime after Google sunset it on 2026-06-18, and
updated the ENGLISH docs. The locale mirrors and the runtime-loaded workflow
prose were not updated in the same change, and no gate asserts the ABSENCE of a
retired runtime, so both drifted quietly for a year.

The finding that shaped this change: English is already correct. docs/
ARCHITECTURE.md, CONFIGURATION.md, USER-GUIDE.md, how-to/install-on-your-runtime.md
and CLI-TOOLS.md carry zero runtime-axis Gemini references; the only English hits
anywhere are a Gemini 2.5 Pro MODEL line, the GEMINI_API_KEY row, and prose that
correctly documents the retirement. So the docs half of this is translation lag,
not a content decision, and every locale edit here is parity with an existing
English line rather than new wording:

  - install-on-your-runtime.md  English has NO `### Gemini CLI` section  -> deleted
  - USER-GUIDE.md :843          "…, Antigravity CLI, Kilo)"              -> substituted
  - ARCHITECTURE.md             English has NO Gemini CLI table row      -> row deleted
  - ARCHITECTURE.md :24         English holds `Kimi CLI` in that slot    -> Kimi CLI
  - context-monitor.md :3       "`AfterTool` for Antigravity CLI"        -> substituted
  - spike-and-sketch.md :93     "(Codex, Antigravity CLI, etc.)"         -> substituted
  - configure-model-profiles    "Codex, OpenCode, Antigravity CLI, or Kilo" -> substituted
  - COMMANDS.md                 English keeps only hyphen + Codex bullets -> colon bullet deleted
  - FEATURES.md                 source docs/features/multi-runtime-support.md:10
                                lists no Gemini CLI                       -> name removed

ARCHITECTURE.md:24 is the clearest case for reading English rather than
substituting blind: Antigravity ALREADY appears later in that list, so replacing
Gemini CLI with Antigravity would have named it twice. English holds Kimi CLI
there, so that is what the locales get.

The largest single class was hand-duplicated boilerplate. A "Text mode" paragraph
repeated across 34 runtime-loaded workflow files ends "…required for non-Claude
runtimes (OpenAI Codex, Gemini CLI, etc.)". No lint enforces that sentence and no
script syncs it, so every copy was edited. These files are read by the agent at
runtime, so they steer behavior rather than only informing a reader — which is why
this class matters more than its word count suggests.

The slash-command-form section is restructured in all four languages to match
English, which had already dropped its colon-form bullet. That bullet claimed the
colon form is "Gemini CLI only", which was false on its own terms independent of
the retirement: `/gsd:…` is GSD's canonical AUTHORING token, rewritten per runtime
at install time, and NO runtime registers it — VALID_COMMAND_STYLES is
{slash-hyphen, shell-var} and 18 of 19 runtimes declare slash-hyphen. Substituting
the runtime name would have left the claim false with Antigravity's name in it, so
the claim is gone, matching English.

Two anchor regressions were caught and fixed while doing that. zh-CN lost its
explicit {#slash-command-forms-hyphen-vs-colon} anchor while its TOC still linked
it; the anchor is restored. ko-KR and pt-BR never had an explicit anchor and rely
on the slug generated from the heading text, so shortening the heading broke their
own TOC links; those links now point at the new slugs. English's heading lost its
anchor while its TOC still links the old one — that latent English bug is
deliberately NOT copied.

Preserved, because `gemini` is not one thing here and a blanket sweep breaks the
product: ~/.gemini/antigravity{,-ide,-cli} and ~/.gemini as their parent;
~/.gemini/config (#3738); GEMINI.md; hookEvents "gemini"; GEMINI_API_KEY in all
four locales; every gemini-* model id and the Gemini 2.5 Pro references in
ko-KR/pt-BR/zh-CN (ja-JP genuinely lacks that line — the locales have diverged, so
a uniform patch would be wrong); the hook-event dialect notes, which are
RE-ATTRIBUTED rather than deleted because Antigravity inherits that dialect;
reapply-patches.md:93's legacy-install note; host-integration-capability-matrix.md
:27 and :342, which correctly record the sunset and Antigravity's contract;
whats-new-1.7.0.md and FEATURES.md:3506, which document the retirement itself; and
the generated launcher preamble, which belongs to epic #4632 — zero
_GSD_SHIM_NAME lines appear in this diff.

Coverage: a #4728 block in tests/gemini-runtime-removed.test.cjs asserts the
retired name is gone from STRUCTURAL POSITIONS (a level-3 heading, a table row's
first cell, a runtime-example parenthetical) rather than asserting the string is
absent, which would be wrong. It pairs those with positive PRESERVE assertions
over the same files — Antigravity's heading, ~/.gemini/antigravity, GEMINI_API_KEY,
AfterTool — so a patch that deletes too much fails as loudly as one that deletes
too little. The model-axis test pins both the presence in three locales and the
absence in ja-JP, so a later uniform patch that "helpfully" adds it back fails.
The new docs/ reads tripped lint-docs-guard-registration for the first time in
this file, so the test is registered in scripts/docs-guard-registry.cjs.

Not covered here, by design: nothing above would catch a Gemini-as-runtime
reference appearing in a NEW file tomorrow. That is the repo-wide drift guard,
#4729, which must land last — written now it would red on the very references this
change removes.

Fixes #4728

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4728): fix four review blockers, including a vacuous test and my own duplicate

A full matrix run on 31f12d7943 FAILED with 3 real failures, and an isolated
adversarial review returned BLOCK on four blockers. All of it was correct.

1. I committed the exact error I claimed to have avoided. The commit message
   boasted that ARCHITECTURE.md:24 proved the value of reading English rather
   than substituting blind, because Antigravity already appeared later in that
   list. Five hundred lines further down the SAME four files, my
   `Gemini:` -> `Antigravity:` substitution produced TWO consecutive
   `- Antigravity:` bullets, because an Antigravity bullet was already there.
   English (ARCHITECTURE.md:827) merges them into one. Now merged in all four
   locales, reusing each locale's existing words.

2. `--gemini` survived in the runtime-detection CLI flag list in all four
   locale ARCHITECTURE.md files. English:817 holds `--kimi` in that slot and
   already lists `--antigravity` later, so this is another place where
   substituting Antigravity would have duplicated it. Now `--kimi`.

3. Two runtime-loaded workflow files still enumerated Gemini one line ABOVE the
   line I had already corrected -- the "Adaptive (Recommended)" option in
   settings.md:192 and new-project/steps/auto-mode-config.md:95.

4. THE NEW TEST WAS VACUOUS for two of its five files. It matched only
   `non-Claude runtimes (` and `(e.g. `, and neither regex could reach the two
   lines the change actually fixed: health.md:52 reads `non-Claude (Codex, ...)`
   without the word "runtimes", and execute-phase.md:1028 has no parenthetical
   at all. The reviewer proved it by re-introducing Gemini at both lines and
   watching the assertion stay GREEN. That same blind spot is what hid finding 3.

   Replaced with a case-sensitive `/\bGemini\b/` walk over every
   `gsd-core/workflows/**/*.md`, which works because every LEGITIMATE gemini
   reference in that tree is spelled differently and cannot match: Antigravity's
   paths are lowercase with a slash (`~/.gemini/antigravity`), Google's model ids
   are lowercase and hyphenated (`gemini-3.1-pro-preview`), and the env vars are
   uppercase (`GEMINI_CONFIG_DIR`, `GEMINI_SESSION_ID`). A bare capitalised
   `Gemini` there means the retired RUNTIME is being named. The walk asserts it
   found at least 50 files so an empty walk cannot pass vacuously, and it now
   covers the nested `new-project/steps/` directory where finding 3 lived.

   Two allowlist entries, both by line CONTENT and both justified:
   reapply-patches.md's `Legacy: ... pre-#1928` note, and settings-advanced.md's
   `Known provider` menu. The second was escalated by the agent rather than
   decided: Section 8 of that file says model policy is defined "independently"
   of the runtime, so `(Claude / OpenAI / Gemini / Qwen)` is the PROVIDER axis --
   the same axis as the lowercase model ids -- and must keep working.

   Proven to fail, not just asserted: the predicate reports 0 offenders on the
   real tree and exactly 2 on a /tmp copy with Gemini re-injected at
   health.md:52 and execute-phase.md:1028.

Also from the review: a `| Gemini |` COLUMN survived in the locale FEATURES.md
comparison tables (English has none) -- removed from all three, with header,
separator and every body row kept aligned; two ENGLISH runtime-axis sites were
missed by my own parity standard (how-to/execute-a-phase.md:88 and
how-to/verify-and-ship.md:89, the latter doubly stale since #4716 retired the
Gemini reviewer lane); docs/USER-GUIDE.md:12 linked a dead anchor, which I had
found and deliberately left -- record-and-proceed on a known defect is exactly
what the rules forbid, so it is fixed; docs/COMMANDS.md:12 and all four mirrors
still claimed "the hyphen and colon forms are runtime-specific spellings" with
no colon form documented anywhere, so that false sentence is deleted; and ko-KR
had the installer rather than the user doing the targeting.

The other two matrix failures were the compact-content benchmark baseline, which
drifted because this PR changes byte counts, refreshed via the script's own
`--write` path rather than by hand; and this commit's emitted-drift-ack trailers.

Method note on the acks: the failing run measured growth against
origin/next@1110c3b4ee, which is the STALE LOCAL `next` ref -- gsd-test merges
into the local base branch, and this machine's `next` is seven commits behind
origin/next, which is checked out in the main worktree and so cannot be
fast-forwarded from here. The 32 trailers below are computed against the REAL
base (origin/next @ ca8d9d4459) by comparing each tracked file's blob size, which
is one more file than that run reported -- the extra is settings.md, grown again
by fix 3. docs-update.md and map-codebase.md are deliberately NOT acked: they
SHRANK, since there the fix deleted ", Gemini CLI" rather than substituting, and
acking a file no delta consumed is itself an error.

Refs #4728

Emitted-Drift-Ack-Growth: add-tests.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: add-todo.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: ai-integration-phase.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: check-todos.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: cleanup.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: complete-milestone.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: do.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: eval-review.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: execute-phase.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: execute-plan.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: health.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: import.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: inbox.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: manager.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: new-milestone.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: new-workspace.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: note.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: onboard.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: plant-seed.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: profile-user.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: quick.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: remove-workspace.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: secure-phase.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: settings.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: ship.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: smart-entry.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: ui-phase.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: ui-review.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: undo.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: update.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: validate-phase.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Emitted-Drift-Ack-Growth: verify-work.md — retiring the Gemini CLI runtime name; Antigravity is one byte longer
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4728): add the changeset fragment

The PR body claimed one was present and it was not — caught by
scripts/changeset/lint.cjs reporting fail_missing_fragment, not by the
checklist, which is exactly why the lint exists.

Type Fixed: the diff is prose, and a docs-only fix uses Fixed since there is
no Documentation type.

Refs #4728

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:49:52 -04:00
Tom Boucher
6f99e493e7 fix(#4395): make the debug session manager's own gsd-debugger spawn blocking (#4718)
* test(#4395): prove the manager spawns its debugger without blocking

Failing-first regression coverage for #4395.

debug.md:209 mandates the orchestrator to session-manager spawn carry
run_in_background: false, and says why outright: "Claude Code backgrounds
subagents by default, and only that flag makes the spawn return the
compact session summary directly" (#2196).

The session-manager to debugger spawn, one level down, carries no flag.
Measured: run_in_background appears nowhere under agents/ -- only in
gsd-core/workflows/. So by the rule #2196 itself states, that spawn is
backgrounded, Step 3 ("Handle Agent Return") has no return to inspect,
the manager emits CONTINUE_REQUIRED, the orchestrator auto-resumes per
#2257/#3448, and a second detached debugger races the first on
.planning/debug/<slug>.md.

Row 4 is the load-bearing one: it closes the CLASS by requiring every
subagent spawn under agents/ to declare run_in_background explicitly, so
the next agent that spawns one has to decide rather than inherit a silent
host default. It is scoped to agents/ precisely so it cannot misfire on
the workflows that deliberately use true for parallel fan-out.

Rows 5-7 are pins, not fixes: the #2196 mandate one level up, Step 2 as
the single spawn-format source that the eight continuation sites delegate
to, and the survival of CONTINUE_REQUIRED (which has a legitimate trigger
unrelated to this defect).

Red round: 4 of 7 rows fail. Row 3 needed hardening first -- asserting
only that the two variants AGREE passed vacuously, because two missing
flags are also equal; it now asserts each is present before comparing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4395): make the manager's own debugger spawn blocking

The orchestrator-to-manager hop already requires a blocking spawn and says
why (#2196, debug.md:209): Claude Code backgrounds subagents by default,
and only run_in_background=false makes the spawn return its summary. The
manager-to-debugger hop, one level down, carried no flag -- measured,
run_in_background appeared nowhere under agents/ at all.

So that spawn was backgrounded. Step 3 ("Handle Agent Return") opens
"Inspect the return output for the structured return header" -- with
nothing to inspect, the manager correctly declined to fabricate a terminal
summary and returned CONTINUE_REQUIRED; the orchestrator correctly
auto-resumed (#2257/#3448); the resumed manager reached Step 2 and spawned
a SECOND detached debugger. Both then raced on .planning/debug/<slug>.md.

Every observable in the report follows with no further assumption,
including the count: the reporter saw exactly three collisions in one
invocation, and debug.md:251 caps auto-resumes at three per slug -- one
collision per cycle.

Fixed at the cause, in both shipped variants, kept byte-consistent. The
eight continuation sites say "see Step 2 format", so they inherit it.

The issue offered two remedies. The second -- have the auto-resume path
reconcile a still-running debugger before spawning another -- is not taken:
it treats the symptom, and needs machinery that does not exist (no portable
way to enumerate or stop another runtime's live agents, plus an in-flight
sentinel with staleness and recovery rules, or an orphaned marker deadlocks
the session permanently). With the spawn blocking, the manager cannot reach
Step 4 while a debugger is live, so such a guard would also be unreachable.

#2257, #3448, the anti-loop heuristic, the cap of three, and the
CONTINUE_REQUIRED shape are all correct and untouched. CONTINUE_REQUIRED
keeps its legitimate trigger: the manager genuinely exhausting its own turn
budget mid-investigation.

Also corrects the red-round test to the canonical CALL form. debug.md
writes run_in_background=false inside Agent(...) and run_in_background:
false in prose; the first draft asserted the prose form, which the shipped
call would never have matched.

Emitted-Drift-Ack-Growth: gsd-debug-session-manager.md — the blocking spawn flag plus the note recording why an unstated flag produced colliding debuggers
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.compact.md — same change as its full sibling, kept byte-consistent with it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4395): add changeset fragment

pr:0 placeholder is backfilled with the real number once the PR exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4395): refresh the variant benchmark baseline

Two entries move.

gsd-debug-session-manager.md 4766/4477 -> 4938/4649 is this change: the
blocking-spawn flag plus its explanatory note, added to BOTH variants to
keep them byte-consistent, so the compact sibling grows by the same amount
and the pair's reduction ratio dips 6.06 -> 5.85. The compact file remains
strictly smaller than its canonical sibling, which is what the variant
guard's size check actually requires.

gsd-code-fixer.md 10741 -> 10740 is NOT from this branch -- the file is
untouched here. It has scored 10740 since f334f277dd (#4324) reworded a
line without refreshing this fixture, so the stale number is sitting on
next. Fixed here rather than deferred; the #4350 branch carries the
identical one-token correction, so whichever lands first makes the other a
no-op.

Refreshed with the variant script's own --write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4395): state the blocking rule agent-wide and correct two overclaims

Review round. Three substantive corrections, one of them to a claim I made
in the previous commit message.

1. The blocking rule is now stated AGENT-WIDE, not per-call. My earlier
   claim that "the eight continuation sites say 'see Step 2 format', so
   they inherit it" was false: exactly ONE of them names Step 2 (the
   compact variant says "Step 2 format" without the "see", which is why
   the first draft of the test matched it zero times there). The other
   sites inherit only because Step 2 holds the sole Agent() spawn literal
   in each file. Both variants now say so outright, and the test pins the
   sole-literal invariant in BOTH variants rather than the prose wording
   in one.

2. debug.md's two auto-resume buckets now restate run_in_background=false
   for the re-spawn. "The same session_params" does not carry it --
   session_params is prompt content, not the spawn flag.

3. The test extractor now also matches single-line Agent(...) calls. The
   class guard was blind to exactly the shape a future offender is most
   likely to take.

Also corrects the diagnosis: remedy 2 is NOT unreachable once the spawn
blocks. This agent's own retained CONTINUE_REQUIRED trigger is "turn
budget exhausted WHILE THE DEBUGGER IS STILL INVESTIGATING", so a harness
turn cutoff mid-wait still double-spawns; debug.md:209 names the same
class from the other side. What this fix removes is the SYSTEMATIC case --
every invocation, because the spawn was always backgrounded. The residual
turn-cutoff window survives, bounded by the existing three-resume cap, and
is stated in the PR rather than denied.

Emitted-Drift-Ack-Growth: gsd-debug-session-manager.md — blocking spawn flag, the note recording why an unstated flag produced colliding debuggers, and the agent-wide restatement the per-site inheritance actually depends on
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.compact.md — same change as its full sibling, kept byte-consistent with it
Emitted-Drift-Ack-Growth: debug.md — both auto-resume buckets restate the spawn flag, since session_params does not carry it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4395): backfill the changeset PR number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:12:46 -04:00
Tom Boucher
ed819aa4d6 fix(#4379): make the TDD RED-commit pathspec language-agnostic (#4715)
* fix(#4379): make the TDD RED pathspec language-agnostic

The pathspec IS this gate's definition of "a test file", and it listed
only JS/TS conventions. Go's *_test.go matches none of them, so a commit
adding a failing Go test was invisible, RED_COMMIT came back empty, and
every behaviour-adding task halted with TDD GATE TRIPPED. references/tdd.md
already advertises `go test ./...` and `cargo test` as supported, so the
gate was refusing to see tests the docs promised to support.

Two corrections, both measured against a seeded repo rather than reasoned:

- cover the conventions tdd.md advertises: *_test.go, test_*.py,
  *_test.py, *_test.exs, *_spec.rb, *_test.rb.
- drop the `**/` prefix. It does NOT match a path with no directory
  component, so a root-level foo.test.js was invisible even in the
  language the gate did support -- a second defect the report did not
  mention. A bare glob matches at every depth.

Deliberately not widened to ordinary source: a pathspec matching
implementation files would make the gate pass on any in-scope commit,
which is worse than tripping wrongly. Rust is a known gap for that exact
reason and is now documented rather than silently broken.

Driving the shipped pathspec against a seeded repo: before, 0 of 7
language/root conventions matched; after, 7 of 7, with src/impl.go and
src/lib.rs correctly unmatched in both.

Emitted-Drift-Ack-Growth: execute-phase.md — the widened pathspec plus the comment recording why it must not cover ordinary source
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4379): drive the shipped RED pathspec against a real repo

The existing row pinned the pathspec as a literal string, which the fix
makes stale. Re-point it, and add behavioural coverage that EXTRACTS the
pathspec from the shipped workflow and runs git log with it against a
seeded repo -- re-typing the pattern into the test would only assert that
two copies of a string agree.

Rows: every advertised convention is visible; a root-level test file is
not invisible (the half the report missed); existing JS/TS still matches;
implementation files never match, so the gate can still trip; and Rust
inline #[test] stays out of reach, asserted rather than left silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4379): add changeset fragment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4379): be honest about the widened pathspec's cost

Adversarial review: the rationale comment claimed the change was safe
without naming what it gives up. `*.spec.*` can match a non-test file
carrying the word (api.spec.json, openapi.spec.yaml), which lets the gate
pass on a commit touching only that. Not new -- `**/*.spec.*` already
matched those at any nested path, so dropping `**/` extends the same
class to the root -- but the comment should say so rather than imply
the widening is free.

Also: the tdd.md list named Ruby and Elixir as recognised while the
detection step above it enumerates only Node/Python/Go/Rust. Say
explicitly that the gate's pathspec is wider than the detected project
types, and why that is deliberate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4379): drop a vacuous row, fold its point into a real one

Adversarial review: the "rust inline #[test] remains out of reach" row
asserted src/lib.rs never matches -- the identical assertion to the
"implementation files never match" row directly below it. It exercised
nothing about #[test] semantics and would have passed against almost any
fix, so it was coverage theatre.

Delete it and move its rationale into the row that already carries the
assertion, where it explains WHY the Rust gap follows from that row
holding: the two cannot both be satisfied by a path-based gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4379): move the pathspec rationale out of a size-capped file

execute-phase.md sits under a FROZEN byte ceiling (ADR-857 Phase 6,
#1168: < 93600). The 20-line rationale comment I added pushed it to
93933 and tripped seven tests, all the same ceiling. Base was 92371, so
the budget was 1229 bytes and the comment spent 1481.

Keep six lines at the call site -- what the pathspec is, why it is not
wider, where to read more -- and move the trade-off detail to
references/tdd.md, which has no ceiling. That is the right home anyway:
the workflow is loaded into context on every run, the reference is read
on demand.

92882 bytes, 718 under. Pathspec line byte-identical; re-proved
behaviour after the trim: 0/7 conventions before, 7/7 after, no
implementation files matched either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4379): refresh the compact-content benchmark baseline

execute-phase.md changed size, so the committed baseline drifted. The
script's own contract makes it a report that exits 0, but the test
asserts the committed baseline is up to date -- refresh via --write,
which is what the drift message instructs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4379): backfill the changeset PR number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:57:08 -04:00
Tom Boucher
1110c3b4ee fix(#4709): stop minting the retired gemini runtime id in shipped surfaces (#4711)
* test(#4709): assert no shipped surface mints a retired runtime id

Extends the #1928 removal guard to the surfaces it structurally could not
reach. Its own docblock scopes it to the installer CLI contract and the
runtime-name-policy exports; it spawns the installer and inspects module
exports, and never reads gsd-core/workflows/**, commands/** or skills/**.

Four structural assertions, all RED on next:
- every RUNTIME= assignment must name a canonical runtime
- the runtime->model-tier table must name only model-catalog runtimes
- runtime selection menus must offer only canonical runtimes
- config-set runtime / model_profile_overrides examples must be canonical

Structural, not textual: each asserts the literal is canonical or the runtime
exists as a catalog key, never that the string "gemini" is absent. That string
is load-bearing across Antigravity's real on-disk contract, so a fifth test
pins that contract from the descriptor (not from a resolved path, which would
read $ANTIGRAVITY_CONFIG_DIR and the real $HOME -- the #4312 defect class).
An over-broad gemini -> antigravity replacement fails there rather than ships.

The menu assertion is scoped by the nearest preceding `question:` matching
/runtime/i, because the same file carries a provider menu (anthropic, openai)
and a budget menu (high, medium, low) whose labels are single lowercase tokens
too and name neither a runtime nor anything the policy should judge.

Refs #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4709): stop minting the retired gemini runtime id in workflow text

#1928 removed the gemini runtime after Google sunset Gemini CLI on 2026-06-18,
but the removal stopped at the installer boundary. Runtime-loaded workflow text
kept assigning the id, and the name policy's unknown-id fallbacks then applied a
default designed for a never-known FUTURE runtime to an id GSD itself retired:
getRuntimeLabel('gemini') is 'Claude Code', getProjectInstructionFile('gemini')
is 'AGENTS.md', getGlobalConfigDir('gemini') is ~/.claude. A stale id produced a
plausible wrong answer instead of an error.

Those fallbacks are DELIBERATE and are left untouched here -- four docblocks
document them, src/runtime-name-policy.cts:220-222 calls the label default "the
always-safe default, fail-closed", and an existing test in this very suite pins
getProjectInstructionFile('gemini') === 'AGENTS.md'. This commit removes the
REACHABILITY of the retired id instead:

- new-project.md, ingest-docs.md: the runtime-detection cascade mapped
  /.gemini/ and $GEMINI_CONFIG_DIR to RUNTIME=gemini. Both now map
  /.gemini/antigravity{,-ide,-cli}/ and $ANTIGRAVITY_CONFIG_DIR to
  RUNTIME=antigravity, the documented successor. ingest-docs.md was not in the
  original report; the new structural test found it.
- settings-advanced.md: dropped the `gemini` row from the runtime->model-tier
  table. The model catalog has no gemini runtime (runtimeTierDefaults has 18
  keys, none of them gemini), so the row advertised built-in defaults for a
  runtime whose config key is ignored. Its three model IDs were copied from the
  `google` PROVIDER preset -- a provider axis rendered as a runtime axis.
- settings-advanced.md: removed the `gemini` / "Gemini CLI." runtime menu
  option and its group listing, so no menu offers a runtime GSD cannot install.
- settings-advanced.md: repointed the config examples from `runtime gemini` to
  `runtime antigravity`, which ships no built-in tier defaults and is therefore
  the case those overrides actually exist for.
- reapply-patches.md: $GEMINI_CONFIG_DIR -> $ANTIGRAVITY_CONFIG_DIR,
  ~/.gemini/gsd-local-patches -> ~/.gemini/antigravity/gsd-local-patches, and
  the local scan's bare .gemini -> .agents (Antigravity's localConfigDir). This
  file is hand-written, so `npm run sync:launcher` never reached it.
- update.md: bare ~/.gemini and ./.gemini as GSD config dirs -> the real
  ~/.gemini/antigravity and ./.agents.

Antigravity's own Gemini-family surfaces are untouched by design: ~/.gemini as
its configHome parent, ~/.gemini/config for global skills/agents (#3738),
hookEvents "gemini", GEMINI.md as its projectInstructionFile, the
~/.gemini/antigravity{,-ide,-cli} ambiguity probes (#1441), and every gemini-*
model ID. The launcher's own GEMINI_CONFIG_DIR arm is left to #4632, which
absorbed #4347 for it.

Refs #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#4709): record the Gemini -> Antigravity migration research

Primary-source research note behind #4709: the sense taxonomy that separates a
runtime-axis `gemini` (stale) from Antigravity's on-disk contract, Google's
model IDs, and release history (all load-bearing); the PRESERVE table; the
guard-gap analysis; and the per-file inventory with file:line citations.

Refs #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4709): keep the legacy patches probe, and stop tripping two lint gates

Three review findings, fixed inline.

1. Dropping the global ~/.gemini/gsd-local-patches probe was a regression: a
   pre-#1928 Gemini CLI install put patches there, and a stranded patches dir is
   still the user's work. Restored as an explicitly-labelled legacy arm probed
   AFTER Antigravity, so a live install always wins. This is a directory probe,
   not a runtime home -- it assigns no runtime id, so it does not reintroduce
   the defect this PR closes. The $GEMINI_CONFIG_DIR env probe is deliberately
   NOT restored: that names a runtime config home, which
   tests/declarative-reference-antigravity.test.cjs:307 pins as ignored.

2. The comment added in (1) originally contained the literal string that the new
   structural test matches, so the test flagged its own fix's comment as a mint.
   Reworded. The test was right; a comment in shipped workflow text is as
   readable to a matcher as code is.

3. docs/research/gemini-to-antigravity-migration.md used the colon slash-form
   inside a quoted manifest description. lint-docs-command-form rejects it:
   docs are never passed through the install-time converters, so the colon form
   names a command no runtime registers. Normalised to the hyphen form.

Also verified, rather than assumed: the local scan's .agents entry is
unambiguous. Antigravity is the ONLY runtime declaring localConfigDir '.agents'
across all 19 capability manifests; grok and codex use ~/.agents as a GLOBAL
home, and this scan is local (./$dir).

Refs #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4709): retire Gemini CLI from the PR templates, and close two review gaps

Adversarial review findings, all fixed inline.

1. All three .github/PULL_REQUEST_TEMPLATE/*.md still offered "Gemini CLI" under
   "Runtimes tested", and none offered Antigravity. #1928's follow-up dropped
   Gemini CLI from .github/ISSUE_TEMPLATE/*.yml but missed the PR templates, so
   every contributor opening a fix/feature/enhancement PR has been asked for two
   releases which runtime they tested and offered a retired one. Now Antigravity.
   Guarded by a new assertion: runtime checklist labels in the PR templates must
   appear in the runtime label table. Proven non-vacuous by reverting one
   template line and watching the probe report the offender.

2. The #4709 scanning corpus excluded agents/, which also ships runtime-loaded
   markdown including .compact.md variants. Widened: 318 -> 382 files (+64), zero
   new offenders, so the gap was coverage rather than a live defect.

3. gsd-core/workflows/sync-skills.md said "grok and gemini have no dedicated
   installer flag — they alias the codex and claude skills roots respectively."
   The gemini half is wrong twice over: the runtime is retired, and it never
   aliased claude -- canonicalizeRuntimeName returns null for it and the caller's
   fail-closed default merely happens to be claude. Describing that as designed
   aliasing is exactly the confusion this issue is about. Reduced to grok, which
   genuinely does alias the codex skills root.

4. The changeset said the runtime was removed in 1.11. It shipped in 1.8.0
   (CHANGELOG.md:1023 is the enclosing release heading for the #1928 entry at
   :1124). Corrected.

Refs #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4709): follow the corrected sync-skills prose, and refresh the compact baseline

Three GREEN-run failures, all caused by this PR's own edits.

1. tests/sync-skills-cross-runtime-refuse.test.cjs pinned the literal phrase
   "grok and gemini have no dedicated installer flag" — a test REQUIRING shipped
   text to name a runtime retired in 1.8.0, which is the exact class #4709
   exists to remove. The assertion and its rationale comment now track the
   corrected prose ("grok has no dedicated installer flag"), and the docblock's
   runtime list drops gemini. The remaining assertions in that file — the
   guard's exit, the installer pointer, the $DEST reference, guard-before-copy
   ordering — are untouched, so #3025's contract is otherwise intact.

2. tests/fixtures/compact-content-benchmark-baseline.json drifted because the
   new-project.md edits changed its compacted size (split "new-project": off
   14279 -> 14308, on 12335 -> 12364; aggregate off 107411 -> 107440).
   Refreshed with `node scripts/benchmark-compact-content.cjs --write`, which
   is that script's own documented remedy.

3. emitted-attribution reported four grown workflow files with no
   acknowledgment. Acked below as commit trailers per ADR-3942, which moved the
   acknowledgment out of tests/emitted-drift-acks/*.json fragments and into the
   PR's own commit range (read with three-dot base...head). Exactly the four
   files the gate named are acked — settings-advanced.md and sync-skills.md
   shrank and are deliberately absent, since a trailer no delta consumed is a
   staleAcks error.

Refs #4709

Emitted-Drift-Ack-Growth: ingest-docs.md — the runtime-detection cascade now names Antigravity's three real directories (/.gemini/antigravity{,-ide,-cli}/) and $ANTIGRAVITY_CONFIG_DIR in place of the single retired /.gemini/ arm and $GEMINI_CONFIG_DIR; three correct paths cost more bytes than the one wrong path they replace.
Emitted-Drift-Ack-Growth: new-project.md — same runtime-detection correction as ingest-docs.md, plus dropping "gemini/" from the two GEMINI.md instruction-file sentences so the prose stops contradicting getProjectInstructionFile, which returns AGENTS.md for that retired id.
Emitted-Drift-Ack-Growth: reapply-patches.md — restores the legacy ~/.gemini/gsd-local-patches probe as an explicitly-labelled arm after an adversarial-review finding that dropping it stranded a pre-#1928 user's patches, and repoints the env/global probes at Antigravity; the four-line comment is load-bearing, since a bare retired-runtime path with no explanation is exactly what the next reader would delete.
Emitted-Drift-Ack-Growth: update.md — bare ~/.gemini and ./.gemini as GSD config dirs are replaced by the real ~/.gemini/antigravity and ./.agents, which are longer strings; no content was added beyond the corrected paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4709): backfill the changeset PR number

pr: 0 -> 4711, now that the PR exists. Never guessed ahead of the number.

Refs #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:08:20 -04:00
Tom Boucher
c0b2a05d2f fix(#4594): one canonical dispatch-identity owner — the emitted format and the parser that reads it back (#4693)
* fix(#4594): give dispatch identity one owner for the emitted format and its parser

The isolation guards decided whether a run-scoped sentinel applied to a
dispatch by regex-scraping model-authored prose. The scrape returned values in
a different namespace from the ones the sentinel records, so the comparison
could never succeed:

  sentinel  { phase: "03", plan: "03-02-hardening" }   <- $PHASE_NUMBER / $plan_id
  prose     "Execute plan 02 of phase 03-auth."
  scraped   { phase: "03-auth.", plan: "02" }          <- greedy (\S+), both wrong

#4594 reports only the phase half. Measured against a real phase-plan-index
run, plans[].id is phase-prefixed, plan-numbered AND slugged, while the prose
carries a bare in-phase plan number — so the plan field mismatches too, and the
Claude path is dead rather than latent. A fresh sentinel was therefore
discarded on every executor dispatch and every legitimate ISOLATION=none
degrade was denied, leaving the work unrun.

hooks/lib/dispatch-identity.js is now the single owner of both halves. The two
prompt-body producers emit a canonical marker carrying the same shell values
the sentinel records, so producer and consumer agree by construction. The prose
frame stays as a fallback, bounded by the phase-token grammar ADR-2121 owns and
deliberately reporting no plan — an absent identifier means "cannot compare"
and is safe; a wrong one is a false mismatch and is not.

The prose sentence itself is byte-identical: the executor agent reads it too,
so the marker is purely additive (Hyrum's Law).

An inapplicable sentinel is now named in the guards' deny reason instead of
being dropped silently — the silence is why this survived three producers and
two consumers unnoticed. Interpolated values come from a sentinel file and from
prompt text, so both are length-bounded and stripped of control characters.

ADR-4630 locks the seam and maps the epic's three phases.

Refs #4630
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4594): resolve eight review findings across the dispatch-identity seam

Three orthogonal review engines ran on 43418af144 — the code-review skill's
Standards and Spec axes, and an isolated adversarial security pass — plus a
self-review of the committed diff. Every finding is fixed here; none deferred.

F1 (major, reproduced). A keyless or unknown-key-only marker — the literal
"[gsd:dispatch]" or "[gsd:dispatch run=..]" — matched the marker grammar and
returned source:'marker' with both fields null, suppressing the prose fallback
entirely. Any prompt text containing that literal silently disabled identity
narrowing, so a fresh sentinel applied to a dispatch it was never scoped to,
defeating #3045 SECURITY F2. Prompt text is attacker-influenceable. A marker
that yields neither recognized key is no longer a marker: the scan continues to
later markers, then later texts, then prose. Forward-compatible tolerance of
unknown keys is unchanged.

F2/F3 (major). The first cut duplicated sanitizeForReason,
describeSentinelDiscard and REASON_INTERPOLATION_MAX_LEN byte-for-byte across
both guards — the exact defect class this epic exists to delete, and with no
cold-load justification, since both hooks already require hooks/lib/. They now
live in hooks/lib/isolation-deny-reason.js, and buildSentinelDiscard lives in
isolation-sentinel.js beside the comparison it mirrors, returning the nested
{sentinel:{phase,plan}, dispatch:{phase,plan}} shape instead of a bespoke
four-field bag that renamed the pairs already flowing through the seam.

F4 (hard violation). The visibility test asserted on the deny reason's prose.
CONTRIBUTING.md prohibits raw text matching on hook output, which is why every
deny carries a stable reason_code. The discard is now a structured
sentinel_discarded field on each hook's stdout JSON, and the test asserts that;
the sentence stays for the operator but is no longer the contract.

F5 (hard violation). The 64-character truncation limit had no boundary
coverage. 63/64/65 are now exercised against the single consolidated helper.

F6 (minor). sanitizeForReason stripped C0/C1 controls but not U+2028/U+2029 or
the bidi overrides, so a crafted value could still reflow or reverse the
message. Both classes are stripped, with a test each.

F7 (major). The producer/template parity test was vacuous — it rendered a
marker and re-parsed its own output, and would have passed with both templates
deleted. It now reads the two workflow templates, extracts each marker line,
substitutes the measured values and asserts the owner's parser returns them.
Proven red by deleting one template's marker line before being proven green.

F8 (doc). ADR-4630 and the design notes claimed the marker is guaranteed on the
orchestrator-worktree path because that prompt is built in shell. It is not:
executor-isolation-dispatch.md:131 says plainly that those are template
placeholders, not shell variables, so {plan_id} is model-substituted there too.
A false guarantee in a design lock is worse than a stated limit. Both documents
now say the marker is model-substituted on both paths and that the prose
fallback is the real floor everywhere. The "3 workflow templates" count was
also wrong — 3 prose sites across 2 files, 2 of which carry the marker.

Refs #4630
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4594): refresh the compact-content baseline and acknowledge execute-phase.md growth

Refs #4630.

The dispatch-identity marker and its substitution note grew
gsd-core/workflows/execute-phase.md by 525 bytes (91846 -> 92371), which drifts
two real-tree guards that lint:ci does not run:

- tests/benchmark-compact-content.test.cjs asserts the committed baseline is
  "up to date"; the split for execute-phase.md moved off 25827 -> 25952 and on
  23576 -> 23701, taking its compaction reduction 8.72% -> 8.67%. Baseline
  regenerated with scripts/benchmark-compact-content.cjs --write.
- tests/emitted-attribution.test.cjs requires a growth acknowledgment trailer
  for any emitted file that grows, keyed on the bare filename. Added below.

The growth is two additions and no rewrites: the [gsd:dispatch ...] marker line
inside the Agent() prompt's <objective>, and the note telling the orchestrator
to substitute {plan_id} with the plan's id verbatim. Both are load-bearing --
the marker is what lets a guard hook match a dispatch to the sentinel the
per-plan gate wrote, and without the note the orchestrator has no instruction
telling it the value must not be paraphrased.

Emitted-Drift-Ack-Growth: execute-phase.md — adds the canonical [gsd:dispatch] identity marker and its {plan_id} substitution note, which the isolation guards compare verbatim against the run-scoped sentinel (#4594)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4594): set changeset fragment pr to 4693

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:36:08 -04:00
Tom Boucher
241646a43a fix(#4651): classify .env names by final extension, and close the trailing-dot alias bypass — Phase 1 of #4636 (#4659)
* test(#4651): failing-first coverage for final-extension classification

Phase 1 of epic #4636, absorbing #4580. Tests only; no fix. These MUST fail.

The guard classifies a name by comparing everything after `.env.` as one
token against a set whose members are FINAL EXTENSIONS. So `.env.local.example`
yields suffix `local.example`, which is not a member, and a committed
secret-free template is refused. That is a category error, not strictness.

Two arms are covered because the same classification is hand-rolled twice in
one file: `isSecretBasename` for Read/Bash, and `globAltSelectsSecret`
(`lit.startsWith('.env.')`) for Grep globs. Fixing one alone would ship a
guard that allows `cat .env.local.example` while refusing
`Grep --glob '.env.local.example'` — the same file, the same hook, opposite
answers. A cross-arm parity loop over one shared list asserts the two cannot
drift.

Rows that exist because they are the ones nobody enumerates:

- `.env.example.local` must stay BLOCKED. Final extension is `local`; this is
  dotenv's documented local-override convention and a real secret. Any fix
  shaped as "contains example" admits it.
- `.env.local.` must stay BLOCKED — empty final extension is not a member.
- `.env.` must stay ALLOWED. Note #4580's proposed patch adds
  `if (suffix === '') return true;`, which flips it to blocked; that breaks the
  existing `allows` assertion in this suite and broadens the protected set,
  which epic #4636's non-goals forbid. Not applied.
- `.env.local.exam*` (partial glob literal) must stay BLOCKED — it can select
  `.env.local`, and a partial literal cannot be classified.
- `*.example` and `*` must stay ALLOWED — regression protection on the arm
  that already works.

Local behavioral repro of the current guard, confirming the tests fail for the
right reason rather than by construction:

  .env.local.example  rc=2 (blocked)   <- the defect
  .env.example        rc=0 (allowed)
  .env.local          rc=2 (blocked)
  .env.example.local  rc=2 (blocked)
  .env.               rc=0 (allowed)
  glob .env.local.example  rc=2        <- the second arm

Regressions are folded into the owning module's suite rather than a new
tests/fix-NNNN-*.test.cjs file, per scripts/lint-regression-test-names.cjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): classify by final extension so .env.<name>.example is readable

Phase 1 of epic #4636, absorbing #4580. Implements ADR-4650 decision 5.

The guard compared everything after `.env.` as ONE token against a set whose
members are FINAL EXTENSIONS. `.env.local.example` yielded `local.example`,
which is not a member, so a committed, secret-free template was refused — the
guard blocked the one file that exists so nobody has to open the real `.env`.

That is a category error, not strictness. The fix is not "add local.example to
the set"; it is to compare the right token. hooks/lib/filename-classification.js
now owns that distinction and is the only place it is expressed.

Both arms are fixed, because the same classification was hand-rolled twice in
this one file:

  - isSecretBasename (Read/Bash) now tests finalExtension(suffix).
  - globAltSelectsSecret (Grep --glob) split its first branch. With no
    wildcard the alternative IS a whole filename, so it is classified exactly
    via isSecretBasename. With a wildcard present the literal is only a
    PARTIAL prefix (`.env.local.exam*` can still select `.env.local`) and
    cannot be classified, so the original conservative rule stays.

Fixing only the first would have shipped a self-contradicting guard: `cat
.env.local.example` allowed while `Grep --glob '.env.local.example'` refused —
same file, same hook, opposite answers. A cross-arm parity loop over one shared
list now asserts the two cannot drift.

Two deliberate departures from #4580's suggested patch, both verified:

  - Its `if (suffix === '') return true;` is NOT applied. That flips `.env.`
    from allowed to blocked, breaking an existing assertion in this suite and
    broadening the protected set, which epic #4636's non-goals forbid.
  - `fullSuffix` was drafted alongside finalExtension and removed before
    commit: zero production consumers, and none planned (Phases 2-4 are
    containment, duplicate draining and the path-join ratchet, none of which
    classify filenames). A zero-caller export is dead code. The distinction is
    pinned instead by a test asserting finalExtension('local.example') is
    'example' and explicitly NOT 'local.example'.

The protected set is unchanged. `.env.example.local` stays BLOCKED — its final
extension is `local`, dotenv's local-override convention and a real secret;
any fix shaped as "contains example" admits it.

Scoped out by measurement, not assumption: src/validate.cts:395 and
src/phase.cts:1674 also hand-roll lastIndexOf('.'), but both parse phase
identifiers (`3.2` -> parent `3`), owned by the phase-id.cts seam. Folding
them in would repeat this same category error in the opposite direction.

Checkpoint 1 (prove RED) on the tests-only commit 91d3d6e1: outcome=failed,
26 failures / 45330, all 26 in the two new test files, zero pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): document the widened template exemption and cover the Bash arm

Two findings from the isolated adversarial review, both fixed in place.

1. The header's "Stated cost" passage named only the four literal template
   names, but since this change the exemption keys on the FINAL EXTENSION, so
   the trusted set is `.env.<anything>.{example,sample,template,dist}` — an
   unbounded family. The reviewer demonstrated it: `.env.prod-real-secrets.example`
   is allowed. That is the deliberate and necessary cost of fixing #4580, but
   it was materially larger than what the header disclosed, and a silent
   expansion of a security guard's trusted set is not acceptable. The passage
   now states the family, the concrete bypass, and that it applies across
   Read, Grep and Bash alike.

2. The cross-arm parity loop asserted Read and the exact-literal Grep glob but
   not Bash, whose `namesSecret` -> `isSecretBasename` path is genuinely
   distinct. The Bash arm was covered only by two one-off tests outside the
   shared table, so the table could not have caught a drift there. The loop now
   drives all three arms from the same TEMPLATES/SECRETS arrays.

No classification logic changed. The Read-arm behavioral table is byte-identical
before and after: rc=0 for .env.local.example / .env.example / .env. ; rc=2 for
.env.local / .env.example.local / .env / .secrets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): treat trailing dots and spaces as aliases of the protected file

Closes a Windows path-alias bypass surfaced by the isolated adversarial review
of this phase. Maintainer-approved as in scope.

Win32 strips trailing dots and spaces from every path component, so `.env.`,
`.env..`, `.env `, `.env. `, `.env .`, `.secrets.` and `.secrets ` all resolve
to the real `.env` / `.secrets` on Windows. The guard allowed every one of them
— a bypass of a file it already protects, reachable from Read, Grep and Bash
alike. `isSecretBasename` now normalizes the basename before classifying.

The whole class is fixed, not the reported name. `.env.` alone would have left
`.secrets.` and the trailing-space forms open, which is the same
one-cause-explains-every-failure trap this epic exists to close.

Two consequences, both measured rather than assumed:

  - `.env.example.` flips blocked -> ALLOWED. It aliases the already-trusted
    `.env.example` template, so this is correct; it was previously blocked only
    because the trailing dot broke final-extension parsing.
  - A Bash token that is exactly `.env` plus trailing whitespace flips
    allowed -> BLOCKED. Verified this is CONSISTENCY, not a new false-positive
    class: the bare `.env` token was ALREADY blocked as an operand in the same
    position before this change, so the alias now simply behaves like the thing
    it aliases.

The header's "No whitespace trimming" guarantee is preserved and now stated
precisely: leading and interior whitespace is still never trimmed, so prose
like a commit message mentioning `.env` in a sentence stays prose and stays
allowed. Only TRAILING dots and spaces are stripped. Two tests pin that.

This lands at the same behavior #4580's proposed `if (suffix === '') return
true;` would have produced for `.env.`, which this phase earlier rejected. The
rejection was correct on its stated grounds — that line broadens the protected
set, which epic #4636's non-goals forbid. The Windows framing is different:
normalizing an alias of an already-protected file is not a broadening, and the
fix is reached by normalization rather than by special-casing an empty suffix,
so it generalizes to `.secrets.` and the space forms.

Cannot be reproduced on this host — the remote matrix is Linux-only and Windows
coverage arrives from CI — so this ships on the Win32 path-normalization
contract plus the CI lane, and that limitation is stated rather than implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): one owner for path segmentation, closing a Read/Grep divergence

Four findings from the two-axis review, all fixed in place.

The real one: the guard had TWO path-segmentation rules. `lastSegment` (used
by Read and Bash via `namesSecret`) splits on both `/` and `\`, while
`classifyGrepGlob` hand-rolled its own on `/` only. Measured:

  Read  of `config\.env`      rc=2  BLOCKED
  Grep  --glob 'config\.env'  rc=0  ALLOWED

Same logical file, opposite answers — precisely the divergence this epic
exists to remove, sitting inside the file this phase was already fixing.
`lastSegment` now lives in hooks/lib/filename-classification.js and both arms
call it. All five path-bearing cases (both separators) now agree.

Note on how this was nearly missed: the first measurement of it reported
"both allow", which looked like the reviewer was wrong. That reading was a
measurement artifact — `config\.env` inside a printf'd JSON payload is an
invalid escape, so the hook fails open at rc=0 and the test was observing
JSON breakage rather than the predicate. Re-measured with correct escaping,
the divergence is real. The tests added here use properly escaped literals
and were verified by running, not by reasoning about the escaping.

Also fixed:

  - Both fast-check properties were satisfied by a degenerate
    always-return-'' implementation: "never contains a dot / is a suffix" and
    "never ends with dot-or-space / is a prefix" are both trivially true of
    the empty string. They now additionally pin content preservation — the
    removed tail must match /^[. ]*$/, and a name with nothing to strip must
    come back unchanged.
  - The cross-arm parity loop used only bare basenames, so it could not have
    caught the divergence above. It now covers path-bearing names with both
    separators.
  - That loop's description overclaimed: Read and Bash BOTH route through
    `namesSecret`, so they are not independent paths; only the Grep glob arm
    is genuinely separate. The description now says so rather than implying
    three-way independence.
  - `normalizeWindowsBasename` runs on every platform, not only Windows. Its
    doc now states that explicitly: the guard must answer identically
    everywhere, and a name is judged by what Win32 would resolve it to.

No classification logic changed; the 12-name regression sweep is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4651): regenerate install-tree goldens, correct the guard's user-facing docs

Three things, all consequences of the fix rather than new behavior.

1. Install-tree goldens. `hooks/lib/filename-classification.js` is a SHIPPED
   file — package.json `files` includes `hooks` — so every per-runtime install
   tree gains a path. Checkpoint 2 failed on exactly this: 11 failures, all in
   tests/golden-install-tree.test.cjs, against 45356 passing. Regenerated via
   scripts/gen-install-tree-fixtures.cjs; 11 goldens changed, matching the 11
   failures one-for-one.

   This ripple was identified at design time and then not acted on. Fleet's
   impact preview named golden-install-tree.test.cjs before any code was
   written, and 40-design.md records it under "Ripples identified". Writing a
   risk down is not the same as discharging it, and a full matrix run was spent
   discovering something already known.

2. docs/USER-GUIDE.md made a precise and now-false claim about the guard's
   protected set: it named `.env.example` / `.sample` / `.template` / `.dist`
   as the four exempt names. The exemption keys on the FINAL EXTENSION, so the
   exempt set is the unbounded family `.env.<anything>.{example,sample,template,dist}`.
   The page now states that family, the widened residual, that order matters
   and only the last segment counts (`.env.example.local` is a secret), and
   that trailing dots and spaces are stripped because Windows resolves them to
   the protected file. A wrong user-facing model of what a security guard
   protects is worth correcting even though Fixed/Security changesets are
   exempt from the required-docs rule.

   docs/ARCHITECTURE.md and docs/INVENTORY.md say "templates such as
   `.env.example` exempt" — non-exhaustive, still true, deliberately left
   alone. Same for the ja-JP / zh-CN / ko-KR / pt-BR rows, which carry the same
   hedged phrasing; hand-translating a security description unreviewed is not
   something to do silently.

3. Two changeset fragments, not one. A refusal corrected is `Fixed`; a bypass
   closed is `Security`. Folding the second into the first would under-report
   it in the release notes. Both carry `pr: 0` for backfill once the PR exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4651): backfill changeset PR number to 4659

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:42:43 -04:00
Tom Boucher
db4d8a9bae fix(#4619): execute-phase computes decimal/N-segment phase numbers without breaking shell arithmetic (#4644)
* fix(#4619): execute-phase computes decimal/N-segment phase numbers without breaking shell arithmetic

$((10#${PHASE_NUMBER})) is a hard bash/zsh syntax error when PHASE_NUMBER is
decimal (01.1, from an inserted phase) or N-segment (23.1.2) — neither is
valid shell-arithmetic syntax at all, and the failed expansion aborts the
rest of the snippet in a non-interactive shell. safe_resume_gate runs
unconditionally before trusting STATE.md or dispatching any executor, so
execute-phase failed at its own gate before the first executor on any
decimal phase, regardless of workflow.tdd_mode. Regression from #4194.

Fixes all 4 sites: safe_resume_gate and the TDD gate in
workflows/execute-phase.md, the completion-signal spot-check fallback in
workflows/execute-phase/steps/completion-reconciliation.md, and the
executor gate validation example in references/tdd.md. Each now zero-strips
only the leading integer segment into a *_INT variable (via %%.* / #
parameter expansion — always valid shell syntax regardless of what follows)
and keeps the remainder as an escaped-dot string for the anchored commit-
scope regex, exactly as issue #4619 verified in both bash and zsh. A plain
integer phase (12, 01) computes byte-identically to before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#4619): pin the decimal/N-segment fix and characterize the pre-fix bug

Behavioral coverage via real bash execution: the old $((10#01.1)) form
throws (characterizes the bug, matching the issue's own reproduction); the
new form resolves 01.1 -> 1\.1 and 23.1.2 -> 23\.1\.2, unchanged for plain
integers (12 -> 12, 01 -> 1); the resulting anchored ERE matches
feat(01.1-03):/test(1.1-3): and correctly rejects feat(01-03):,
feat(01.2-03):, feat(011-03):, feat(12-03): for a decimal phase — mirroring
issue #4619's own verified table exactly. Updates
safe-resume-gate-anchoring.test.cjs's 4 existing source-text assertions
(one per site) to the new fixed text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4634): refine the shell-arith drift detector to distinguish safe from unsafe arithmetic

With #4619's fix in place, the guard's original "ban $((10#... outright,
match any occurrence" was too blunt: it flagged a comment merely mentioning
the pattern in prose, the now-safe $((10#$PHASE_INT)) arithmetic on an
already-%%.*-stripped integer, and the always-safe plan-id arithmetic
(plan ids are plain integers, never decimal). Refines the detector to skip
full-line comments and to only flag a captured variable/placeholder name
that contains "phase" and does NOT end in _INT/_int — the naming convention
the #4619 fix establishes at all four sites for "already reduced to a safe
integer." A plan-id variable was never phase-number arithmetic in the first
place and is excluded on the same basis.

This closes epic #4634's D6 ("lint-phase-id-drift... passes with no new
exemptions") and D7 ("a decimal and N-segment phase id survive an
end-to-end execute-phase selection without error") for real — the guard now
reports zero violations across all five .cts/.md rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: regenerate conformance-tier manifests for the new test file

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#4619): cover the plain-padded-integer near-miss matrix too

Review found the anchored-ERE near-miss coverage only exercised the
decimal case (PHASE_NUMBER=01.1); issue #4619's own worked table also
verifies the plain padded-integer case (01 -> PHASE_N=1) against its own
near-miss set (matches 01-03, rejects 01.1-03/011-03/12-03). Adds the
missing assertion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4619): add Fixed changeset

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4619): correct JS backslash-escaping in safe-resume-gate anchoring test

The test's string-literal assertions for the PHASE_FRAC//./\\.} pattern wrote
only 2 backslash characters in JS source, which single-quoted-string parsing
collapses to 1 real backslash at runtime -- but the workflow/reference files
actually contain 2 raw backslash bytes at that position (needed so bash's
${var//pattern/replacement} produces the correct single-backslash output).
Write 4 backslash characters in the JS source at all 4 occurrences so the
runtime string matches the files' real bytes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4619): refresh the committed compact-content benchmark baseline

The new PHASE_INT/PHASE_FRAC arithmetic lines added to
gsd-core/workflows/execute-phase.md shifted its committed compaction-ratio
baseline. Regenerate via `node scripts/benchmark-compact-content.cjs --write`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4619): note the safe_resume_gate arithmetic growth in the test header

The emitted-attribution gate flags execute-phase.md growing 91253 -> 91846
bytes (593 bytes). The growth is the fix: the safe_resume_gate and TDD RED
block now derive PHASE_INT/PHASE_FRAC before computing PHASE_N, so a
decimal/N-segment phase number (e.g. 01.1, 2.3.1) zero-strips its leading
integer segment via base-10 arithmetic instead of forcing the whole value
through $((10#...)) and hitting a hard shell syntax error on the first dot.

A blank line previously separated the Emitted-Drift-Ack-Growth trailer from
the Co-Authored-By trailer below it, which splits git's trailer-block
detection: only the last contiguous non-blank run of Key: Value lines at the
end of a commit message is recognized as trailers, so the growth ack was
silently read as ordinary body text and the differential-attribution gate
failed with the growth unacknowledged. Joining the two trailers into one
contiguous block fixes it.

Emitted-Drift-Ack-Growth: execute-phase.md — adds PHASE_INT/PHASE_FRAC derivation to the safe_resume_gate and TDD RED commit-scope grep so a decimal/N-segment phase number zero-strips its leading integer segment via base-10 arithmetic instead of failing on a non-numeric value (#4619)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#4208): replace chmod-based restore-failure injection with a root-proof git shim

`tests/commit-files-deletion.test.cjs`'s two restore-failure tests simulated
an unwritable index via a `post-index-change` hook running `chmod a-w` on
the git dir. That relies on the OS enforcing the *owner's own* permission
bits against itself, which uid 0 (a routine identity inside this repo's
Docker-based gsd-test benches) does not: every DAC check short-circuits true
for root, so the write the chmod meant to block silently succeeds, the
restore comes back clean, and the disclosure/rollback behavior under test
never actually gets exercised.

This is CLAUDE.md's own named anti-pattern for I/O-failure injection
("Cross-platform test IO-failure injection" — chmod tricks fail under root
Docker/CI). It is confirmed as the actual root cause here, not a production
defect: `src/commands.cts`'s `restoreRemovedEntries`/rollback-disclosure
logic (added by #4253, merged just before this run) was hand-traced and
manually reproduced end to end on an unprivileged workstation against a
freshly built `gsd-core/bin/lib/commands.cjs`, and it already produces
exactly the `staging_failed` + "could not be restored" / "could NOT be
restored during rollback" results both tests assert. The other
`post-index-change`-based tests in this file (a `sleep` to force a timeout;
a real `update-index` to flip a restored entry's mode) are unaffected
because neither depends on a permission check — consistent with only the
two chmod-based tests failing on the real remote run.

Replaces the chmod fixture with a fake `git` placed ahead of the real one on
PATH that fails only `update-index --add --cacheinfo` — the one call the
restore makes — unconditionally, regardless of privilege level. Every other
git invocation execs straight through to the real binary, so the rest of
each scenario (`rm --cached`, the restore's own `ls-files` verification,
etc.) is exercised exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4619): backfill changeset pr number to 4644

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4619): feed the bash fixture script via stdin, not argv, to fix Windows CI

Passing the script as a `-c "<script>"` argv element made it subject to
Windows' CreateProcess command-line argument encoding, which silently
dropped the escaped-dot backslashes before bash ever saw them (observed on
PR #4644's windows-latest CI shard: `1\.1` came back as `1.1`). Feeding the
same script via stdin instead removes argv entirely from the transport, so
there is nothing for Windows to re-encode. POSIX behavior is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 15:47:14 -04:00
0xdhx
4cc2a466b5 fix(#4208): add --files-removed so commit --files can record a move without a directory pathspec (#4253)
* fix(#4208): add --files-removed so commit --files can record a move without a directory pathspec

`cmdCommit`'s `--files` list can stage an addition but never a deletion:
the #2014 guard skips a missing explicit entry because the filesystem
cannot tell "moved away" from "not written yet". A caller that moves a
file therefore had two forms, both wrong — a directory entry records the
move but also commits every unrelated file in that directory (a
concurrent session's in-flight todo, in the unattended execute-phase
sweep), and a file entry leaves the old path's deletion dangling with the
todo tracked at both paths.

`--files-removed <paths>` is the caller-declared delete intent. Each entry
names a file, or a directory whose tracked-but-absent files are the
removals; those paths are staged with `git rm --cached` and join the
commit pathspec. `--files` keeps its skip-if-missing contract untouched.
A file entry still present on disk fails the commit closed with the
existing staging-failure rollback; a never-tracked path is a no-op.
`--files-removed` alone is a declared scope, not the unscoped .planning/
sweep.

The dispatcher previously folded every non-flag token after `--files`
into that list, so a second list flag could not exist; each list now
runs from its flag to the next `--` token.

The execute-phase todo sweep names the moved todos on both sides from
CLOSED[@], and cleanup's archive commit moves .planning/phases/ and
.planning/quick/ under --files-removed.

Fixes #4208

Emitted-Drift-Ack-Growth: cleanup.md — the archive commit moves phases/ and quick/ under --files-removed; the growth is one paragraph stating why those two directories must not be --files entries

* chore(#4208): set changeset fragment pr to 4253

* fix(#4208): fit execute-phase.md under the ADR-857 ceiling and re-point the #2415 guard

Three CI failures, all consequences of this PR's own change.

1. gsd-core/workflows/execute-phase.md was 93,577 bytes against the
   ADR-857 Phase 6 margin gate's <= 93,400 (hard ceiling 93,600). The
   three-line rationale comment plus the four-line array-building block
   added 318 bytes to a file that had only 141 of headroom on next.

   Move the rationale to docs/CLI-TOOLS.md -- which this PR already
   extends with the --files-removed contract, and which is where the
   ADR-857 gate wants call-site detail to live rather than in the host
   workflow -- and fold the array build onto one line. 93,577 -> 93,372.

2/3. tests/close-phase-todos-stage-deletion.test.cjs pinned the #2415
   guarantee to its old MECHANISM: it regex-matched the literal
   .planning/todos/{completed,pending}/ directory pathspecs in the
   commit --files list. This PR deliberately replaced those with named
   files (a directory entry also committed an unrelated todo a
   concurrent session dropped in mid-close), so the guard failed on a
   change it should have accepted.

   Re-point it at the new mechanism without weakening it: assert the
   ADDED array reaches --files, the REMOVED array reaches
   --files-removed, STATE.md is still committed, and -- newly -- that
   the two arrays are built from $COMPLETED_DIR and $PENDING_DIR
   respectively. Verified by negative control: deleting
   --files-removed "${REMOVED[@]}" from the workflow still fails the
   test, so the #2415 regression remains caught.

Note for the merge queue: #4233 also grows execute-phase.md (+114). The
two are additive -- different regions, no textual conflict -- so with
both landed the file reaches ~93,486, over the 93,400 margin though
under the 93,600 hard ceiling. Whichever merges second will need to
reclaim ~86 bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0183892Y3fxxirte4WNmBKbv

* fix(#4208): reclaim execute-phase.md bytes so the PR is net-neutral under the ADR-857 margin

Rebasing onto next surfaced the byte-gate collision flagged earlier on
this PR: #4284 grew execute-phase.md by 95 bytes (93,259 -> 93,354),
so this PR's +113 landed at 93,467 against the <= 93,400 margin in
tests/claude-orchestration.test.cjs.

Compact the close_phase_todos step this PR already edits -- drop the
PHASE_NUM indirection, fold the normaliser and the match guard, print
the closed list with one printf, shorten the step's prose -- without
touching the mechanism the #2415 guard pins (ADDED/REMOVED arrays, the
plain mv). 93,467 -> 93,349: 5 bytes under the base, so the PR no
longer spends any of next's 46 bytes of headroom.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm

* fix(#4208): classify absent index entries before staging a removal; restore removed entries exactly on rollback

Review of #4253 found three Majors with one root cause: the removal
side judged presence by fs.lstatSync alone, where the addition side
already reads `git ls-files -v` state. Absence from the worktree is not
removal:

- a submodule gitlink (mode 160000) whose directory was deleted by hand
  lists like a file and was `rm --cached` with no .gitmodules cleanup;
- a skip-worktree path is never materialised by a cone-mode sparse
  checkout, so a directory entry over a sparse-excluded tree dropped
  that whole tree from the index;
- an assume-unchanged path's worktree state is not something git
  itself consults;
- an intent-to-add entry (`git add -N`) renders as a plain cached entry
  on the empty blob, yet nothing tracked exists to remove and no
  rollback can restore the flag.

The index listing now carries each entry's `ls-files -v -s` tag, mode
and stage. Only a plain cached (H), stage-0, non-gitlink entry is a
removal candidate; every other state is left alone under a directory
entry (exactly like a present file) and fails closed when named
directly, with the state in the error. "Named directly" is decided on
RESOLVED paths, not strings -- realpath of the longest existing prefix
with the absent tail re-appended: an absolute path, `./x`, `--cwd`, or a
symlinked spelling of the tree (macOS `/var` ->
`/private/var`, where `process.cwd()` is the real path and the caller's
absolute path is not -- CI on this round's first push) all resolve to the
same entry, where a string compare against git's cwd-relative output
silently took the directory polarity (pre-push review, driven; the
symlink case is driven with an aliased fixture directory). The enumeration's domain is what
`ls-files -v -s` can emit for an index entry, stated at the classifier.

The third Major -- on an unborn HEAD a successful `rm --cached` was
never rolled back when a later entry failed -- is fixed differently
from the review's suggestion. Pushing the path into stagedPaths would
put it on the commit pathspec, which a root commit refuses ("pathspec
did not match", driven), and `git reset -- <path>` cannot restore an
entry with no HEAD anyway. Instead every index entry this call removes
is recorded (mode, blob) before the `rm` and put back with
`update-index --cacheinfo` on rollback. That also restores a
caller-pre-staged blob at a removed path exactly, where a reset would
have silently replaced it with HEAD's version. The rollback is
best-effort, as the addition-side reset already was, and the docs say
so.

Eight tests: gitlink under a directory entry, named directly, and named
by absolute path; skip-worktree both forms; intent-to-add both forms;
assume-unchanged named; unborn-HEAD partial failure restores the
removal; pre-staged blob survives the rollback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm

* fix(#4208): drop the empty fenced block left dangling in cleanup.md's commit step

Review nit on #4253: inserting the --files-removed rationale between the
original bash block and its closing fence left an empty ```bash``` pair
before </step>. Harmless at runtime, a formatting artifact of this PR's
own diff; removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm

* fix(#4208): a boolean flag inside a commit path list no longer ends the list

Review minor on #4253: collectList stopped at the next `--` token, so a
positional wedged between a boolean flag and the next list flag
(`--files a --amend b --files-removed c`) was claimed by neither list
and silently dropped -- a regression in shape against the old
slice-to-end parse, which filtered `--` tokens and kept `b`. No current
call site interleaves that way, but the gap was real.

A list now runs to the next LIST flag (`--files` / `--files-removed`)
and skips boolean flags on the way, and a REPEATED list flag merges
its runs (`--files a --files b` -> [a, b]) as the slice-to-end parse
did -- a first cut stopped at the repeat and dropped `b`, the same
silent-drop shape one level over (pre-post comment audit). The only
change #4208 makes to parsing is that a second list flag can exist.
Tests: STATE.md wedged between --no-verify and --files-removed lands
in the commit; both runs of a repeated --files reach it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm

* test(#4208): drive the reappearance window with a post-index-change hook

Review nit on #4253: the defensive re-check for a file recreated between
the absence test and `git rm --cached` -- the concurrent-session race
this PR's own changeset names -- had no test. git fires
post-index-change the moment `rm --cached` writes the index, so a hook
that copies the file back exactly then exercises the window
deterministically. The call reports staging_failed / "reappeared on
disk", commits nothing, and the rollback restores the removed entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm

* fix(#4208): restore a staged removal when the call records nothing

A `git rm --cached` that succeeds mutates the index whether or not a commit
follows. Only the staging-failure rollback put those entries back, so a call
that reached `nothing_to_commit` reported no state change while the removal sat
staged -- riding along on the caller's next commit.

The review named the unborn-HEAD, removal-only shape. Keying on `headExists`
would have fixed half of it: the guard also fires with a real HEAD when the
removed path is index-only (added, never committed), because `diff HEAD` reads
clean with the path absent on both sides. Both shapes now restore, at both
`nothing_to_commit` exits. The failure exits are deliberately left alone --
they report a failure rather than no-change, and the addition side leaves its
own staged paths there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* refactor(#4208): lift declared-removal staging out of the cmdCommit hotspot

`cmdCommit` was a critical-risk hotspot before this flag existed, and #4208 had
inlined another ~270 lines into it. `stageDeclaredRemovals(cwd, removedDeclared)`
now owns the index-state classification, path canonicalisation and entry
recording, returning the pathspec entries and the recorded removals its caller
merges.

Pure motion: no branch, message or probe changed. Only the two accumulators
became local names, and `restoreRemovedEntries` stays with the caller because
the exits that restore are the caller's. cmdCommit 888 -> 625 lines here; the
extracted helper is 277.

(Figures corrected after publication: an earlier version of this message said
854 -> 591 and claimed the result was below cmdCommit's pre-#4208 shape. Both
were wrong -- the count came from a faulty brace scanner, and `next`'s cmdCommit
is 581, so this is above it, not below.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* test(#4208): property-test the two-list commit parser

RULESET.TESTS.property-based-testing asks a parser for at least one property
test asserting a domain invariant; `collectList` had only hand-picked examples,
one per shape a review round had already broken.

Hoisted it to module scope as `collectListFlagValues` and exported it in the
file's existing exported-for-tests convention -- a parser reachable only by
spawning the CLI can be tested one example at a time and no faster.

Three properties over generated argv: every positional lands in exactly the run
open at it whatever the flag order or count; no positional after the first list
flag is dropped or double-claimed; and with `--files-removed` absent the parse
equals the pre-#4208 slice-to-end parse. Controlled against two mutants -- a run
ending at any `--` token, and a repeated list flag that does not merge -- each
of which the properties catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* test(#4208): pin cleanup.md's archive commit to --files-removed

execute-phase.md's rewrite is pinned by the #2415 guard in this file;
cleanup.md's equivalent was not, so reverting its routing would have been
caught by nothing -- the mechanism's unit tests never read this file and pass
either way.

Asserts the two archived directories are under --files-removed and NOT under
--files (where a directory entry sweeps in a concurrent session's in-flight
writes), and that the destinations and STATE.md stay on the additive half.
Controlled by restoring the pre-#4208 sweep, which fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* test(#4208): pin that a symlink to a directory is one tracked path

Review of #4253 read the `lstatSync(...).isDirectory()` test as a
symlink-following defect. Driving it says the opposite: git tracks the link as
a single blob (mode 120000) and does not traverse it, so the tracked paths
"under" it live at the real directory and were never named by the caller.
Following the link would stage those -- the directory sweep #4208 exists to
remove -- while the named entry still sat present on disk.

Pinned rather than changed, with the premise driven in the test body. Swapping
`lstatSync` for `statSync` -- the prescription as written -- fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* chore(#4208): refresh the compact-content baseline for this PR's execute-phase edit

The base range added `tests/benchmark-compact-content.test.cjs` and a committed
token baseline over the compacted workflows. This PR edits
`gsd-core/workflows/execute-phase.md`, so the baseline drifts by +12 tokens on
that entry and on the aggregate.

Refreshed with `node scripts/benchmark-compact-content.cjs --write`; the diff is
those two entries and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* fix(#4208): report a removal the call could not put back

Round review of this round found the restore itself unchecked: the helper
ignored `update-index`'s exit code, so a FAILED restore still reported
`nothing_to_commit` -- the same false "no state changed" the restore exists to
prevent, surviving one level down on the restore-failure path.

It now returns a boolean. The two no-change exits report `staging_failed`
naming the paths left staged; the staging-failure rollback still ignores it,
deliberately, because it is already reporting a failure and an unwritable index
is usually the failure being reported.

Driven with a post-index-change hook that makes the git dir unwritable the
moment `rm --cached` lands, so the restore cannot take its lock. Reverting both
guards fails the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* fix(#4208): disclose a removal the rollback could not restore

Round review refuted the reasoning behind leaving the rollback path's restore
unchecked. The claim was that this exit is already reporting a failure, so the
restore's result adds nothing. The counterexample is the ordinary case: the
reported failure is usually a DIFFERENT cause -- a contradictory declaration, a
reappeared path -- so a caller reading `failures` sees only that cause and
learns nothing about the removal still sitting in its index.

The rollback now appends a disclosure entry per un-restored removal, naming the
path. The reason and `file` still report the failure that caused the rollback;
the disclosure is additive.

Also moves the restore-failure test's chmod into a `finally`: `t.after` runs
AFTER the parent `afterEach`, so a throw before it left the fixture undeletable.

Both driven; reverting the disclosure fails the new test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* fix(#4208): decide index state by observation, never by an exit code

The restore added two commits earlier keyed both its record decision and its
success verdict on git's exit code. An exit code answers "did the command
succeed", never "did the index change" -- execGit collapses a spawn timeout to
a non-zero exit, and a killed git can already have written the index. Round
review drove four failures from that one assumption, in both directions:

  - a failed `rm` still contributed an entry, so the rollback disclosed a
    removal that was never staged (stale index.lock);
  - a timed-out `rm` whose write DID land contributed none, so a real mutation
    was neither restored nor disclosed;
  - a timed-out `update-index` whose write landed reported failure, publishing
    a "could NOT be restored" disclosure that was false;
  - and the read-back that replaced it omitted `-z`, so core.quotePath rendered
    `café.md` as `"caf\303\251.md"` and an exactly-restored entry read as not
    restored -- the same quoting defect this PR already fixed for `preStaged`.

Everything now observes the index. A failed `rm` re-reads `ls-files -z` for the
path: gone means this call owns the removal and records it; still there means
nothing was staged; a probe that cannot answer becomes its own failure entry
rather than an assumption. The restore verifies the same way, comparing the
WHOLE entry (mode, blob, stage), because `--cacheinfo` restores all three and a
path-only test accepts an entry that came back as something else.

The verdict is three-valued -- `restored` / `not-restored` / `unverified` --
and the unverified wording says the restore could not be VERIFIED rather than
that it failed. The rm's own failure is pushed ahead of any probe diagnostic so
a timed-out removal keeps `timed_out: true` and its own message as the reported
cause.

Five regression cases, each negative-controlled against the shape it pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* fix(#4208): treat a declared removal path as a path, not a pathspec

An index path handed back to git is parsed as a PATHSPEC, and the removal side
handed several back. Three driven harms, all of them the sweep-in this flag
exists to remove, arriving through the operand rather than through a directory
entry:

  - a tracked file literally named `.planning/*.md` made `rm --cached` GLOB: it
    removed `peer.md` and `stays.md` too, only the declared entry was recorded,
    so the rollback restored one of three and the other two rode out as staged
    deletions the result disclosed nowhere;
  - the same name reached `git commit -- <paths>`, which globbed and committed
    an undeclared `M peer.md` alongside the declared removal;
  - and the intent-to-add probe (`diff --cached` over the path) matched a
    STAGED PEER instead of itself, so an `add -N` entry was misclassified as
    ordinary content, removed, and restored by `--cacheinfo` -- which cannot
    restore the intent flag. It came back as a real staged addition.

Every operand on this path is now `:(literal)`: the `rm`, both index probes,
the intent-to-add probe, the restore read-back, the entry-level `ls-files` /
`ls-tree`, and -- for the REMOVAL-derived entries only -- the downstream
`ls-files` / dry-run / `diff HEAD` / `commit` pathspec. `--files` entries keep
whatever pathspec behaviour they have today; that is not this change's to
alter. `:(literal)` still resolves a directory to its descendants (driven), so
the directory form is unchanged.

Closes what an earlier cut of this commit declared as a residual: a filename
beginning with `:` is now removable end to end, because the commit pathspec no
longer reinterprets it.

Also fixes a MINOR from the same review: cleanup.md's contract test checked the
destinations' position relative to `--files-removed` but never that `--files`
was present at all, so deleting the flag still passed.

Un-literalising the seven sites fails three of the new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* fix(#4208): scope the rollback to the caller's own name space

Round review drove a rollback that destroyed the caller's own staged work. Two
causes, one of them pre-existing:

  - `git diff --cached` prints REPO-relative paths whatever the cwd, while
    `stagedPaths` holds the caller's cwd-relative names. In a project nested
    inside its repo (`<repo>/sub/.planning/...`) the two name spaces never
    intersect, so `preStaged` matched NOTHING, every path landed in `toUnstage`,
    and the reset unstaged a caller-staged deletion and modification that this
    call had never touched. `--relative` makes the two sets comparable, and is a
    no-op when the project IS the repo root. This governs the `--files` side too
    and predates this flag.
  - the rollback's `reset` was the last place a removal-derived name reached git
    as a bare pathspec; it takes `asPathspec` like every other site.

Driven on a nested fixture; dropping `--relative` fails the new test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* test(#4208): gate six fixtures that Windows cannot construct

CI's `test (windows-latest, 24, shard 2/3)` went red on this round. Two
primitives the new fixtures rely on do not exist on Windows, both driven on a
real Windows host rather than inferred:

  - a filename containing `*` or `:` cannot be created at all (`IOException` /
    `FileNotFoundException`), which is four of the pathspec fixtures;
  - `chmod` cannot make a directory unwritable — a write into a ReadOnly
    directory succeeds — so the two restore-failure fixtures cannot drive the
    failure they exist to drive.

Each is skipped on win32 with its measured reason, in the repo's existing
`{ skip: process.platform === 'win32' ? '<reason>' : false }` form. The
behaviours they pin are platform-independent; only the fixtures are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* test(#4208): build git's index-syntax path with forward slashes

The remaining Windows red was mine, not the platform's: `git rev-parse :<path>`
takes a forward-slash path, and `path.join` yields backslashes there, so git
rejected it as an ambiguous argument. The hook in the same test already used
the slash form.

Not gated — the behaviour it pins is portable; only the argument was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj

* chore(#4208): refresh the compact-content baseline against the rebased base

`next` moved the `new-project` split and the aggregate under this PR's
execute-phase entry; regenerated with `scripts/benchmark-compact-content.cjs
--write` so the only leaves differing from the base's copy are the
execute-phase split and the aggregate it feeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUcGM4FWeZV4cqvR7QBtJh

* chore(#4208): regenerate the macOS conformance tier for this PR's fixtures

`next` gained the macOS-specific conformance tier (#4593) after this branch
was cut. Its classifier (`scripts/gen-platform-conformance-tier.cjs --target
macos`) now selects `tests/commit-files-deletion.test.cjs` on the
`chmod-mode-bit` and `symlink-keyword` signals the PR's fixtures carry (the
chmod-driven failed-restore cases and the symlink-to-directory case).
Regenerated with `--target macos --write`; the platform tier was already in
sync. The file was modified, not added, which is why the added-files check
did not surface it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUcGM4FWeZV4cqvR7QBtJh

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-11 12:26:55 -04:00
Tom Boucher
1e47560e34 feat(#4593): add a macOS-specific conformance tier, final phase of epic #4589 (#4607)
test-conformance's macos-latest leg (Phase 2, #4591) has been running the
same 546-file, Windows-oriented conformance-tier list as windows-latest --
built from signals like windows-shell-token/windows-env-var that have
nothing to do with macOS. Issue #4593 asked for macOS coverage sized to
its own evidence-backed surface (zsh dispatch, case-sensitivity, darwin-
specific behavior) instead.

Issue #4593 was filed before Phase 5 (#4603) existed and referenced
updating test-full's macOS legs -- that job is gone. Corrected the issue's
body before any code was touched: the "shrink from full replay" half of
the original ask was already done by Phase 5; what remained was narrowing
the still-Windows-oriented tier macOS was inheriting.

Two design assumptions were measured and rejected before accepting a
design (documented in docs/adr/4593-macos-conformance-tier-architecture.md):
- Reusing the general tier's signals minus its 3 Windows-specific
  categories barely narrows anything (546 -> 424, 78% retained) -- most
  files match multiple signals and only need one to survive exclusion.
- A standalone CRLF/autocrlf signal, despite the issue naming
  "CRLF-checkout behavior": even narrowed to /\bCRLF\b|autocrlf/i it hit
  143/930 files. Root cause: CRLF is primarily a Windows checkout concern
  in this codebase (ADR-1703 files it under DEFECT.WINDOWS-TEST-
  PORTABILITY), so the signal was really re-selecting Windows-relevant
  files already covered by the general tier, not narrowing macOS
  specifically.

Built 5 new, genuinely macOS-specific signals instead: darwin-literal
(darwin alone, not the general tier's win32-OR-darwin), zsh-dispatch,
case-sensitivity, plus chmod-mode-bit and symlink-keyword reused verbatim
from the general tier (genuinely Unix-relevant, not Windows-motivated).
Measured against the real tree: 196 of 930 eligible unit-suite files
(21%), versus the general tier's 546 (59%) -- a real, evidence-backed
narrowing.

scripts/gen-platform-conformance-tier.cjs gains classifyMacosContent/
classifyMacosTree/renderMacosGeneratedFile and a --target windows
(default, unchanged)/--target macos CLI flag, so the same generator
produces two independent, gated outputs rather than needing a second
script. New committed output: scripts/lib/macos-conformance-tier.
generated.cjs. .github/workflows/test.yml's test-conformance job: only
the macos-latest leg's file-list source changes; windows-latest is
byte-for-byte untouched. New shipped-file ripples handled proactively
(19 install-tree fixtures regenerated, bin/install.js registered).

An isolated code-review pass found one real defect: the ADR's per-
category count table had drifted by 1 (zsh-dispatch, case-sensitivity)
because the new test file's own fixture strings joined the tree it
classifies after the table was authored -- fixed, with the union total
(196, what CI actually gates on) confirmed unaffected. An isolated
security-review pass found no qualifying findings.

The ADR also records an explicit requirement for any future widening
proposal: check whether the motivating regression is already covered by
Phase 1's no-rendered-text-length-assert lint rule (#4590) before
re-proposing full macOS/Linux parity, since that is exactly what #4421's
root cause was (a rendered-text-length assertion, not a real behavioral
divergence).

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:37:15 -04:00
Tom Boucher
bcd99696d3 chore(#4591): add platform-conformance-tier classifier + gate CI on it (#4598) 2026-09-10 08:36:13 -04:00
Tom Boucher
2cefa5a5ac enhance(#4139): Phase 8 — the toggle becomes discoverable, and the ledger closes (#4587)
* enhance(#4139): Phase 8 — the toggle becomes discoverable, and the ledger closes

ADR-4139's final phase. workflow.compact_content already defaulted to false
(Phase 1's buildNewProjectConfig hardcoded default), but nothing surfaced it:
/gsd-new-project never asked, and /gsd-settings/config had no toggle path for
an already-initialized project — config-set/config-get were the only route.

new-project.md gains a fourth question in the existing Round 2 AskUserQuestion
array (grouped with the other general-workflow-behavior toggles, not the
per-agent capability questions above it) and threads compact_content into the
config-new-project CLI JSON literal. settings.md mirrors the exact pattern
every other non-capability workflow.* key already follows: read_current bullet,
question block, update_config write, the safe-merge non-capability-keys list,
save_as_defaults, and the confirm summary table — seven edits, zero new
src/*.cts code, since Phase 1's merge logic is a generic passthrough. Its
success_criteria question-count ("24 settings") is bumped to 25 to match the
now-25-entry main AskUserQuestion batch.

settings-advanced.md deliberately does NOT get a duplicate question: no other
boolean toggle in this repo is asked in both settings.md and
settings-advanced.md, and there's no reason to start with this one.

docs/CONFIGURATION.md, docs/USER-GUIDE.md, and a new docs/features/4139-compact-
content.md fragment (regenerated into docs/FEATURES.md) document the toggle.

ADR-4139 itself: Status flips Proposed -> Accepted, the acceptance-criteria
section becomes a guard ledger — a 13-row table covering all 12 of #4139's
original checkboxes plus the shipped-content guard criterion, each with real
evidence (the merged PR that satisfied it, fetched via `gh issue view
--json closedByPullRequestsReferences` rather than asserted from phase
numbers) — and both "Open questions for the implementation phases" are
resolved rather than left dangling: discuss-phase was never converted to
spine+detail shape (verified: no detail/ subdir exists) — a genuine gap, not a
reasoned decline; the disjointness check is confirmed line-based by reading
compact-content-split.cjs's normalizeNonTrivialLines directly.

Orthogonal review (isolated Standards/Spec code-review + security-review
sub-agents) found and this fixes two real defects: the changeset fragment's
body didn't match CONTRIBUTING.md's single em-dash-sentence format (was
multi-sentence prose naming implementation file paths); and settings.md's own
success_criteria still said "24 settings" after the new question pushed the
main batch to 25. Also fixed, found by the Spec pass while confirming
commands/gsd/settings.md correctly needed no sync edit: that file and its
skills/gsd-settings/SKILL.md twin both still described "Interactive 5-question
prompt (model, research, plan_check, verifier, branching)", stale since long
before this phase (the batch has had far more than 5 questions for a while) —
replaced with a description that names the current set without hardcoding a
count that will drift again.

gsd-test (real run, sha 1da78fe2) caught a third real regression the local
sweep missed: new-project.md is a registered spine+detail split for Phase 4's
token-reduction benchmark (scripts/benchmark-compact-content.cjs), and the new
question's +167 tokens drifted the committed baseline
(tests/fixtures/compact-content-benchmark-baseline.json). The benchmark itself
is designed never to fail CI on drift, but the test asserting the COMMITTED
baseline is currently non-drifted correctly caught it. Regenerated via
`node scripts/benchmark-compact-content.cjs --write`; re-verified --check now
reports "up to date" and the test file passes 27/27.

Closes #4408.
Closes #4139.

Emitted-Drift-Ack-Growth: new-project.md — new 4th Round-2 AskUserQuestion entry (Compact Content, #4139) plus the config-new-project CLI JSON field and explanatory sentence; a new opt-in toggle needs new prose.
Emitted-Drift-Ack-Growth: settings.md — new workflow.compact_content read_current bullet, question block, update_config write, safe-merge key, save_as_defaults field, and confirm summary row (the same seven-edit pattern every other non-capability workflow.* toggle already follows), plus the 24->25 success_criteria count fix found in review.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4408): backfill changeset PR number

pr:0 -> pr:4587 now that gh pr create has returned the real number.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 00:04:51 -04:00
Tom Boucher
37b965c0d1 enhance(#4139): Phase 7 — the agent-skill seam picks the payload in code (#4553)
* enhance(#4139): Phase 7 — the agent-skill seam picks the payload in code

ADR-4139 stream 2. The non-Claude `#2454` persona fallback in cmdAgentSkills
(src/init.cts) now selects between a canonical agents/<name>.md and a
token-minimized agents/<name>.compact.md sibling based on
workflow.compact_content, resolved in code (a real function call with a real
exit code) rather than a prose config-get gate — the same precedent stream 1's
spine/detail split established for a load-bearing seam, applied here because
this seam already runs through TypeScript instead of an eager @-include.

A missing compact sibling falls back to the canonical persona and discloses
the fallback in the served payload itself (a leading HTML-comment provenance
line), so the Done-when contract — compact when on, canonical when off, never
silent or empty — holds even for an agent nobody has compacted yet.

Authored a .compact.md sibling for all 35 shipped agents (agents/gsd-*.md),
each an independent, complete rewrite (not an extraction — nothing is "moved"
the way spine/detail moves text) that preserves frontmatter, every @-include,
every output-format contract, and every guardrail verbatim while cutting
restatement and verbose framing. Verified mechanically: every pair registers
(a canonical sibling exists), every compact file is strictly smaller, and the
full @-include set matches canonical's — including which references are
standalone eager-load lines versus inline prose mentions, since demoting one
to inline changes what the host actually substitutes.

Traced the install path before writing any code (.gsd/phase/.../40-design.md):
stageAgentsForRuntimeWithConverter glob-copies every agents/*.md file with no
stem filtering under the default full profile, so the new .compact.md files
install for free with zero installer changes — matching issue #4407's stated
scope. A tiered agent profile that doesn't stage a compact sibling degrades
through the same fallback-with-provenance path already required for an
unauthored one, so no installer change is needed there either.

Extends tests/helpers/compact-content-variant.cjs with an AGENTS_ROOT export
(deliberately not folded into DEFAULT_VARIANT_ROOTS, since agent variants are
reached by a generic code construction rather than a literal path in prose,
and checkReachability's markdown-search shape has nothing to find there).
Reachability is instead proven behaviorally: tests/agent-skills.test.cjs's new
"#4407 compact payload selection" describe block spawns gsd_run agent-skills
against real compact/canonical fixture pairs and asserts on the served
payload, which can only pass if the seam genuinely wires through.

Fixed a pre-existing test whose agents/*.md glob incidentally matched the new
.compact.md siblings (tests/agent-skills.test.cjs's Skill-frontmatter drift
guard) and added the 35 new agents/*.compact.md entries to docs/INVENTORY.md's
roster, both real, unrelated-to-content defects the new files' mere existence
surfaced.

Regenerated: install-tree fixtures (19 runtimes now ship 35 more agent files
under the full profile), INVENTORY-MANIFEST.json, and the variant-swap token
benchmark baseline (npm run benchmark:compact-content-variants --write).

Closes #4407.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4407): apply orthogonal review findings from the compact-payload seam

Standards axis of /code-review: extracted readNonEmptyFileOrNull(filePath)
to collapse the duplicated read-and-empty-check shape between the compact
and canonical branches in cmdAgentSkills, and updated the adjacent comment
enumerating flat JSON extras to name agent_payload_variant alongside
source/degraded (added by the prior commit, comment left stale).

Security review and the Spec axis found no defects requiring a code change;
their non-blocking observations (a pre-existing, unmodified path-construction
pattern; the reasoned, documented substitution of a behavioral test for the
literal reachability check) are recorded in
.gsd/phase/enhance-4407-agent-skill-seam/60-review.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4407): repo-wide roster/cap fixes surfaced by shipping .compact.md agents

Root-caused via a real gsd-test run (93 failures) rather than guessing which
tests glob agents/ naively. Two classes of defect, both genuine:

1. Identity-roster confusion (11 files/areas): many tests and one production
   script derive "the set of GSD agents" from `readdirSync(agentsDir).filter(f
   => f.endsWith('.md'))`, which incidentally matched the new .compact.md
   variant siblings too — a compact file is a rendering of an EXISTING agent
   identity, not a new one. Fixed at the shared root
   (tests/helpers/agent-roster.cjs's listAgentFiles, which several tests
   already consolidated on) and at each independent glob that didn't use it:
   agent-size-budget.test.cjs (tier-cap lookup now strips the .compact suffix
   before checking XL/LARGE membership, so a compact file inherits its
   canonical sibling's tier instead of silently falling through to DEFAULT),
   agent-skills-bootstrap.test.cjs, check-contract-drift.test.cjs (the actual
   script, not just its test), codex-config.test.cjs (confirmed directly
   against generateCodexAgentToml that a compact role's derived sandbox_mode
   is byte-identical to its canonical sibling's before excluding it — not
   assumed), and copilot-install.test.cjs (two counts that legitimately DO
   need both files — an installed-file count and a full-conversion smoke test
   — fixed to expect 70, not stay pinned to 35).

   no-bare-gsd-tools-command-position.test.cjs needed the opposite kind of fix:
   two compact files reproduce descriptive prose already allowlisted at their
   canonical file's line number; added matching entries at the compact files'
   own line numbers rather than excluding them from the scan (a genuine bare
   gsd-tools command-position bug in a compact file would be as real a defect
   as in canonical).

2. A hard, non-ackable cap (found via emitted-attribution.test.cjs's real-tree
   run): six agents' compact renditions (gsd-debugger, gsd-executor,
   gsd-phase-researcher, gsd-plan-checker, gsd-planner, gsd-verifier) exceed
   the 32,768-byte NEW_FILE_CAP (ADR-1610) even after aggressive compaction —
   confirmed structural, not a compaction-quality gap: each is dominated by
   content this phase's own rules require verbatim (the ~2.6 KB gsd_run
   bootstrap preamble runtime-launcher-parity.test.cjs requires inlined in
   every agent that calls gsd_run, output-format contracts, guardrails).
   ADR-4139's prescribed remedy (spine + lazily-read parts) has no landing
   spot in cmdAgentSkills's single-file synchronous read. Removed these 6
   compact files rather than ship an over-cap file or invent a multi-part
   read mechanism out of scope for this phase; recorded by name with the
   reason in .gsd/phase/enhance-4407-agent-skill-seam/40-design.md and
   50-test-matrix.md, per #4407's own "or explicitly recorded as not worth
   covering" allowance. Their canonical personas are served correctly today
   via the fallback-with-disclosed-provenance path this phase's own Done-when
   #2 already requires — 29 of 35 agents now have a compact variant.

Also fixes an unrelated, genuinely pre-existing defect this gsd-test run
surfaced: gsd-core/workflows/execute-plan.md sat 21 bytes over its own
DEFAULT-tier hard cap (40,960 bytes) at the branch point, before any change in
this PR touched it — confirmed via `git show <merge-base>:...execute-plan.md
| wc -c`. Per CLAUDE.md's no-deferral rule, fixed inline rather than filed:
two meaning-preserving trims in the <success_criteria> block (a repeated
parenthetical replaced with a same-exception reference; one redundant
qualifier dropped) bring it to 40,940 bytes.

Regenerated install-tree fixtures, INVENTORY-MANIFEST.json, and the variant
benchmark baseline to reflect the 6 removed files. Docs/INVENTORY.md's 6
now-orphaned roster rows removed alongside them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4407): make .compact.md-aware roster checks resilient to partial coverage

Round 2 of the gsd-test-driven roster fixes: two checks assumed every agent
has a compact sibling (true for 29 of 35 after the NEW_FILE_CAP exception),
breaking once 6 stems legitimately have none.

- tests/agent-classification-parity.test.cjs: the INVENTORY.md parser was
  picking up the "### Compact Payload Variants" subsection's rows as
  phantom/uncounted entries in the primary/advanced/inventory-only
  classification this test validates — a compact row documents an existing
  agent's alternate rendition and never gets its own AGENTS.md heading, so it
  was never meant to participate in that classification. Excluded at the
  parser, not per-assertion.
- tests/copilot-install.test.cjs: the derived expected-file-list generator
  assumed every listAgentFiles() stem has a .compact.md source sibling;
  checks disk per stem now instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4407): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:38:59 -04:00
sim
8b5e347377 chore: regenerate golden install-tree fixtures for the new lib file
scripts/lib/npm-version-check-diagnosis.cjs (added in the previous
commit) is a new shipped file under the "scripts" files-glob, so it
needs to appear in every runtime's golden install-tree fixture.
Confirmed by gsd-test: 23 tests/golden-install-tree.test.cjs failures,
one per runtime, each showing the same single added path. Ran
npm run gen:install-tree; diff is exactly one line per fixture file,
matching the new file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
Tom Boucher
a27cb6b2fa enhance(#4139): Phase 6 — the lazily-read remainder and the artifact templates (#4540)
* enhance(#4406): the lazily-read remainder and the artifact templates

ADR-4139 Decision 3, Phase 6 of the #4139 Compact Content epic. Covers stream 1b
(gsd-core/workflows/<name>/{modes,steps,templates}/*.md) and stream 4
(gsd-core/templates/**) with a variant-swap mechanism, confirmed with the user:
two independent, complete files per covered path (canonical + .compact.md
sibling), with the gate picking which one gets Read at the call site. This is
a different shape from Phase 5's spine+detail partition, and is safe here
specifically because these files are already reached only by a runtime Read —
a missed Read already means zero overlay content today, with or without
workflow.compact_content, so selecting between two independently-complete
files introduces no new failure mode (documented in
gsd-core/references/compact-content-gate.md's new "Streams 1b and 4" section).

Disposition, after inspecting every candidate rather than trusting a byte-size
threshold (same rigor Phase 5 applied to review.md):

- Stream 1b: 1 of 78 files compacted (help/modes/full.md, a user-facing
  reference doc emitted verbatim, not orchestrator instruction). The other 9
  size-threshold candidates are dominated by fail-closed guards, exact CLI
  invocations, or output-format contracts (AskUserQuestion blocks) — recorded
  not-worth-compacting, same reasoning as Phase 5's review.md.
- Stream 4: a ground-truth reachability audit replaced the initial size-only
  candidate list. Two files (summary.md, user-setup.md) got compact variants;
  a third (spec.md) was drafted, then dropped after discovering its only two
  call sites are eager @-includes, not a runtime Read — stream-1 material
  hiding under gsd-core/templates/, not stream-4's actual mechanism. summary.md
  itself has 3 eager call sites and only 1 genuine runtime-Read call site
  (execute-plan.md); only that one was wired, so the compact variant's savings
  apply to the sequential single-plan execution path only.
- Discovered while auditing reachability: 12 gsd-core/templates/** files with
  zero references anywhere in workflow/agent/command prose, compiled source,
  or tests — dead scaffolding predating this phase. Deleted in this same PR
  per this repo's no-defer policy, after re-verifying against a computed
  path.join(...) pattern (not just a plain-string search) that nearly caused
  two genuinely load-bearing templates (user-profile.md, dev-preferences.md)
  to be misclassified as dead.

New checker (tests/helpers/compact-content-variant.cjs): registration,
reachability, protected-content-preserved, size-smaller — replacing Phase
3/5's disjointness/completeness checks, which assume a partition rather than
two deliberately-overlapping documents. The reachability check's own
"unprefixed match" guard had a real bug (rejected the repo's own
`~/.claude/gsd-core/...` convention), caught by running it against the
already-wired help/modes/full.compact.md pair rather than only synthetic
fixtures — fixed to anchor on the nearest `gsd-core` path segment instead.

Template consumer parity (tests/compact-content-template-variant-parity.test.cjs):
proves each compact variant's `## File Template` fenced block — the actual
output-format contract a generated SUMMARY.md/USER-SETUP.md is parsed
against — is byte-identical to the canonical file, then runs the one real
deterministic consumer (gsd-core/bin/lib/coverage.cjs's classifyContent,
backing `gsd-tools uat classify-coverage`) against content built from that
shared contract.

Added a sibling benchmark script (scripts/benchmark-compact-content-variants.cjs)
rather than extending the existing spine/detail one — different data shape,
and the existing script's own contract deliberately isolates it from a
test-only helper's shape changing.

Emitted-drift acknowledgement: not needed. Every changed/added path in this
diff is hand-authored and present in the diff itself, so diffEmitted's
attribution loop resolves `via` to the path's own source before reaching the
ack-lookup branch (same reasoning Phase 5 verified for its own diff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* enhance(#4406): address code-review findings on the variant-swap gate

- docs/CONFIGURATION.md and gsd-core/references/planning-config.md's
  workflow.compact_content entries described only the spine+detail mechanism
  (Phase 5) and were missing this phase's variant-swap mechanism and its
  benchmark:compact-content-variants script entirely — required since this
  PR's changeset is type Added (CLAUDE.md's "Missing Docs for Changesets"
  rule). Both now describe both mechanisms and which call sites are wired.
- Added the missing RED^-1/no-op fixture for checkProtectedContentPreserved:
  a canonical file with zero <!-- gsd:protected --> blocks must be a
  no-op, not a violation — the only branch of that function the existing
  fixtures didn't exercise.
- Collapsed findCompactFiles/findMarkdownFiles in
  tests/helpers/compact-content-variant.cjs into one findFilesWithSuffix
  helper — the two were identical recursive walks differing only in the
  extension predicate (minor Duplicated-Code finding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): restore copilot-instructions.md, a false-positive dead-template classification

gsd-test caught this, not static analysis: 10 real failures in
tests/copilot-install.test.cjs, tests/installer-migration-install.integration.test.cjs,
and tests/repo-layout.test.cjs — all downstream of bin/install.js's Copilot install
path, which does
fs.readFileSync(path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'))
after copying gsd-core/templates/** into the target project, then merges it into both
.github/copilot-instructions.md and (local installs) AGENTS.md. The reachability audit
that flagged this file as dead checked src/*.cts and gsd-core/bin/*.cjs but never the
repo-root bin/install.js — a separately maintained installer bundle outside the
src/-to-gsd-core/bin/lib/ compiled-output convention. The fs.existsSync guard around
that read degrades to a silent skip rather than a crash when the template is missing,
which is why this surfaced only once the real E2E install test ran, not from any
static check.

Re-verified the remaining 11 deleted filenames against bin/install.js specifically
(plain substring and quoted-filename search) before trusting that list — all 11 have
zero hits there, confirmed dead by the same standard this one file failed.

Regenerated the installer emitted-tree goldens (tests/fixtures/install-tree/*.json) to
reflect the restored file, and corrected the "Removed" changeset (jolly-lynx-sprint.md)
and the phase design doc from 12 to 11 deleted files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: execute-plan.md — call-site wiring for the summary.md and user-setup.md .compact.md variants
Emitted-Drift-Ack-Growth: help.md — call-site wiring for full.compact.md, same variant-resolution rule

* docs(#4406): backfill changeset PR numbers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): resolve removed-but-needed lint findings on the dead-template deletion

CI's own full-test matrix (not gsd-test's matrix, which does not run this
check) caught 4 more false-positive dead-template classifications via
tests/removed-but-needed-lint.test.cjs / scripts/lint-removed-but-needed.cjs
— a literal, word-boundary basename check across .github/workflows/,
gsd-core/, and docs/ (excluding docs/adr/** and docs/research/**) for every
file a PR deletes. It has no semantic awareness, so a deleted template's
basename colliding with something else entirely still fires:

- claude-md.md: gsd-core/templates/README.md had a stale table row claiming
  /gsd-profile reads this template to generate CLAUDE.md. Verified false (no
  code reads it anywhere, same search that already covered bin/install.js) —
  fixed the row to *(inline)*, matching every other command-generated
  artifact in that table. File stays deleted.
- codebase/testing.md: collided with docs/guides/testing.md, an illustrative
  example row in docs-update.md's sample output table (an unrelated real
  generated-docs path). Swapped the example topic to "contributing" — the
  row is illustrative, any topic works. File stays deleted.
- codebase/architecture.md, codebase/stack.md: collided with docs/reference/
  planning-artifacts.md's directory listing of a user's own generated
  .planning/codebase/architecture.md and stack.md output — the same
  semantic mismatch already investigated and dismissed as unrelated earlier
  in this phase's audit, now caught by a gate instead of judgment. That
  listing repeats across 5 locale copies of the doc.
- continue-here.md: collided with the real .continue-here.md pause-work
  artifact, referenced across 15+ locale and workflow files.

For the last two, the lint's own error message offers "restore the file or
update every consumer in the same commit." Rewording 15+ files across
languages I cannot verify translation quality for, to shave 2 already-tiny
templates that were merely presumed dead, is disproportionate to this PR's
actual scope — restored codebase/architecture.md, codebase/stack.md, and
continue-here.md instead, and corrected docs/ARCHITECTURE.md's Templates
section accordingly.

Final confirmed-dead set: claude-md.md, codebase/concerns.md,
codebase/conventions.md, codebase/integrations.md, codebase/structure.md,
codebase/testing.md, debug-subagent-prompt.md, discovery.md — 8 files, down
from the original 12. Verified locally: GSD_REMOVED_BUT_NEEDED_BASE=next
node scripts/lint-removed-but-needed.cjs now passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: docs-update.md — swapped an illustrative example-table topic (testing -> contributing) to avoid a removed-but-needed basename collision with the deleted codebase/testing.md template; net +10 bytes

* fix(#4406): split codex-config.test.cjs to fix a genuine Windows CI timeout

Root cause of the `full test (windows-latest, 24, shard 2/3)` failure the
user asked to be actually fixed, not just re-run past: PR #4497 (landed
2026-09-07, one day before this PR's CI run) isolated
tests/codex-config.test.cjs into its own dedicated chunk because its
measured weight (17.87, ~45% of the post-cut Windows budget) made it unsafe
to share a chunk with any other file. That isolation was necessary but not
sufficient — even alone, with zero companion-file contention, the file's
real Windows execution time sits right at the 600s per-chunk ceiling. Two
independent CI runs on two unrelated PRs (this one and #4154) were both
killed within ~1.4s of the identical 600000ms mark — not random contention,
a deterministic near-miss the isolation fix couldn't address because it
never reduced the file's own cost, only removed the risk of a companion
file's cost stacking on top of it (which the PR #4497 comment explicitly
anticipated: "if a future profiling pass genuinely speeds up
codex-config.test.cjs itself, this isolation can be revisited").

The file itself explains why it's this heavy: 11,262 lines / 433 tests / 79
describe blocks, accumulated over dozens of bug-fix PRs (#2695, #2760,
#3245, #3285, #3346, #3426, #3427, #3562, #3566, #3582, #3808, and more),
several of which are explicitly documented as "folded" in from separate
files that were never actually split back out ("Verified non-duplicate
against both the pre-existing target and the other three folded sources").

Split into 4 files by top-level AST statement boundaries (never a naive
column-0 regex — an early attempt at that overcounted 79 apparent
"describe(" matches when only 21 are genuinely top-level; the rest are
nested inside a handful of large folded-in blocks, which a regex can't tell
apart from real top-level statements). Verified lossless twice: the split
script asserts byte-for-byte reconstruction of every source character, and
independently, total test()/describe() call counts match exactly between
the original file and the sum across all 4 new files (433/79 both sides).
Each new file carries the complete original shared header (imports/helpers)
for safety; per-file unused-import warnings from that duplication are
resolved via ESLint-precise alias renames (`{ foo: _foo }`, the standard
form for an intentionally-unused destructured binding — never a bare `{
_foo }`, which would destructure a different, nonexistent property).

No change needed to scripts/run-tests.cjs's ISOLATED_HEAVY_FILES or its
pinned test in tests/run-tests-harness.test.cjs: the file that keeps the
original name (tests/codex-config.test.cjs) is now only ~28% of the
original's size and safely isolated in its own chunk as before; the other
three new files re-enter normal weight-balanced packing, none individually
close to disproportionate. Confirmed no other file hardcodes the hardcoded
filename anywhere that would silently stop these tests from running (the
CI test-selection scripts determine scope algorithmically, not by literal
filename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:31:04 -04:00
Tom Boucher
511c900052 fix(#4458): reuse detectSubRepos for new-project.md's sub-repo detection (#4548)
* fix(#4458): reuse detectSubRepos for new-project.md's sub-repo detection

new-project.md's Step 5.1 (Sub-Repo Detection) ran its own bash predicate:

  find . -maxdepth 1 -type d -not -name ".*" -not -name "node_modules" \
    -exec test -d "{}/.git" \; -print

`test -d` requires .git to be a DIRECTORY. A linked git worktree's .git is
a FILE (a `gitdir: <path>` pointer), so this predicate silently excluded
valid linked-worktree children while still finding ordinary clones.

src/core-utils.cts's detectSubRepos(cwd) already handles this correctly
(fs.existsSync, type-agnostic) but had zero callers anywhere in the
codebase -- orphaned logic the workflow never actually used, despite
duplicating a narrower version of the same check inline.

Wired detectSubRepos into cmdInitNewProject's JSON output as a new
sub_repos_detected field (matching the file's existing pattern of
similar directory-scan-derived fields like has_existing_code/
is_brownfield/has_codebase_map) and replaced the workflow's raw find
fence with a gsd_run query init.new-project call reading that field --
removing the duplicate, narrower detection logic entirely rather than
patching it in place, per the issue's own "reuse a central policy"
framing.

Added the missing .git-as-FILE test case to the existing
tests/core-utils.test.cjs detectSubRepos coverage (proving the helper
was already correct -- the defect was entirely in the unwired workflow
predicate) plus CLI-level end-to-end coverage in
tests/init-manager.test.cjs using a REAL `git worktree add` fixture,
matching the issue's own reproduction steps, alongside an ordinary
child-clone case and a non-repository-directory negative case.

Refreshed tests/fixtures/compact-content-benchmark-baseline.json (gsd-test
caught the drift from new-project.md's byte-count change; the benchmark
script itself always exits 0 -- report, not gate -- but the wrapper test
enforces the committed baseline stays in sync).

Emitted-Drift-Ack-Growth: new-project.md — #4458 replaces the raw find predicate in Step 5.1 with a gsd_run query call reading the new sub_repos_detected field, net +140 bytes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4458): backfill changeset PR number

pr: 0 -> pr: 4548

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4458): bound the new git worktree add spawn with a named timeout

CI's lint-tests caught two ESLint findings my local gsd-test run
couldn't see (gsd-test's matrix doesn't run npm run lint:ci -- same gap
already observed on #4456's PR):

- local/no-unbounded-spawn: the new execFileSync('git', ['worktree',
  'add', ...]) call had no timeout, an indefinite-hang risk.
- local/no-adhoc-timeout-literal: my first fix (a bare `timeout: 15_000`
  literal) was itself flagged -- two independent hardcoded copies of a
  guessed timeout can silently drift or collide (this repo hit exactly
  that on 2026-09-06, PR #4428).

Fixed by importing GIT_FIXTURE_TIMEOUT_MS from tests/helpers/timeouts.cjs
-- `git worktree add` checks out files into a new working tree, the same
"construction" weight class as init/config/add/commit that constant
already covers, not plain plumbing (GIT_TIMEOUT_MS's class).

Verified via `npm run lint` directly (clean) before re-running gsd-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4458): reduce redundant git subprocess overhead in new tests

CI's full-test Windows shard 2/3 failed: chunk 1/9 (306 files) exceeded
its internal 600s budget and was force-killed, with an unrelated file
(codex-config.test.cjs) in flight at the moment of the kill -- meaning
the chunk's AGGREGATE runtime, not any single hang, blew the budget.

This PR's own three new tests each independently called
createTempGitProject() (git init + a commit), and one of them also runs
git worktree add -- real subprocess spawns, each Defender-scanned on
Windows CI (tests/helpers/timeouts.cjs's own documented rationale for
why Windows spawn classes get generous budgets). That's a genuine,
quantifiable overhead addition to the exact chunk that timed out, not
something to wave off as unrelated flake without checking.

Two of the three tests never actually needed a real git repo --
detectSubRepos only inspects a CHILD directory's own .git, never the
root's git state, and the existing SUBCOMMANDS loop earlier in this same
file already proves `init new-project` succeeds against a plain,
non-git createTempProject() fixture. Switched those two tests to the
lighter fixture, leaving only the one test that genuinely needs a real
repo (git worktree add requires one) on createTempGitProject().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:39:34 -04:00
Tom Boucher
c6df4e1e46 fix(#4455): autonomous.md and complete-milestone.md resolve STATE/ROADMAP/MILESTONES/PROJECT/REQUIREMENTS through the workstream-scoped init fields (#4542)
* fix(#4455): thread workstream-scoped paths through autonomous and complete-milestone workflows

autonomous.md and complete-milestone.md read/wrote hardcoded literal
`.planning/STATE.md` / `.planning/ROADMAP.md` / `.planning/milestones/...`
paths in their shell fences, bypassing workstream scoping entirely. With
GSD_WORKSTREAM=alpha set, planningDir(cwd) correctly resolves into
workstreams/alpha/, but a literal `cat .planning/STATE.md` still read the
ROOT file (or silently returned empty if root state was absent) --
reproduced deterministically in the issue's own repro.

Root cause: each workflow step's bash fence is a separate shell
invocation, and cmdInitManager/cmdInitCompleteMilestone's JSON payloads
never carried resolved state_path/roadmap_path/archive_dir fields for the
workflows to extract -- unlike cmdInitPlanPhase, which already does this
correctly and is the pattern this fix mirrors.

- src/init.cts: cmdInitManager and cmdInitCompleteMilestone now emit
  state_path/roadmap_path (workstream-scoped via planningDir(cwd),
  existence-checked, toPosixPath'd, null when absent -- identical to
  cmdInitPlanPhase's existing contract) and archive_dir (the milestone
  archive directory, composed the same way milestone.cts's already-correct
  archive helper does per #1911).
- autonomous.md: discover_phases and iterate now extract state_path via
  the already-fetched INIT_MANAGER payload instead of hardcoding
  `.planning/STATE.md`; iterate's second, previously-separate hardcoded
  read is folded into the same fence (no double-fetch); lifecycle step 5b
  checks the resolved archive_dir instead of a hardcoded milestones path.
- complete-milestone.md's reorganize_roadmap_and_delete_originals step
  (which previously called no init command at all) now fetches
  init.complete-milestone and uses the resolved roadmap_path/state_path/
  archive_dir for the backlog read, the write-guard sentinel's armed
  content, the Write-tool target for the reorganized ROADMAP.md (the
  sentinel fence now echoes the resolved path so the executing agent can
  see it), and the safety-commit --files list. `.planning/MILESTONES.md`
  and `.planning/PROJECT.md` stay literal root paths -- documented shared
  files, per the issue's explicit "not a blanket replacement" scope.

Regression tests extract and execute the real bash fences (with a stubbed
gsd_run) rather than string-matching the markdown, covering flat mode
(unaffected), an active workstream (the issue's own repro shape, now
correctly resolving), the no-double-fetch requirement, and a dedicated
guard locking MILESTONES.md/PROJECT.md as shared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4455): add changeset for workstream-scoped autonomous/complete-milestone fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4455): close write-guard gap on workstream-scoped curated paths

Isolated security review of the #4455 fix (workstream-scoped STATE/
ROADMAP/milestone-archive path resolution in autonomous.md and
complete-milestone.md) flagged that hooks/gsd-write-guard.js's
CURATED_PATTERNS only matched root-level .planning/ paths, never
.planning/[<project>/]workstreams/<ws>/... — meaning the catastrophic-
shrink guard silently never engaged for a workstream-scoped write.
This is directly relevant here: the #4455 change makes a workstream-
scoped ROADMAP.md Write reachable via complete-milestone.md's own
explicit sentinel-hatch instructions, which assume guard protection
that did not actually exist for that path shape. Extended
CURATED_PATTERNS with the three workstream-scoped equivalents;
consumeSentinelFor's own path-derivation logic needed no change since
it derives from the actual write target. Verified empirically (a
293->16 line workstream ROADMAP.md shrink now correctly returns
exit 2 / decision:"block") and with 5 new regression tests.

Also addressed a code-review nit on the core #4455 fix:
cmdInitCompleteMilestone called planningDir(cwd) three separate
times instead of caching it once.

Accepted as-is (not fixed): complete-milestone.md's
reorganize_roadmap_and_delete_originals step re-fetches
`gsd_run query init.complete-milestone` three times across its
fences rather than merging the first two (no state-changing Write
between them, unlike autonomous.md's iterate step which does merge).
This is an efficiency nit, not a correctness bug — merging risks
disrupting the step's prose flow and its existing binding test for a
non-functional gain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4455): add changeset for the write-guard workstream-scope fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4455): fix gsd-test-surfaced regressions from workstream-path fix

Running gsd-test against the full #4455 diff (including the write-guard
security fix and the cmdInitCompleteMilestone caching nit) surfaced four
real, non-flaky failures, all direct consequences of editing
gsd-core/workflows/autonomous.md and complete-milestone.md:

1. tests/autonomous-converge.test.cjs pinned the OLD hardcoded
   `STATE_CONTENT=$(cat .planning/STATE.md ...)` read in both
   discover_phases and iterate. That is exactly the literal-path
   behavior #4455 fixes, so the test needed updating to assert the new
   init.manager-resolved `STATE_PATH` read instead (with an explicit
   doesNotMatch guard against regressing to the old literal).

2. tests/workstream-scoped-paths.test.cjs's own "no-double-fetch" test
   counted gsd_run invocations via a shell variable incremented inside
   the stub function — but `INIT_MANAGER=$(gsd_run ...)` runs gsd_run
   inside the command-substitution SUBSHELL, so that increment never
   survives back to the parent shell and the counter always read 0.
   Switched to a file-based call log (one byte appended per call),
   which survives the subshell boundary.

3. tests/compact-content-partition-guard.test.cjs's disjointness check
   flagged the reorganize_roadmap_and_delete_originals step's new
   `INIT_CM=$(gsd_run query init.complete-milestone)` fetch (added 3x,
   per the accepted-as-is disposition in the prior commit) as
   byte-identical to a pre-existing, unrelated fetch already present in
   complete-milestone/detail/elaboration.md's handle_branches section
   (§2). Same idiom, same conventional variable name, coincidentally
   colliding across the spine/detail split boundary. Renamed the new
   step's local variable to INIT_REORG — a distinct, purpose-specific
   name is arguably better practice anyway for two logically unrelated
   fetches, and it removes the literal collision honestly rather than
   restructuring the split.

4. tests/benchmark-compact-content.test.cjs reported real byte-count
   drift in the committed baseline (autonomous.md and
   complete-milestone.md both grew from the #4455 content). Refreshed
   via `node scripts/benchmark-compact-content.cjs --write`.

Verified: node scripts/benchmark-compact-content.cjs --check now
reports the baseline up to date; a standalone invocation of
checkDisjointness() against the real repo state now reports zero
violations across all 6 registered splits; manual bash-fence execution
of both the autonomous.md iterate fence (call count = 1) and the
complete-milestone.md backlog fence (with INIT_REORG) confirms correct
behavior.

Emitted-Drift-Ack-Growth: autonomous.md — #4455 workstream-scoped STATE.md path resolution replaces hardcoded literal reads
Emitted-Drift-Ack-Growth: complete-milestone.md — #4455 workstream-scoped STATE/ROADMAP/archive path resolution replaces hardcoded literal reads
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4455): MILESTONES.md/PROJECT.md/REQUIREMENTS.md are workstream-scoped too, and so is project-only mode

Fresh isolated code-review and security-review passes against the full
diff (run after the previous gsd-test-surfaced fixups landed) each
found one real, confirmed defect:

Code review: the safety-commit `--files` list and the REQUIREMENTS.md
`git rm` step both hardcoded `.planning/MILESTONES.md`,
`.planning/PROJECT.md`, and `.planning/REQUIREMENTS.md` as literal
root paths — but src/milestone.cts's cmdMilestoneComplete writes
MILESTONES.md via `planningPaths(cwd).planning` (the workstream base)
and PROJECT.md/REQUIREMENTS.md resolve the same way through
`planningPaths().project`/`.requirements` (src/planning-workspace.cts).
Only `todos` is the documented root-scoped exception (#4256); an
earlier version of this fix wrongly generalized that exception to
MILESTONES.md/PROJECT.md too, and the now-corrected test previously
enshrined that wrong behavior as intended. Under an active workstream,
the safety commit would have silently missed the actual files
`milestone complete` just wrote, and the git-rm step would have
targeted the wrong (root) REQUIREMENTS.md entirely. Fixed by exposing
`milestones_path`/`project_path`/`requirements_path` from
init.complete-milestone (src/init.cts) and resolving all three through
them, the same pattern already used for state_path/roadmap_path/
archive_dir. The four remaining literal MILESTONES.md/PROJECT.md
mentions elsewhere in complete-milestone.md (lines ~12-13, ~441, ~607,
~662) are display-only prose in status/summary message templates, not
actual file operations — left as-is; they are a cosmetic path-display
inaccuracy under an active workstream, not a data-integrity bug like
the two fixed here.

Security review: confirmed the write-guard fix from the prior commit
is correct and complete for workstream scoping, and independently
surfaced the same project-only gap the code-review pass above also
caught structurally: `CURATED_PATTERNS` had no pattern for
`.planning/<project>/...` (GSD_PROJECT set, GSD_WORKSTREAM unset) —
planningDir(cwd) supports that shape independently of workstream
nesting, so it is reachable, not hypothetical. Fixed by adding three
more patterns, verified empirically (a project-scoped 292->16 line
ROADMAP.md shrink now correctly returns exit 2 / decision:"block")
and with 6 new regression tests.

Verified: manual bash-fence execution of the corrected commit-files
and requirements-rm fences (both flat mode and GSD_WORKSTREAM=alpha)
resolves to the right paths in both cases; a standalone invocation of
checkDisjointness() against the real repo state still reports zero
violations; the benchmark baseline was refreshed again for the further
size change (already covered by the existing Emitted-Drift-Ack-Growth
trailer on complete-milestone.md two commits back — that trailer is
read over the whole merge-base..HEAD range, not per-commit, so it
still applies here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4455): backfill changeset PR numbers and correct final scope

pr: 0 -> pr: 4542 for both fragments, and updated both bodies to
reflect the final fix scope (MILESTONES/PROJECT/REQUIREMENTS are
workstream-scoped too, not shared-root exceptions; the write-guard fix
also covers project-only scoping, not just workstream nesting).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4455): lifecycle-5b archive-path assertions use the fence's own separator, not path.join

PR CI's windows-latest shard 3/3 failed: "expected ls to find the root
archive file, got: ...\milestones-root/v1.0-ROADMAP.md". The
autonomous.md lifecycle step 5b fence composes the checked path with a
literal bash `/` (`"${ARCHIVE_DIR}/v${milestone_version}-ROADMAP.md"`),
which on Windows yields a MIXED-separator path — Windows backslashes
from archiveDir plus one trailing `/`. My test's assertion used
path.join(archiveDir, 'v1.0-ROADMAP.md') instead, which on a Windows
Node process produces an all-backslash path that never matches the
fence's mixed-separator output. Both assertions in that describe block
now mirror the fence's own literal `/` concatenation
(`${archiveDir}/v1.0-ROADMAP.md`) instead of path.join — matching the
style the other two describe blocks in this same file (safety-commit
--files list) already used correctly for the identical archive-dir
pattern, so this brings the one outlier into line rather than
introducing a new idiom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4455): write-guard sentinel comparison now realpath-resolves the token, not just the target

PR CI's macos-latest full-test shard 2/3 failed a #4455 test: "the
sentinel hatch ... unblocks a workstream ROADMAP.md write" got status
2 (still blocked) instead of 0.

Root cause, unrelated to the Windows fix in the previous commit:
hooks/gsd-write-guard.js's main flow realpath-resolves the Write
TARGET before the curated-pattern match (round 9 Minor 1's
symlink-before-match fix, `filePath = fs.realpathSync(filePath)`), but
consumeSentinelFor resolved the sentinel TOKEN's absolute path via
plain path.resolve() with no realpath step. On macOS, os.tmpdir()
resolves through a /var -> /private/var symlink, so a test's cwd
(lexically under /var/folders/...) and its realpath'd target
(/private/var/folders/...) diverge — an armed, correct sentinel then
never matches the realpath'd target string, and the guard stays
incorrectly blocked. This is not macOS-specific in principle: ANY cwd
sitting under a symlink (a symlinked project checkout, a symlinked
worktree) hits the same asymmetry — gsd-test's Linux bench runs never
caught it because /tmp there is not a symlink.

Fixed by applying the same fs.realpathSync (with the same
keep-lexical-on-failure fallback the caller already uses) to the
token's resolved path before comparing. The named file is already
known to exist at this point (the caller only reaches consumeSentinelFor
after successfully reading the target), so realpath is expected to
succeed in the legitimate case; a garbage/mismatched token still fails
safe (verified — falls back to the lexical path, still mismatches,
stays blocked).

Verified: reproduced the exact bug locally (macOS) via os.tmpdir()
before the fix, confirmed it resolves after; the negative case
(sentinel armed for a DIFFERENT file) still correctly blocks; the
pre-existing relative-token sentinel tests (predating #4455) still
pass; a garbage/non-existent token still fails safe. Added a
deterministic, cross-platform regression test using an explicit
symlink (skipped on Windows, matching the existing round-9 symlink
test's own skip condition) so this class of bug is caught by
gsd-test's Linux bench too, not only by a real macOS CI run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:56:11 -04:00
Tom Boucher
e03921c7d8 enhance(#4405): split the rest of the eager-window workflows worth splitting (#4536) 2026-09-08 00:17:22 -04:00
Tom Boucher
93e141a006 enhance(#4139): Phase 4 — measure the window instead of asserting it (#4502)
* enhance(#4404): add offline token benchmark for compact-content splits

ADR-4139 Decision 2 requires the finite-attention justification for
workflow.compact_content to be measured, not asserted. `npm run
benchmark:compact-content` computes, per registered spine/detail split
discovered under gsd-core/workflows/, the token count with the split
active (spine alone) vs inactive (spine + all detail parts read back
in), using gpt-tokenizer (pinned exact devDependency — Anthropic
publishes no tokenizer for Claude 3+, so every output surface labels
this a PROXY-TOKENIZER comparison: the on/off delta is exact under one
tokenizer applied identically to both sides, the absolute counts are
not Claude's real ones).

Reporting-only by design and verified so: --check diffs the live
recompute against a committed baseline (tests/fixtures/compact-content-benchmark-baseline.json)
and prints drift, but never exits non-zero for a drifted or missing
baseline — the only thing allowed to fail this script is a genuine I/O
error reading a source .md file it's measuring. Not wired into lint:ci
or pretest.

Discovery is deliberately reimplemented rather than importing
tests/helpers/compact-content-split.cjs (Phase 3, #4403), keeping a
scripts/ reporting tool from depending on a test-only module.

tests/fixtures/deny-network.cjs preloads via NODE_OPTIONS=--require to
prove the benchmark makes no network call, monkeypatching http/https/
net/dns/fetch to throw rather than relying on sandboxing.

docs/CONFIGURATION.md documents the new benchmark against the
workflow.compact_content key to satisfy this repo's docs-required gate
for an Added-type changeset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4404): address orthogonal review findings on the token benchmark

Standards axis found two hard violations against documented rules:
- CLAUDE.md's Generative Fix Divergence rule requires a parity assertion
  for shared discovery logic maintained in two places. Added a test
  comparing benchmark-compact-content.cjs's own discoverRegisteredSplits
  against tests/helpers/compact-content-split.cjs's version on the real
  repo tree, so the two can never silently drift apart.
- The changeset body closed its bold span with a period and continued
  as a second sentence, instead of the canonical
  `**<phrase>** — <explanation>.` shape CONTRIBUTING.md documents.

Spec axis found the "network disabled + identical output across two
runs" Done-when criterion was verified as two separate properties
(determinism tested without network denial, offline survival tested as
a single run) rather than as one combined property. Added a test that
runs the benchmark twice under the deny-network preload and asserts
byte-identical stdout.

Security axis found tests/fixtures/deny-network.cjs didn't patch
dns.promises (a separate binding from the callback dns API), tls.connect,
or http2.connect — inert today since nothing in the benchmark calls
them, but a silent gap in what the preload's own header claims to
guarantee. Patched all three.

CLAUDE.md's Property-Based Testing rule also requires a fast-check test
for budget-limit arithmetic; added one for computeAggregate's off/on
summation (true sum over N splits, never NaN/Infinity, never exceeds
100% when off >= on for every split).

Standards axis's remaining two findings (a Data Clumps observation on
the {offTokens, onTokens, reductionPct} triple, and mild duplication in
formatDriftReport's three line-formatters) are left as judgement calls:
introducing a named type for a 3-field local tuple, or a formatter
abstraction for three short lines, would be exactly the premature
abstraction CLAUDE.md's engineering guidance warns against for a script
this size.

All changes verified directly (parity logic, the fast-check property,
and the three newly-denied network surfaces actually throwing under the
preload) via node -e before committing; full npm run lint:ci passes
with the eslint cache cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4404): backfill changeset pr number to 4502

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:19:03 -04:00
Tom Boucher
a0f8f956c4 enhance(#4139): Phase 3 — partition rules + the five checks (#4497)
* enhance(#4139): Phase 3 — partition rules + the five checks

ADR-4139 Decision 5, epic #4139 Phase 3. Issue #4403's own "Proposed behavior"
section lists four checks; the ADR's Decision 5 and its own phase table ("partition
rules + the five checks") list five — the same four plus "boundary moves are
declared, ongoing". Same issue-vs-ADR drift Phase 2 hit on the detail.md vs
detail/*.md layout: the ADR is the locked, reviewed document, so it wins. This PR
implements all five.

docs/PARTITION-RULES.md (new) is the partition-rules document: the partition rule
itself, the protected-content list and <!-- gsd:protected --> sentinel syntax
(relocated unchanged from gsd-core/references/compact-content-protected-content.md,
now deleted — it was never referenced by any runtime workflow Read, only by the
predecessor test as documentation, so nothing at runtime regresses, and removing it
from gsd-core/references/ also drops it from all 19 installed-project shipped-content
trees for a file nothing ever read), and the five checks explained for a human
reader. Referenced from a new CONTRIBUTING.md subsection under "Editing shipped
content".

tests/helpers/compact-content-split.cjs (new) is the shared mechanics: split
discovery (any gsd-core/workflows/<name>/detail/*.md paired with <name>.md — no
registry file, a pair is registered by existing on disk), line normalization
(carries forward Phase 2's bare-label-line isTrivial fix and the canonical
gsd_run-launcher-preamble exclusion), sentinel extraction, and a
Boundary-Move-Declared commit-trailer reader that is a direct structural port of
tests/helpers/emitted-runtime.cjs's Emitted-Drift-Ack-Hash/-Growth trailer reader
(ADR-3942) — same merge-base range, same fail-closed throw on an uncomputable range,
same dedupe/conflict rules.

tests/compact-content-partition-guard.test.cjs (new) is the actual guard, superseding
tests/plan-phase-compact-split.test.cjs (deleted — its per-pair checks are now the
general guard's job for plan-phase specifically). Checks 2 (disjointness) and 3
(registration + size cap) run unconditionally against every registered split. Checks
1 (completeness, fires once per split on the PR that introduces a new detail/ path),
4 (protected content — no trailer can ever excuse this one, unlike check 5) and 5
(boundary moves declared) are PR-diff-scoped against the resolved base ref and skip
cleanly when there's nothing to compare (a fresh clone, no PR in flight) — a
deliberate asymmetry from check 5's trailer reader, which must throw rather than
silently pass when ITS range is uncomputable, since that function is answering "did
this PR declare its moves" rather than "is there even a diff to look at". Each of the
five checks carries a RED (deliberately broken fixture) / GREEN (fixed) test pair,
built against synthetic temp files or real throwaway git repos, per this repo's rule
that a guard nobody has seen go red is not yet a guard. Building the real fixtures
caught and fixed one real bug before it shipped: check 4's line-presence test was
using the trivial-line-filtered normalizer, so a byte-identical spine falsely
reported its own protected code-fence line as "deleted" — fixed with a
non-filtering membership check.

Extending docs/INVENTORY.md's "Workflow Sub-Files" table for `detail` surfaced a
pre-existing, unrelated gap in the SAME area: gsd-core/workflows/<name>/templates/*.md
is a fourth workflow sub-file kind that already existed on disk and was already known
to lint-response-language-coverage.cjs's FRAGMENT_DIRS, but was invisible to
gen-inventory-manifest.cjs and undocumented in that table. Fixed alongside it, same
pattern, same PR, rather than deferred.

Also, mechanically required by the new fourth sub-file kind:
- scripts/lint-response-language-coverage.cjs: `detail` added to FRAGMENT_DIRS
  alongside modes/steps/templates — a detail/<part>.md inherits its parent's
  response_language coverage through the same per-file proof, not a parallel one.
- tests/workflow-size-budget.test.cjs: explicit regression test locking that
  detail/ files are governed solely by the hard, non-waivable NEW_FILE_CAP
  (tests/helpers/emitted-diff.cjs) and never by the XL/LARGE/DEFAULT spine tiers —
  true by construction (measureWorkflows/listWorkflowStems don't recurse), made
  explicit per the issue's own Done-when item rather than left true-by-omission.
- scripts/gen-inventory-manifest.cjs: `workflow_detail` and `workflow_templates`
  NESTED_FAMILIES entries; docs/INVENTORY-MANIFEST.json regenerated
  (plan-phase/detail/elaboration.md, discuss-phase/templates/*.md now tracked);
  docs/INVENTORY.md's table updated to four kinds.

Verified: `npm run lint:ci` clean with the eslint cache cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4403): review findings + a real gsd-test failure in the new guard

Two orthogonal review passes (Standards + Spec, isolated sub-agents) plus a
separate security review ran against the prior commit. Fixed everything each
surfaced:

- Security (Low, path-traversal existence oracle): checkRegistration's
  dangling-reference check extracted detail-path-shaped substrings from spine
  PROSE via a regex that permits `.`/`/` freely, then joined them onto repoRoot
  and probed fs.existsSync with no containment check — a spine file containing
  `../../../etc/detail/passwd.md`-shaped text could make the guard test file
  existence outside the repo. Added a path.relative-based containment check
  before the fs.existsSync call; anything that resolves outside repoRoot is now
  reported as a dangling reference directly, never probed on disk.
- Standards (Boundary Coverage): the size-cap fixtures covered NEW_FILE_CAP and
  NEW_FILE_CAP-1 but not NEW_FILE_CAP+1 — added the third boundary-point case
  CLAUDE.md's TEST RULES require (limit-1/limit/limit+1).
- Standards (Property-Based Testing): extractProtectedBlocks (a sentinel
  parser) and the new parseBoundaryMoveTrailerValues (a declare/dedupe/conflict
  parser, bijective-shaped) had no fast-check property test. Added three: a
  render/parse bijectivity property for the trailer parser (mirroring the exact
  ADR-3942 sibling test's alphabet/idiom), a dedupe-is-idempotent property for
  the same parser, and a well-formed-sentinel-round-trips property for
  extractProtectedBlocks.

Then dispatched gsd-test on the resulting commit. It found a real bug the
reviews couldn't have caught (none of them can run inside gsd-test's sandbox):
checks 4/5's real-repo assertion failed against plan-phase's own split,
reporting DISK_PLANS/#3218-comment lines as "undeclared boundary moves" —
content Phase 2 (#4402) legitimately moved into detail/elaboration.md months
before this PR's Boundary-Move-Declared mechanism existed to require a
trailer for it. Root cause: `resolveBase()`'s own doc comment already documents
that no `origin/*` remote-tracking ref exists inside the gsd-test sandbox
container, and its fallback candidate (a bare `next` branch) can resolve to a
point in history that predates an already-merged, already-reviewed split —
making that split look "newly introduced" from the sandbox's vantage point.
Check 1 (completeness) already scopes itself correctly to only genuinely-new
detail paths (git diff status 'A'); checks 4 and 5 did not share that scoping,
so a stale base made them re-litigate a settled split retroactively. Fixed by
having checks 4/5 skip any split name check 1 already counted as newly-split —
their own premise ("did an EXISTING split shed/undeclare something") does not
apply to a split that is, from the resolved base's vantage point, brand new;
that is check 1's domain alone. Verified locally (25/25 tests pass via a
direct `node -e` require, since `node --test` is blocked in this repo) and via
re-reasoning through the exact real-repo scenario the gsd-test failure showed.

Also regenerated all 19 tests/fixtures/install-tree/*.json goldens — the
prior commit's deletion of gsd-core/references/compact-content-protected-content.md
was never reflected there, which is what golden-install-tree.test.cjs's other
19 failures in the same gsd-test run were.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4403): backfill changeset pr number to 4497

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4403): isolate codex-config.test.cjs into its own chunk, root-causing the Windows CI failure

PR #4497's "full test (windows-latest, 24, shard 2/3)" job failed: run-tests
killed chunk 3/8 at the 600s per-chunk backstop, with codex-config.test.cjs
(weight 17.87, by far the chunk's dominant cost) packed alongside 39 other
files. Traced, not assumed:

- scripts/run-tests.cjs's own timeout-headroom comment for the OUTER
  per-shard timeout documents that "adding one test file reshuffled 115 of
  268 unit files between shards" — shard/chunk composition is architecturally
  known to be unstable to single-file additions, which is exactly what this
  PR's own new tests/compact-content-partition-guard.test.cjs is.
- A second comment, dated 2026-09-06 (one day before this PR, PR #4428's own
  CI), already documents the SAME chunk hitting the SAME 600s backstop with
  the SAME file (codex-config.test.cjs, "a genuinely MEASURED weight of
  17.87 — not a stale-table miss") dominating it — the fix then was cutting
  the Windows per-chunk budget from 60 to 40. That cut clearly was not
  enough: two documented incidents in two days, at two different budget
  settings, both centered on one file that alone consumes ~45% of even the
  reduced Windows budget.
- tests/test-timings.json's own header confirms its source data
  (test-events-linux-node22/24.jsonl) is Linux-only, and run-tests.cjs's own
  chunk-timeout diagnostic already prints "real Windows cost runs ~2.2x the
  recorded figure" — the packer's weight-balancing is working off data that
  is both stale (table last regenerated 2026-08-07) and known to
  underestimate the platform where the failure occurs.

Given codex-config.test.cjs is disproportionately heavy AND every companion
sharing its chunk is decided by a packing algorithm already documented as
reshuffling unpredictably on any new file, tuning the shared budget a third
time only moves the marginal line to wherever the next new file happens to
land — it does not remove the gamble. Isolating codex-config.test.cjs into
its own dedicated single-file chunk, unconditionally and on every platform,
removes it at the source: the file never enters the pool packChunks balances,
so no other file's packing changes, and no future single-file addition
(mine or anyone else's) can silently reintroduce this exact failure by
landing in its chunk.

Extracted as a small pure function, partitionIsolatedFiles (mirroring this
file's existing pattern of pulling packing/analysis logic out of main() for
in-process unit coverage — see computeSweepProtectSet, analyzeChunkEvents),
with 6 new tests in tests/run-tests-harness.test.cjs covering basename
matching across path separators, near-miss non-matches, the empty-list case,
and the isolated-set contents.

Root cause is now closed rather than papered over with a retry: this failure
is a property of one specific heavy file's chunk placement, not something
that recurs randomly. If codex-config.test.cjs itself is ever genuinely sped
up, this isolation can be revisited — this is a packing-side mitigation for
a known file's cost, not a claim the cost is irreducible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:44:53 -04:00
Tom Boucher
8b7a0b696b enhance(#4139): Phase 2 — one shared gate, one pilot split, one accuracy spot-check (#4471)
* enhance(#4402): split plan-phase into a spine + detail, add the shared compact-content gate

ADR-4139 Decisions 3-5, Phase 2 of the #4139 Compact Content epic. Pilot
split for plan-phase.md, the largest of the 58 eagerly-@-included workflow
files (98,290 bytes): the spine keeps every happy-path step, every
protected-content block (planner/checker prompt templates, quality gates,
the failing-direction few-shot example, the two ScheduleWakeup guardrail
paragraphs — each marked with a <!-- gsd:protected --> sentinel), and
condensed one-paragraph summaries of five rare/opt-in fallback paths
(planner and checker filesystem-hang recovery, phase-split recommendation,
source-audit gaps, the thinking-partner conditional, and plan bounce). The
full text of those five moves verbatim to gsd-core/workflows/plan-phase/detail.md
(9.9KB, well under the 32,768-byte NEW_FILE_CAP), read by the spine only
when workflow.compact_content is false (the default) — the exact same
resolution rule now stated once in the new shared
gsd-core/references/compact-content-gate.md, which every future split
references instead of restating.

Verified mechanically (tests/plan-phase-compact-split.test.cjs, scoped to
this one split — Phase 3/#4403 owns the generalized guard): the union of
spine + detail contains every non-trivial line the parent commit carried
(0 missing), no non-trivial line is duplicated between them (0 duplicated),
and every declared protected block is well-formed and non-empty. The spine
shrinks from 98,290 to 93,206 bytes (-5.2% of the eager-window cost this
epic exists to reduce); detail.md's 9,853 bytes are only ever paid by a
project that has NOT opted in.

Verified live, end to end, twice, against this actual repo (not a
synthetic fixture) — real gsd-planner and gsd-plan-checker subagent
spawns, real PLAN.md output:
- workflow.compact_content=false: planned a real disposable phase
  (a docs/how-to page for enabling the key itself); planner returned
  PLANNING COMPLETE, checker returned VERIFICATION PASSED, all fact-checks
  against real repo state confirmed.
- workflow.compact_content=true (detail.md never read): planned a second
  real disposable phase; planner returned PLANNING COMPLETE with
  frontmatter.validate and verify.plan-structure both clean, again fully
  grounded against real repo state. The five condensed fallback sections
  were independently re-read spine-only and confirmed sufficient to act on
  correctly without detail.md's elaboration.

Also drafts gsd-core/references/compact-content-protected-content.md — the
protected-content category list and <!-- gsd:protected --> sentinel syntax
ADR-4139 Decision 5 calls for, written to move to Phase 3 (#4403) unchanged
once it lands there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4402): move detail.md into the ADR-4139-mandated detail/ subdirectory

Two independent review sub-agents (Standards and Spec axes of /code-review)
caught the same structural defect: ADR-4139 Decision 6 mandates
gsd-core/workflows/<name>/detail/*.md ("one or more parts... individually
skippable"), and this PR had shipped a flat plan-phase/detail.md instead,
copying issue #4402's own (inconsistent) restatement rather than the
locked ADR text. Fixed by git-mv to plan-phase/detail/elaboration.md and
updating every cross-reference (the spine's step 0.5 gate pointer, the
shared compact-content-gate.md's own resolution-rule wording, and the
completeness test's path constants).

Also, from the same review pass:
- docs/CONFIGURATION.md and gsd-core/references/planning-config.md's
  workflow.compact_content rows said "nothing branches on it yet" — no
  longer true now that plan-phase.md's spine does. Updated both to name
  plan-phase as the pilot and note the rest of the corpus is still pending.
- Regenerated all 19 tests/fixtures/install-tree/*.json golden fixtures
  (npm run gen:install-tree) — the three new shipped files were missing
  from the installer emitted-tree goldens.
- Found via a cache-busted `eslint . --max-warnings 0` (this repo's
  eslint --cache has produced false-greens before): the split test's
  `git show` call had a bare `timeout: 10000` literal, tripping
  local/no-adhoc-timeout-literal. Extracted to the existing GIT_TIMEOUT_MS
  constant from tests/helpers/timeouts.cjs instead of a second guessed
  copy of the same class of timeout.

Verified NOT needed, by tracing the actual mechanism rather than asserting
(tests/helpers/emitted-provenance.cjs's gsd-core-verbatim rule attributes
every gsd-core/{workflows,references}/** path to itself as an identity
source): an Emitted-Drift-Ack-Hash/-Growth trailer. Every changed/added
path in this diff is hand-authored and present in the diff itself, so
diffEmitted's attribution loop resolves `via` to the path's own source
before ever reaching the ack-lookup branch — there is no unattributed
delta to acknowledge. The spine also shrank (98,290 to 93,206 bytes), so
the growth ratchet has nothing to ack either.

Re-verified after these changes: the completeness/disjointness self-check
(0 missing, 0 duplicated) still holds against the relocated detail file,
and a full `npm run lint:ci` passes clean with the eslint cache cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4402): restore literal content the pre-existing drift guards pin on

The first gsd-test run against this split (19 failures) surfaced real
regressions: several pre-existing structural guards pin the EXACT text
of the sections this split condensed, and paraphrasing broke them.

- tests/plan-phase-drift-guard.test.cjs expects the literal
  `DISK_PLANS=$(gsd_run query find-phase ...)` bash assignment inside
  plan-phase.md itself, not a prose description of the same check.
  Restored the exact line into both §9a and §11a's spine summaries.
- tests/thinking-partner.test.cjs expects plan-phase.md to literally
  offer "No, I'll decide" as the skip option. Restored that exact
  phrase into the condensed thinking-partner paragraph.
- Both restores would have duplicated the same text into
  plan-phase/detail/elaboration.md (which still carries the full
  elaboration). Removed the now-redundant restatements from the
  detail file instead of leaving them duplicated — the spine already
  computes DISK_PLANS before the detail elaboration is ever read, so
  the detail file references it rather than recomputing it.
- Re-running scripts/sync-runtime-launcher.cjs after that edit found
  the canonical gsd_run preamble had also become an unintentional
  spine/detail duplicate (both files call gsd_run and each is
  required, by runtime-launcher-parity's own contract, to carry its
  own copy). That's sanctioned duplication under a DIFFERENT
  contract, not lost/copy-pasted content, so
  tests/plan-phase-compact-split.test.cjs now excludes it from the
  disjointness check the same way it already excludes trivial
  fences/headings.
- Applied the adversarial-review finding on tests/plan-phase-compact-split.test.cjs's
  own isTrivial(): a blanket `line.length <= 15` cutoff silently
  swallowed real content (e.g. the 14-char `<quality_gate>`
  sentinel). Replaced it with a specific bare-label-line pattern
  (`Options:`, `Display banner:` etc.) — verified 0 missing / 0
  duplicated against the actual split, an improvement over both the
  original cutoff and a naive full removal (which produces
  false-positive "duplicates" on generic recurring labels).
- gsd-core/references/planning-config.md's own workflow.compact_content
  row used `/gsd-plan-phase` (hyphen). That file is Claude-facing
  source text (gsd-core/references/), which tests/slash-command-namespace.test.cjs
  requires in colon form; docs/CONFIGURATION.md's use of the hyphen
  form is correct as-is since docs/ is human-facing and outside that
  test's scanned directories. Fixed to `/gsd:plan-phase`.
- tests/plan-phase-compact-split.test.cjs's own `git show` of the
  parent commit failed inside the gsd-test sandbox ("detected dubious
  ownership") because the checkout is mounted under a UID the
  invoking user doesn't own. Scoped `-c safe.directory=<repo-root>`
  to that one git invocation rather than touching global git config.
- docs/INVENTORY.md still had one outstanding "detail.md part" wording
  fix from the earlier adversarial-review pass, staged now.

Re-verified locally against the exact assertions in all four affected
test files (all pass) before dispatching a fresh gsd-test run — no
change here should have broken any of the other 18 gates; `npm run
lint` is clean with the eslint cache cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4402): restore the full marker enumeration to §9a's spine trigger line

The isolated Spec-axis review flagged that §9a's "Triggered when" line was
condensed to "Agent() returns but the return contains no recognized
marker" — dropping the literal `## PLANNING COMPLETE` / `## PHASE SPLIT
RECOMMENDED` / `## ⚠ Source Audit` / `## CHECKPOINT REACHED` /
`## PLANNING INCONCLUSIVE` enumeration, which is exactly the "machine-
parsed structural headings" category compact-content-protected-content.md
lists as protected. The load-bearing use of that same list (the
gsd_stall_watch call and the Handle Planner Return bullets a few lines
above) was never touched — only this one descriptive restatement was
genericized — but leaving any instance of a protected category
unsentineled is the silent erosion ADR-4139 Decision 4(c) warns
sufficiency isn't machine-checkable enough to catch on its own. Restored
the full enumeration into the spine.

That reintroduced an exact duplicate into plan-phase/detail/elaboration.md,
which still stated the same trigger sentence verbatim. Reworded the
detail file's version to reference the spine's trigger condition instead
of restating it, since the spine is now the single place that sentence
lives in full — mirroring the DISK_PLANS/"already computed above" pattern
from the previous commit.

Re-verified locally: completeness/disjointness (0 missing, 0 duplicated)
and all previously-fixed literal-content assertions still hold.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4402): backfill changeset pr number to 4471

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:18:38 -04:00
Tom Boucher
476394689a fix(#4254): pin sequential executor to the orchestrator's validated root (#4476)
* test(#4254): sequential executor root pin — failing-first regression + matrix

The new suite executes the shipped supplied-root-pin guard against real git
fixtures (drifted primary-checkout cwd halts before the write and the FATAL
names both roots; matching cwd permits it; unexpanded/empty pins halt;
normalization forms; submodule and sibling boundaries; metacharacter quoting;
drive-letter form gate) and locks the dispatch contract across execute-phase.md,
its sequential-root-pin step fragment, and worktree-path-safety.md. The #2772
per-plan serialization assertion retargets to the fragment that now carries
those rules (ADR-857 Phase 6 ceiling), plus the host-step wiring.

* fix(#4254): pin sequential executor to the orchestrator's validated root

Sequential-mode dispatch told the executor to self-derive PROJECT_ROOT from its
own cwd; every existing guard is worktree-mode-only or self-referential, so an
executor spawned with a drifted cwd committed onto the wrong checkout silently.

- worktree-path-safety.md step 0p: mode-agnostic supplied-root pin guard,
  composed by the orchestrator at build time with the literal $ORCHESTRATOR_WT
  (git-vs-git comparison on both sides — representation-safe on Windows, the
  #4296 lesson), fail-closed on empty/unexpanded pins, registered-submodule
  allowance, warn-and-proceed only when the dispatch carries no pin block.
- execute-phase.md sequential branch: build-time embed of the bound
  <project_root_pin> via the new execute-phase/steps/sequential-root-pin.md
  fragment (ADR-857 Phase 6 frozen ceiling — the host step cannot grow; the
  wave serialization rules move with the fragment, verbatim in substance) plus
  the per-write/commit pin instruction in <sequential_execution>. Worktree-mode
  dispatch untouched (its self-derived toplevel IS correct there).
- INVENTORY rows (5 locales) + INVENTORY-MANIFEST + install-tree goldens
  regenerated for the new fragment; changeset added.

* chore(#4254): backfill changeset PR number

* fix(#4254): accept backslash-separated Windows drive pins

CI on windows-latest showed every permit-path test failing with
"Actual root: <none>": pins composed from Node's path.join arrive in the
backslash drive form (C:\Users\RUNNER~1\...), which the guard's absolute-form
gate rejected before the cwd-side root was ever computed — a legitimate
matching pin could never pass. The gate now accepts either separator
([A-Za-z]:[\\/]); git -C resolves both forms (and 8.3 short names) to the
same canonical toplevel, so the git-vs-git comparison is unaffected. Form-gate
tests cover the emitted (C:/…) and produced (C:\…) spellings plus short names.

* fix(#4254): portable drive-form gate for MSYS bash

The bracket class [\\/] that accepted backslash drive pins parses
inconsistently on MSYS bash (the Windows CI leg still rejected C:\ pins —
every permit-path test red with "Actual root: <none>"). Replace it with
standard pattern escaping outside brackets: [A-Za-z]:/*|[A-Za-z]:\\* —
the escape form is version- and build-portable. Verified across all forms:
both drive spellings accepted; bare "C:", relative, empty, and unexpanded
rejected.

* fix(#4254): runtime-generated backslash comparator + self-describing FATAL

The Windows CI legs failed every #4254 permit-path row with
'Actual root: <none>' across two prior pattern spellings ([\\/] and \\*).
Stage misattribution: <none> appears whenever the FATAL fires BEFORE the
cwd-side capture assigns ACTUAL_ROOT — the absolute-form gate was what fired.

Mechanism: the test harness spawns bash -c <script> through the Windows
command-line boundary; that round-trip applies one extra shell-quoting pass
with double-quote semantics — a backslash written twice in the script text
arrives halved, while a lone backslash survives (the pin displays intact;
row 9's pure-bash gate independently showed the halved pattern rejecting
C:\ while C:/ still passed its surviving arm). On windows-latest every pin
carries backslashes (os.tmpdir() is the 8.3 short form C:\Users\RUNNER~1\...),
so the gate ate every pin before the actual root was ever computed.

Fix, robust by construction:
- the drive-form gate generates its backslash comparator at RUNTIME
  (BS=$(printf '\134'); match [A-Za-z]:"$BS"*) — the shipped guard now
  contains no doubled backslash anywhere, enforced by a regression
  assertion on the extracted guard text;
- the FATAL self-describes: Guard stage (pin-unbound / form-gate /
  actual-capture / pinned-capture / root-mismatch) plus a Diagnostic line
  carrying git's own stderr for capture failures and both compared values
  for mismatches — future platform failures name their stage in the log;
- row 9's hand-rolled duplicate case gate (transit-fragile copy, #4296
  Minor 1 duplication smell) is replaced by driving the SHIPPED guard and
  asserting the stage; rows 2/4 pin the new stage machinery.

Validated on darwin across drift/match/relative/unbound/empty/bare-drive/
forward-and-backslash drive forms, each also re-run under a simulated
Windows transit (every doubled backslash halved) with identical outcomes.

* fix(#4254): close the empty-comparator fail-open seam in the drive-form gate

Self-review of the runtime-generated backslash comparator: if printf's
octal escape ever returned empty, the drive arm [A-Za-z]:"$BS"* would
widen to drive-RELATIVE pins (C:foo) — the construction's one theoretical
fail-open path. Fail closed with a self-describing diagnostic instead of
trusting the shell's printf.

---------

Co-authored-by: sim <sim@local>
2026-09-07 10:54:30 -04:00
Tom Boucher
2cf119f57e fix(#4217): reconcile artifacts before classifying an abnormally-ended executor (#4442)
* fix(#4217): reconcile artifacts before classifying abnormal ends

* test(#4217): pin the completion-reconciliation contract

* chore(#4217): regen derived inventory and install-tree fixtures

* test(#4217): follow the #4003 anchoring pins into the reconciliation fragment

Emitted-Drift-Ack-Growth: execute-phase.md — the runtime-neutral completion-reconciliation pointer, the two Codex wait-rule bindings, and the step-7 reconcile-first gate net +33 bytes over the extracted fallback block (#4217)

* chore(#4217): add changeset fragment

* chore(#4217): backfill PR number in changeset fragment

---------

Co-authored-by: sim <sim@local>
2026-09-06 18:57:38 -04:00
Michel Moreira
19b66c3ec8 fix(#4218): stop the orchestrator steering an executor that is still working (#4391)
* fix(#4218): stop the orchestrator steering an executor that is still working

An executor with recent RED/GREEN/REFACTOR commits and passing verification had
not yet written its SUMMARY because it was finishing closeout. The parent saw no
local OS test/build process, inferred an "idle tail", and sent "Finalize
immediately" into a working child; in CLI runs the same inference interrupted an
executor before GREEN, leaving a RED commit and an uncommitted edit.

The stall block said only "if no completion signal, no SUMMARY.md, and no
expected-branch commits appear for N minutes" — it never said what to do when
commits DO exist and only the SUMMARY is outstanding, never defined the
threshold as a period without progress rather than a total runtime, and never
ruled out a process listing as an idleness signal. Four rules close that:

- the threshold measures a period WITHOUT MEANINGFUL PROGRESS, from the last
  sign of progress, not from dispatch — a long verification tail is not a stall;
- commits + missing SUMMARY + recent activity resolves to KEEP WAITING, with
  steering, interrupting and re-dispatching each named and forbidden;
- urgency/finalization messages ("Finalize immediately" and family) are
  forbidden outright — they arrive mid-verification and truncate a correct run.
  The existing user-facing pause is the only sanctioned stop, and `kill and
  retry` is a clean restart, not a nudge;
- the absence of a local OS test/build process is NOT idleness: a native
  subagent runs in the runtime's own session, and an executor between two tool
  calls shows no process at all. Progress is judged only by the signals this
  workflow names.

Five prose-contract assertions in tests/execute-phase-wave.test.cjs, all red on
next.

* fix(#4218): extract the progress policy to a step fragment

CI's #1168 gate caught it: execute-phase.md sits 77 bytes under a frozen 93600
ceiling and the four rules added ~2.3 KB. "Extract, not bump" is the repo's
stated remedy, and this workflow already carries policy detail that way.

execute-phase/steps/executor-progress-policy.md owns the policy. The
worktree-recovery arm moved with it — `kill and switch to inline execution`
qualifies the stop this policy governs, so it belongs beside the rule about when
stopping is sanctioned at all, not stranded in the host. The #3212 recovery
OPTIONS stay in the host, where tests/config.test.cjs pins them.

The host keeps what must be read before the orchestrator acts: the verdict, the
threshold definition, and a pointer that fires before any message is sent to the
child. execute-phase.md is now 93475 bytes — 48 SMALLER than next.

* chore: add changeset for #4218

* chore(#4218): regenerate the inventory manifest for the new step fragment

docs/INVENTORY-MANIFEST.json is the authoritative per-file list behind
INVENTORY.md's `<workflow>/steps/*.md` row, so a new fragment has to appear
there or gen-inventory-manifest --check reds the lint-tests lane.

* chore(#4218): restore the issue ref on the allow-test-rule marker

ADR-456 requires a #NNN on a new exemption; the block rewrite that moved the
policy into the fragment dropped it.

* chore(#4218): regenerate the install-tree fixtures for the new step fragment

The fragment ships with the workflow, so every runtime's golden install tree
gains one path — gen:install-tree is the generator that owns those fixtures.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-06 17:51:34 -04:00
Tom Boucher
03738824de enhance(#2586): stop installing Codex context-monitor hooks without metrics (#4367) 2026-09-06 05:46:49 -04:00
Tom Boucher
06eba5fdb0 fix(#4130): parse phase-prefixed decision IDs (D4-01) (#4357)
* test(#4130): failing-first regression for phase-prefixed decision IDs

Add the #4130 matrix: D4-01/D12-01 across all three bullet forms, tags,
discretion, wrapped lead-ins, gate-level plan/verify end-to-end rows, and
parity properties (well-formed digit-prefixed ids parse to their exact id;
a non-digit injected into the prefix fails loud). Update the #2347
non-D-prefix fixture from D5-NN (now a legal grammar) to DEC-NN, and
graduate the representative d5-prefix corpus fixture from could-not-parse
to parsed-but-uncovered.

All new rows are RED against origin/next; they go green with the parser
fix in the next commit.

* fix(#4130): parse phase-prefixed decision IDs (D4-01)

The three declaration grammars, the parse-miss guard, the #3939 join
regexes, and the token evidence all anchored on the literal 'D-' (or
'**D-'), so an ID carrying a digit-run phase prefix between the leading
letter and the hyphen matched nothing — while the #2347 shape detector
correctly called those bullets decision-shaped, collapsing the whole
CONTEXT.md to could-not-parse with 0 extracted instead of a coverage
verdict.

Derive the extractor ID grammar from one shared DECISION_ID_SOURCE
('D[0-9]*-' + the existing alnum tail, full id captured), widen the
guard/join anchors to ID_ATTEMPT_SOURCE (bare 'D-' or a digit-initial
prefix run, so a typo'd 'D4x-01' fails loud while letter-initial prose
like 'Deferred-until' stays none-present), and align the bare-token
evidence. Both gates and the gap-checker share the parser, so all three
surfaces read phase-prefixed decisions now; the gate messages name the
accepted forms including the phase-prefixed one.

* docs(#4130): document the phase-prefixed decision identifier form

The canonical CONTEXT.md reference said decisions carry 'a sequential
D-NN identifier' with no mention of the optional phase-number prefix the
parser now accepts (D4-01) or the alphanumeric tail it always accepted
(D-INFRA-01). Name both in the Decision identifier format section, EN
and ja-JP.

* chore(#4130): changeset

* chore(#4130): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
2026-09-05 21:05:19 -04:00
Zy Deng
4c60879b5d fix(#4132): verify durable runtime surface sources (#4182)
* fix(#4132): verify durable runtime surface sources

* chore(#4132): record PR number in changeset

* test(#4132): cover rejected commands source alias

* fix(#4132): reject aliased package fallback

* test(#4132): cover rejected agents source alias

* test(#4132): cover partially aliased marker provider

* fix(#4132): reject partially aliased source providers

* test(#4132): cover routed source identity probes

* fix(#4132): route installed source identity probes

* refactor(#4132): tighten installer source metadata

* test(#4132): cover corpus trust boundary attacks

* fix(#4132): close installed corpus trust gaps

* refactor(#4132): keep installer authority private

* fix(#4132): preserve private installer fallback

* test(#4132): preserve fixture source authority

* fix(#4132): reject overlapping source fallback

* fix(#4132): avoid redundant installed corpus reads

* refactor(#4132): simplify provider resolution

* test(#4132): sync install tree fixtures after rebase

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 15:32:52 -04:00
Cody Anderson
77e2472ca0 enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook (#4236)
* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration

Add hooks/gsd-secret-read-guard.js, a blocking PreToolUse guard on
Read|Grep|Bash that denies reads of .env, .env.<suffix> and .secrets
(the .env.example/.sample/.template/.dist templates stay readable).
Read checks file_path; Grep checks an explicit path and judges the glob
per brace alternative; Bash runs a two-pass token scan (quotes, comments,
redirects with fd digits, separators, $( )/backtick/<( ) recursion,
heredoc bodies never scanned as commands, nested bash -c/eval rescans,
git <ref>:<path> shapes) with a closed non-reading exemption set for
existence checks. Fail-open crash policy; 1 MiB commands are denied as
command-too-large; more than 64 glob alternatives as glob-too-complex.

Why: Claude Code 2.1.259 makes every `cd DIR && grep …` compound prompt
for approval whenever any Read() deny rule exists, even in auto mode. A
hook denial is not a permission rule and never arms that check. The
installer-written deny rules are retired in the follow-up commit.

Registration: hooks.json (Read|Grep|Bash, timeout 5), build-hooks
HOOKS_TO_COPY, managed-hooks-registry, runtime-hooks-surface (blocking
guard with BLOCKING_GUARD_TIMEOUT_S; Kimi ReadFile|Grep|Shell),
shell-command-projection managed sets, installer-migration-report,
OpenCode/Kilo plugin (grep tool mapping, include -> glob, dispatch),
docs tables in five locales, ADR-766 always-on list, regen:derived
fixtures, and a new table-driven unit suite.

* test(#4221): pin the secret-read guard in existing hook gates

Register gsd-secret-read-guard.js in every existing hook gate: the
hooks-crash-policy table (deny row; 6 -> 7 deny cases), plugin-manifest
REQUIRED_HOOKS and its Read|Grep|Bash group, docs-hooks-table-parity
EXPECTED_SURFACE_HOOKS, install.test MANAGED_JS_HOOKS, install-minimal-
hooks JS_HOOKS/BLOCKING_GUARDS, portable-node-runner GUARD_HOOKS,
kilo-upgrades PLUGIN_GUARD_HOOKS, the Kimi normalization-parity and
typed-payload floors, the OpenCode adapter (grep mapping, include ->
glob, three dispatch tests) and a Kimi TOML matcher assertion.

* fix(#4221): retire installer Read() deny rules (legacy filter)

Rename GSD_CLAUDE_DENY_PERMISSIONS to GSD_CLAUDE_LEGACY_DENY_PERMISSIONS
and stop adding the three Read(.env) / Read(.env.*) / Read(.secrets)
strings. mergeClaudePermissions now only filters them out of an existing
permissions.deny: an absent deny key stays absent, a malformed one is
still repaired to [], and an array emptied by the filter is deleted so
no `"deny": []` residue is left. Uninstall filters the same legacy list
and, symmetric with the Antigravity branch, drops an emptied allow or
deny key and an emptied permissions object.

Unlike the #2278 allow-side migration there is no surviving current
deny list, so the constant is renamed rather than mirrored. Removal is
byte-exact: a hand-written identical rule is indistinguishable from the
installer's and is removed too (the manifest never recorded permission
strings). USER-GUIDE and CONTEXT.md updated.

* test(#4221): flip install-regressions deny-rule assertions to the retired shape

The fresh-merge, non-destructive merge, idempotency, end-to-end install,
reinstall and uninstall assertions now expect no Read(.env*) deny rules
and no permissions.deny key on a fresh install; the deny:null repair case
is kept. A new describe block covers the legacy filter: retired strings
removed with a user entry kept, partial sets, near-miss strings
untouched, idempotency, GSD-only deny array deleted, a pre-existing
empty deny preserved, and uninstall symmetry for allow/deny/permissions.

* chore(#4221): add changeset fragment for PR #4236

* fix(#4221): case-fold names; scan shell stdin and xargs pipes

Review round 1 (trek-e):

- Blocker: secret-name matching is now case-insensitive in the Read,
  Grep (path and glob) and Bash paths, so `.ENV` / `.Secrets` on a
  case-insensitive filesystem are recognized as the same secret file.
- Major: a shell interpreter's script is now scanned wherever it comes
  from. The tokenizer keeps heredoc bodies as per-segment tokens and
  records separator operators; pass 2 groups by segment id and resolves
  bash/sh/zsh/dash/ksh/su invocation mode: `-c` (including combined
  `-lc`) scans the script operand, a file operand is checked as a file
  (a `<( )` operand's echo/printf output is reconstructed), otherwise
  stdin is the script and heredocs, here-strings and a piped echo/printf
  source are scanned. `eval` joins all its operands; `source`/`.` handle
  process substitution. Data heredocs (`cat <<EOF`, the commit-message
  shape) stay unscanned.
- Major: `… | xargs <cmd>` checks the upstream segment's operands as
  file names when the sub-command reads (`echo .env | xargs cat`,
  `find . -name .env | xargs cat`); `-a`/`--arg-file` suppresses the
  inference; a shell sub-command's `-c` script is scanned.

Header, USER-GUIDE bullet and changeset updated; documented gaps now
include piped scripts from non-echo sources and `exec`/`timeout`
wrappers. 60 new suite cases pin the block and allow shapes.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:00:08 -04:00
Behruz Nassre Esfahani
c6efe2905c fix(#4087): stage the hook helpers the Codex bundle's hooks require (#4117)
* fix(#4087): stage the hook helpers the Codex bundle's hooks require

CODEX_HOOKS_TO_COPY is a flat, hand-maintained filename allowlist that never
recursed, and Codex is excluded from installSharedHooksBundle() — the path that
stages hooks/lib/ for full-bundle runtimes — by an !isCodex gate. Excluding
hooks/lib/ was a correct scoped decision for #3579 until #3911 (2ea5efc15) gave
gsd-context-monitor.js a real require('./lib/hook-exit.js'). From then on every
fresh --codex install staged the hook without its helper and the hook died with
MODULE_NOT_FOUND at module load, before its own try/catch, on every event Codex
registers it for. The install still exited 0, so nothing surfaced it.

Reproduced before changing anything, in a sandboxed CODEX_HOME: four hooks
staged, no lib/, and the installed hook exiting 1 on "Cannot find module
'./lib/hook-exit.js'".

Rather than hand-add today's three helpers — which re-breaks the next time a
Codex-bundled hook grows a lib dependency, exactly how this regressed — the
transitive-require walk already written for Cursor in 704859e9c is extracted out
of writeCursorHooksJson into an exported stageTransitiveHookLibs(), Cursor is
rewired onto it, and the Codex copy loop calls it. bin/install.js already
required that module, so this adds no new seam. Cursor's staged set is
byte-identical to base, compared file by file.

Extraction surfaced a latent defect in that walker, fixed here: its regex read
`./X` and `./lib/X` identically, but from a hook SCRIPT a bare `./X` is a
sibling in hooks/ — gsd-check-update-worker.js requires
`./managed-hooks-registry.cjs`, which is not a lib — so it demanded
hooks/lib/managed-hooks-registry.cjs and the fail-loud guard threw. Seeds now
match only `./lib/X`; lib files still match both, which is the
sibling-within-lib case 704859e9c exists for. Cursor never exposed it because
none of its scripts carries a bare sibling require.

Three further grammar gaps closed after review, each in the fail-closed
direction: an extensionless `require('./lib/x')` is valid CommonJS and was
resolved literally, failing the install on a legitimate require — now resolved
through .js/.cjs and written under its resolved name; a NESTED `./lib/sub/x.js`
could not be expressed by the character class and was a SILENT miss, the one
failure mode this function exists to remove — now refused loudly; and a capture
carrying no alphanumeric character is prose, not a module name — hooks/lib/
injection-patterns.js documents this very mechanism with the literal string
require('./lib/...'), which captured `...` and sent the resolver hunting for
hooks/lib/... . The scan is still not comment-aware, which is disclosed at the
call site rather than papered over.

Seeded from the entries THIS invocation staged rather than probing the
destination, so a file left by an earlier install whose source is no longer
allowlisted cannot contribute helpers for a hook that is no longer shipped.

The #3579 boundary holds: three of ten helpers ship, gsd-graphify-rebuild.sh
among those correctly absent. Seven rows — three driving a real install into a
sandboxed config dir (with HOME sandboxed for the child, since Codex's skills
kind resolves from os.homedir() and the #3712 guard rightly refuses otherwise)
and four pinning the discovery grammar directly. All proven fail-first; the
set-equality row also reds on over-staging, which the count-based version it
replaced did not catch.

Fixes #4087
Fixes #4098

Emitted-Drift-Ack-Hash: hooks/lib/hook-exit.js — newly emitted for codex because the installer now stages the helpers its hooks require; the helper's own content is unchanged
Emitted-Drift-Ack-Hash: hooks/lib/cli-exit.js — newly emitted for codex as hook-exit.js's transitive require; the helper's own content is unchanged
Emitted-Drift-Ack-Hash: hooks/lib/exit-code-registry.js — newly emitted for codex as cli-exit.js's transitive require; the helper's own content is unchanged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9

* chore(#4087): add changeset

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9

* fix(#4087): stage the hooks/lib helpers the Windsurf guards require

Review of #4117, verified as asked and reproduced against a real install.

Windsurf sets hostBehaviors.skipSharedHooksInstall, so like Cursor it never
reaches installSharedHooksBundle -- the only other stager of hooks/lib --
and writeWindsurfHooksJson staged its two Cascade guards without the
helpers both require at module load: gsd-windsurf-pre-write.js requires
./lib/hook-exit.js and ./lib/git-probe.js, gsd-windsurf-pre-command.js
requires ./lib/hook-exit.js. stageTransitiveHookLibs had one call site,
Cursor's.

Measured on a fresh `--windsurf --global` install into a sandboxed HOME:
the installer exited 0, hooks/ held only the two scripts and package.json,
and executing either installed guard exited 1 with "Cannot find module
'./lib/hook-exit.js'" -- so every pre_write_code and pre_run_command event
failed at load while the install reported success. The same command with
`--cursor` staged four helpers and its hook ran, which is the control.

Pre-existing rather than introduced here: at merge-base 05092ff36 the same
three require lines exist and writeWindsurfHooksJson already staged no
lib/, and this PR's diff carried no reference to Windsurf. Fixed here
anyway because the helper this PR extracted is the right tool and a second
runtime is a few lines onto it.

writeWindsurfHooksJson now calls stageTransitiveHookLibs after staging its
scripts, with the same gsd: -> gsd- transform the scripts receive, so a
helper is rewritten the same way as its caller. The install-tree fixture
regenerates with exactly hook-exit.js, git-probe.js, cli-exit.js and
exit-code-registry.js added and no other fixture moved. Two rows execute
the INSTALLED guards, beside the Codex rows they mirror; the existing
windsurf-hooks-bridge rows run the guards from source and test behaviour,
a different question, and are left as they are. Both new rows fail-first
against the unfixed compiled artifact -- gsd-core/bin/lib, which is what
bin/install.js loads -- on the MODULE_NOT_FOUND assertion.

Emitted-Drift-Ack-Hash: hooks/lib/git-probe.js — first staged for Windsurf, whose pre-write guard requires it; the file itself is unchanged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 05:49:37 +00:00
JusticeWay
0fca71eaae enhance(#2529): cover every workflow with response-language directives + CI lint (#2558)
* enhance(#2529): cover every workflow with response-language directives + CI lint

Every workflow now carries response-language coverage in one of three forms,
and a CI lint keeps it that way.

- 43 workflows load the new shared reference,
  `gsd-core/references/response-language-directive.md`, by eager `@`-import.
- Lazy-loaded modes/steps/templates, which cannot rely on an eager import,
  carry an exact inline directive; 35 such paths are pinned by exact path.
- Fragments dispatched by a covered parent inherit coverage, proven per file
  rather than granted per directory.

The 45 workflows whose directive covered only "questions, prompts, and
explanations" now name inter-tool narration, which is the defect #2529
reports: the running commentary between tool calls stayed English while the
answers around it were translated.

`scripts/lint-response-language-coverage.cjs` enforces it and fails closed on
three independent discovery failures (unreadable catalog, empty catalog,
unfollowed symlink). It resolves which reference a workflow imports and applies
the same four-predicate test to that file, so a weakened shared reference
uncovers its importers instead of passing silently, reported once as a systemic
failure rather than 43 times. The walk follows symlinked subtrees with a
realpath cycle bound. `lint:ci` invokes it by name.

REQ-LANG-03 and REQ-LANG-04 state the contract in docs/FEATURES.md;
REQ-LANG-04 names the two forms that satisfy it ("narration", "between tool
calls") rather than enumerating class members an author cannot use verbatim,
and a test pins that text to what the matcher accepts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2529): register the coverage test in the docs-guard lane

`107eb8c1` (#3787) landed the docs-guard lane on `next` while this PR was
open: a test that reads a `docs/` path must be named in
`scripts/docs-guard-registry.cjs` or carry a `docs-guard-exempt` marker,
so the guards that read a doc run on the PR that changes it.

`tests/response-language-coverage.test.cjs` reads `docs/FEATURES.md` -- it
extracts every form REQ-LANG-04 offers an author and runs each through the
matcher that enforces it. Registration, not exemption, is the correct side
of that gate: a reword of the requirement with no code change is precisely
the diff this test exists to catch, and it is the diff the lane would
otherwise skip.

Registered narrowly (`['docs/FEATURES.md']`) rather than with the `'*'`
sentinel, so an unrelated docs change does not pull this test into the lane.

Verified: lint-docs-guard-registration 0 violations, tests/ci-docs-guard-registry.test.cjs
51/51, lint:ci exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2529): consolidate this PR's emitted-growth acks into its own fragment

This PR ripples emitted bytes across 85 workflow paths. Until now each ripple
was acknowledged by appending to whichever live fragment owned that path,
because two ack sources may never name the same path.

`a84f7563` (#3078) swept all 45 fully-spent fragments off `next`. Forty-two of
the paths this PR grows were owned by swept fragments, so those keys are now
unowned and this PR's own fragment declares them directly -- one path, one
source, and no dependence on a fragment that no longer exists. Each adopted
entry keeps its measurement and records where it came from.

Two paths are handled differently, because the sweep did not free them:

- `review.md` is now owned by `3034-parallel-reviewer-lanes.json`, which
  landed on `next` after the sweep. Its entry is live, so the old route still
  applies: this PR's note is appended to that entry rather than declared a
  second time.
- `plan-review-convergence.md` keeps the arrangement made in round 24.

Result: 3 fragments in the directory, 85 keys in this PR's own,
0 cross-source duplicates. `lint-emitted-drift-ack` exit 0,
`tests/emitted-attribution.test.cjs` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2529): move REQ-LANG-03/04 into the feature fragment that now generates them

`36375513` (#3845) made docs/FEATURES.md a generated projection of
docs/features/*.md, marked "do not edit by hand". This PR wrote REQ-LANG-03
and REQ-LANG-04 straight into the generated file, so the rebase left the
requirement present in the projection and absent from its source -- the next
regeneration would have deleted both, and `tests/features-index-gate.test.cjs`
was already red on the mismatch.

Both requirements now live in docs/features/response-language-config.md
alongside REQ-LANG-01 and -02. Regenerating produces a docs/FEATURES.md that is
byte-identical to the committed one, so the text this PR shipped is unchanged --
only its source of truth moved to where #3840 put it.

The docs-guard registration is widened to name the fragment as well as the
projection. The requirement's source is the fragment now, and an edit there
that skips regeneration would otherwise reach this guard through neither path.

Verified: features-index-gate 68/68, lint-docs-guard-registration 0 violations,
ci-docs-guard-registry + response-language-coverage 142/142.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2529): hand the plan-phase ack back to its new live owner

`c933184b` (#3825) landed `3172-stated-failing-direction.json` on `next` after
fragment had adopted that path when the sweep left it unowned, so the merged
tree named it from two sources -- a hard failure in
`scripts/lint-emitted-drift-ack.cjs`.

The path has a live owner again, so the append route applies: this PR's note
joins that entry, carrying its own measurement, and the key is dropped from
this PR's fragment (84 keys left, the others untouched). The provenance
sentence written for the swept-fragment case is removed rather than reused --
this path was never orphaned, so that account of it would be false.

Same shape as `review.md` and `plan-review-convergence.md`: ownership is a
property of the merged tree, and a fragment landing upstream after a push can
reclaim a key no local check would have flagged.

Verified: lint-emitted-drift-ack exit 0, lint:ci exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2529): state byte figures that are true against the tree

The reference claimed `execute-phase.md` has "2 bytes of headroom under the
ceiling named below". That was true when the sentence was written -- the file
sat at 93398 against the 93400 comfort assert -- and upstream has since shrunk
it to 91493 against a 93600 hard ceiling, so the figure now understates the
headroom by three orders of magnitude. The rationale the sentence supports does
not depend on the number, so the number is gone rather than refreshed: a
restated figure would go stale again on the next upstream edit, and nothing
parses it.

Audited every other numeric claim this PR ships the same way, mechanically
against the merge base: all 82 FILE-delta claims in the ack fragment match the
real per-file delta exactly, and the 1,629-byte reference and 63-byte import
line check out. One class was imprecise: the 41 notes for workflows whose
inline directive was rewritten in place quoted the conversion counterfactual as
"+1,692 bytes more loaded context", which is the reference form's whole weight,
not the increase over the inline directive those files already carry. Each now
names both quantities and the net (+1,605 / +1,609 / +1,584).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2529): one rule for pinned vs inherited coverage, and the docs to pick it

Review measured that 14 of the 35 pinned fragments would pass by inheritance
anyway, and that the PR asserted both readings at once: inheritance is real
coverage (so those 14 pins are noise) or it is not (so 30 inheriting fragments
are green-but-uncovered). Only one can be true.

Inheritance is real: the predicate proves it per file -- the parent must
dispatch this exact path from a read/execute context AND be covered itself --
so the parent's directive is in the loaded context by the time the fragment is
read. The 14 pins are therefore removed along with the directive lines they
pinned, and those files inherit like the 30 structurally identical ones. The
rule is now stated where the set is declared, and enforced from the other side
by a test: no member of the pinned set may be one that would have inherited.
That is what decides the form for the next fragment.

- pinned set 35 -> 21; 14 workflow files revert to their base content
- `findViolations` no longer returns early on a pinned path: a file that becomes
  eagerly loaded and takes the shared reference is strictly better off, and the
  gate must not red that. The reference form is admitted because its own wording
  is validated in turn; an arbitrary reworded inline line still fails.
- the reference-directive cache is keyed by size and mtime, not by path alone,
  so a rewritten reference re-asked in one process no longer returns the stale
  verdict
- `carriesInlineDirective` names its negation blindness: four independent hits
  read vocabulary, not polarity
- the real-tree scan asserts each source produced files instead of `> 152`, a
  constant that read as the workflow count and would have passed a scan that
  lost one of its two directories
- the pinned-set size assertion goes the same way: the size follows from the
  rule, so the rule is what the suite asserts

Docs, for the gate that now governs every future workflow:
- `docs/contributing/response-language-coverage.md` -- why the narration class
  is the discriminator, the four coverage forms, the decision order that picks
  one, the pinned line, and what each failure message means
- a row in CONTRIBUTING.md's CI checks table, matching the docs-guard row
- `docs/CONFIGURATION.md` points at it from the `response_language` entry

Also: the changeset said 45 reworded workflows; it is 44 (42 @-reference + 21
pinned + 44 rewritten = 107 touched). That text ships to CHANGELOG.md.

`3707-parse-gap-reporting.json` landed on `next` reclaiming `audit-uat.md` and
`progress.md`; both handed back by the append route, leaving 82 keys here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2529): correct the reference-taker count, 43 -> 42

The ack notes said the import line is byte-identical "in each of the 43
workflows that take the reference" and that the alternative would be "43 inline
copies". The shared reference has 42 importers; the 43rd file in review's table
is `execute-phase.md`, which imports the OTHER reference. Corrected in all 41
notes that carry the sentence, across this PR's fragment and the two it appends
to.

Found by re-running the numeric audit from the previous round after the rebase,
which also re-verified all 84 FILE-delta claims against the new base -- all
exact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2529): migrate the emitted-drift ack from a fragment to commit trailers

ADR-3942 (#3954) landed while this PR was open: the acknowledgment is now a commit
trailer and tests/emitted-drift-acks/ no longer exists. The fragment is deleted and
each key it declared becomes one trailer, reasons unchanged.

The four keys this PR had handed to 3034-*, 3172-* and 3707-* under the one-source
rule come home here. That rule was the whole reason for the hand-backs, and the
trailer model has no shared namespace to collide in -- five of this PR's rounds were
spent on exactly those collisions.

Emitted-Drift-Ack-Growth: add-backlog.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: add-phase.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: add-tests.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: add-todo.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: ai-integration-phase.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 16: the fragment that carried this sentence (`3423-required-reading.json`) was retired on `next` by ddf85287 (fix(#3357), #3513), and no fragment on `next` declares this path now. The ack therefore returns to this PR's own fragment, which is the only live source for it — the change to the path is this PR's.
Emitted-Drift-Ack-Growth: analyze-dependencies.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: audit-fix.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 19: this PR declared the path in its own fragment, and `3602-workflow-subagent-model-resolution.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3602-workflow-subagent-model-resolution.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: audit-milestone.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed from `2962-zsh-nomatch-for-glob-portability.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: audit-uat.md — A live/archived split was added and then reverted on this branch (see `$comment`): the split's extra rule in `initialize`, the narrowed Unparsed-table filter, and the separate 'Unparsed UAT Files in Archived Milestones' informational section are all removed, so the file settles at origin/next 5582 -> 7124 bytes (+1542, final). #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: autonomous.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 16: `3210-autonomous-precondition-gate.json` landed on `next` in 8fc88f66 (fix(#3210), #3528) and declares this path today. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3210-autonomous-precondition-gate.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: check-todos.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: cleanup.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 18: this PR declared the path in its own fragment, and `2142-quick-task-archival.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `2142-quick-task-archival.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: code-review-fix.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 13: `3190-code-review-fix-auto-rewrite-review.json` landed on `next` in 1d5d7795 (fix(#3190), #3434) and declares this path too. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3190-code-review-fix-auto-rewrite-review.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: code-review.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 19: the fragment that carried this sentence (`3503-diff-base-scope-anchor.json`) was retired on `next` by 2fca0e17 (enhance(#2554), #3695), and `2554-code-review-depth-overrides.json` declares this path today. One path takes exactly one ack source, so the sentence follows the path to its live owner. Re-homed from `2554-code-review-depth-overrides.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: complete-milestone.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 15: the fragment carrying it (`3458-audit-open-acknowledge-wiring.json`) was retired on `next` and the path is declared by `3409-unreachable-guard-arms.json` today. One path takes exactly one ack source, so the sentence follows the path to its live owner. Re-homed from `3409-unreachable-guard-arms.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: debug.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 14: the fragment that carried this sentence (`3149-init-debug-entry-point.json`) was retired on `next` by 26f8015c (fix(#3448), #3476), and `3448-debug-autoresume-next-action.json` declares the path today. One path takes exactly one ack source, so the sentence follows the path to its live owner rather than being dropped or re-armed under a retired number. Re-homed from `3448-debug-autoresume-next-action.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: diagnose-issues.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: an inline copy in every workflow would be that many places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 19: this PR declared the path in its own fragment, and `3602-workflow-subagent-model-resolution.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3602-workflow-subagent-model-resolution.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: discuss-phase.md — #2529 round 36: this workflow's inline directive was rewritten in round 10 to name inter-tool narration, but in the compressed form, and that rewrite came to −1 byte against `next` — so it declared no growth and this key was absent from this PR's ack set until now. Round 36 replaces the compressed clause with the same enumeration the other rewordings carry — narration between tool calls, status updates, progress notes, findings, questions, prompts, and explanations — because `discuss-phase.md` started from the identical upstream sentence as `verify-work.md` and `new-milestone.md` and those two took the full list, so the shorthand was an inconsistency rather than a decision. +87 bytes against `next`, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. The directive stays INLINE rather than becoming an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have cost 1,692 bytes of loaded context against the 87 this sentence costs. `commands/gsd/discuss-phase.md` dispatches this workflow lazily (`Read and execute ...`) rather than `@`-importing it, so the 87 bytes land in the installed file and are read once the workflow is dispatched, not on every command invocation.
Emitted-Drift-Ack-Growth: discuss-phase-assumptions.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 15: `3409-unreachable-guard-arms.json` landed on `next` in #3558 and declares this path too. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3409-unreachable-guard-arms.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: discuss-phase-power.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: do.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: docs-update.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +83 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +83 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 83 bytes this inline directive costs, a net +1,609. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,609 bytes more loaded context per invocation. Re-homed in round 19: this PR declared the path in its own fragment, and `3602-workflow-subagent-model-resolution.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3602-workflow-subagent-model-resolution.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: edit-phase.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 13: `3262-editphase-milestone-scope-guard.json` landed on `next` in fd4715f8 (fix(#3262), #3446) and declares this path too. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3262-editphase-milestone-scope-guard.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: eval-review.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 16: the fragment that carried this sentence (`3423-required-reading.json`) was retired on `next` by ddf85287 (fix(#3357), #3513), and no fragment on `next` declares this path now. The ack therefore returns to this PR's own fragment, which is the only live source for it — the change to the path is this PR's.
Emitted-Drift-Ack-Growth: execute-plan.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 14: the fragment that carried this sentence (`2652-quick-diagnose-dispatch-isolation.json`) was retired on `next` by 362d0434 (fix(#3370), #3478), and `3370-execute-phase-gate-conflation.json` declares the path today. One path takes exactly one ack source, so the sentence follows the path to its live owner rather than being dropped or re-armed under a retired number. Re-homed from `3370-execute-phase-gate-conflation.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: explore.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed from `2229-explore-claim-disposition.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: extract-learnings.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: fast.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 18: this PR declared the path in its own fragment, and `3585-planning-commit-guard.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3585-planning-commit-guard.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: forensics.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: graduation.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: health.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 13: the fragment that carried this sentence (`2573-state-head-freshness.json`) was retired on `next` by 7ddcc198 (fix(#3309)), and `3309-health-docs-generated.json` declares the path today. One path takes exactly one ack source, so the sentence follows the path to its live owner rather than being dropped or re-armed under a retired number. Re-homed from `3309-health-docs-generated.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: help.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: import.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 18: this PR declared the path in its own fragment, and `3576-references-canonical-cites.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3576-references-canonical-cites.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: inbox.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: ingest-docs.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed from `2658-trae-instruction-file-path.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: insert-phase.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: list-phase-assumptions.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: list-seeds.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: list-workspaces.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 …

* fix(#2529): read the catalog-relative dispatch spelling, and the plural of "output"

Two false positives in the coverage lint, both surfaced by this round's work
rather than by a red gate finding them for us.

#3552 landed `execute-phase/steps/protected-branch.md` on next while this PR was
open, dispatched from execute-phase.md's `"none"` arm with the path written
RELATIVE to the catalog. `namesFragmentAsEntryPoint` only ever looked for the
`gsd-core/workflows/`-rooted spelling, so it read a live dispatch as no dispatch
and the new fragment as uncovered. It now accepts both spellings and matches the
relative one on a path boundary, so `vendor/<path>` cannot vouch for `<path>`.

Recognizing that spelling makes one pin redundant: execute-phase.md dispatches
executor-isolation-dispatch.md the same way, so the fragment inherits and its
own copy of the sentence comes back out. That is the rule round 29 encoded,
enforced by the test that measures it rather than by hand.

`output` was the one term in USER_OUTPUT_RE without an `s?`, so "translate all
outputs, including narration between tool calls" read as uncovered. The new
property tests caught it on their first run.

Those properties pin the rule the hand-written cases are instances of: four
signals on ONE line accept, dropping any one rejects, spreading them across
lines rejects. The vocabulary is written out in the test rather than read back
from the script's regexes, per CONTRIBUTING.md "Fixture provenance (#2371)" -- a
generator seeded from the matcher can only re-derive what the matcher already
believes, and that independence is what caught the plural. Both new properties
are mutation-verified: dropping the narration predicate reds the necessity
property, and collapsing the document to a single line reds the cross-line one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* enhance(#2529): spell the narration enumeration out in the last two shorthand directives

discuss-phase.md and plan-phase.md were the only two of this PR's 44
rewordings that abbreviated the inserted clause to "narration between tool
calls included" instead of naming the output classes the way the rest of them
do. Both forms satisfy the lint's four predicates, so nothing was broken --
but the point of #2529 is that an author reading one workflow should not have
to infer what the neighbouring one means by "included".

Both abbreviations were size decisions rather than wording ones, and both
reasons have since expired because next shrank the files. discuss-phase.md
sat 25 bytes under the 32,000-byte #717 dispatcher budget and now has 1,825;
plan-phase.md sat 87 bytes under the 94,519-byte ADR-857 capstone ratchet
against a +108 clause and now has 3,180. Neither budget is raised here and no
unrelated prose is trimmed; workflow-size-budget and
phase6-capstone-conformance both pass.

discuss-phase.md started from the identical upstream sentence as verify-work.md
and new-milestone.md ("All user-facing questions, prompts, and explanations in
this workflow"), and those two received the full enumeration; it now matches
them exactly. plan-phase.md keeps its own scope word ("orchestrator output") and
its subagent pass-through instruction, both upstream's, and only trades the
shorthand for the enumeration.

The shorthand now appears nowhere in the catalog. The two remaining variants
(plan-review-convergence.md, spec-phase.md) keep upstream's own verb and scope
and end on "report prose", which is what those workflows actually emit --
rewriting those would change a directive's strength, not its wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2529): match the workflow extension case-insensitively in coverage discovery

`findMarkdownFilesRecursive` filtered on `entry.name.endsWith('.md')`, so
`SETTINGS.MD` — the same file to Windows and macOS, a different one to Linux —
was skipped on the only platform whose verdict gates the merge. The direction of
that failure is the problem: a workflow the walk declines to see is a workflow
this lint certifies by omission, which is the same vacuous pass `main()` already
refuses when discovery returns nothing at all.

The filter is now an allowlist keyed on the lowercased `path.extname`.
`.mdx` stays out on purpose: admitting an extension states what a workflow IS,
and that claim has a second half — `inheritsParentCoverage` resolves a
fragment's parent as `<workflow>.md`. An `.mdx` entry belongs here next to the
parent resolution it would have to move with, not ahead of it.

Two tests: an uppercase-extension file is discovered AND lands as a violation
rather than an exemption, and every admitted extension is spelled so the
lowercasing match can reach it (an uppercase or dotless entry would be dead
configuration that reads like coverage).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* enhance(#2529): cover the quick-batch workflow that #3676 landed uncovered

`next` gained `quick-batch.md` and nine step fragments in 2f64e6230 (#3676,
PR #4212) with no response-language directive, so the merge result reds this
PR's own lint with 10 violations. The lint is doing exactly what it exists to
do; the coverage is what has to move.

`quick-batch.md` takes the shared @-reference on line 1, the same as the other
42 top-level workflows, and eight of the nine fragments then inherit through
its `read and execute` stubs. The ninth does not:
`quick-batch/steps/plan-checker-loop.md` is dispatched by a SIBLING fragment
(`planner-wave.md:134`) and named in the parent only inside a parenthetical
with no dispatch verb, which is the shape round 29's rule already covers for
`execute-phase/steps/regression-gate-run.md` and
`plan-phase/steps/prd-express-path.md`. It carries the pinned inline directive
and joins `EXACT_INLINE_DIRECTIVE_WORKFLOWS`; the comment above that set now
names four such fragments instead of three. Coverage: 163 workflows.

`FULL_BUDGET` in tests/skill-frontmatter-contract.test.cjs moves 844 -> 846.
The same commit grew `help/modes/full.md` from 834 to 844 lines, landing it
exactly on the ceiling with zero slack, and the two lines this PR adds there
are its pinned directive and the blank separating it. That is a coverage
contract every workflow carries, not the content creep the budget guards.
The #597 ratchet rule holds: actualMax 846, slack 0, well inside LARGE_GRACE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: quick-batch.md — #2529: the workflow arrived on `next` in 2f64e6230 (#3676, PR #4212) with no response-language directive, so this PR's lint reds on the merge result; covering it is the PR's whole contract, not an optional extra. It gains the shared directive as a single eager `@`-reference line, the identical form the other 42 top-level workflows take. FILE delta: +62 bytes, as the gate measures it. LOADED-CONTEXT delta: +1,691 bytes — the import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates read the FILE and not the transitive inline, so they see 62 of those 1,691 bytes; the remaining 1,629 are declared here because no gate reads them. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Nine `quick-batch/steps/*` fragments are covered without a byte of their own — eight inherit through the parent's dispatch stubs, and the ninth takes the pinned inline sentence, which the emitted surface does not measure.

* fix(#2529): scope row 48 by what a diff says, not by which paths it names

`tests/gsd-quick-batch-quick-regression.test.cjs` treats any branch touching a
`quick-batch` path as #3676 phase work, then forbids it from editing ordinary
`quick.md`. This PR covers EVERY workflow with the shared response-language
directive — quick-batch.md and its fragments included — so the scope check
turned true, and the row read this PR's one-line directive on `quick.md` as a
phase violation.

That is the false positive the row's own #3730 note already scoped away from,
arriving by the other door: not an unrelated branch that misses the surface,
but a catalog-wide sweep that touches all of it. A path now counts as phase
work only when its diff says something other than the coverage contract, and
the two accepted directive forms are read from
`scripts/lint-response-language-coverage.cjs` rather than restated, so a
reworded contract cannot leave the carve-out matching prose the lint no longer
recognizes. A file the branch ADDED still counts — every line is new, which is
what a real #3676-phase branch looks like.

The invariant is unweakened in the direction that matters: a phase branch that
edits `commands/gsd/quick.md`, `gsd-core/workflows/quick.md` or anything under
`quick/steps/` for any reason other than the directive still fails the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-04 21:12:12 -04:00
Tom Boucher
2f4f7538e9 fix(#4264): wire both TDD dispatch backends to phase.tdd-applicable (#4284) 2026-09-04 16:08:42 -04:00
Tom Boucher
2f64e6230a feat(#3676): quick-batch command, workflow, and isolation integration (#4212)
* test(#3676): add failing tests for quick-batch dispatch core

Failing-first tests for Phase 4 of epic #3344 (ADR-1239 "Quick-batch
binding"): quick-batch-dispatch.test.cjs / .property.test.cjs cover the
new pure decision-logic module (arg validation, effective concurrency,
deterministic merge order, spawn backpressure, verification/merge
routing, cleanup-entry construction — design doc rows 3-15,24,26-28,
30-36,39; property rows 51-53). quick-batch-update-items.test.cjs
covers the new updateBatchItems export on src/quick-batch.cts (rows
15,22-23, including the negative cycle-rejection case).
quick-batch-command-router.test.cjs covers the new
gsd-tools quick-batch CLI family (rows 46-47). These reference modules/
exports that do not exist yet.

* feat(#3676): implement quick-batch dispatch core, updateBatchItems, and command router

Phase 4 of epic #3344 (ADR-1239 "Quick-batch binding") CORE decision
layer — CLI verbs and pure orchestration logic only; no workflow
markdown, no Agent()/git-worktree I/O.

- src/quick-batch-dispatch.cts (new): pure decision functions consumed
  by the (separate, follow-up) /gsd:quick-batch workflow markdown —
  parseQuickBatchArgs, computeEffectiveConcurrency, computeMergeOrder,
  computeSpawnPlan, routeVerificationOutcome, routeMergeOutcome,
  buildCleanupManifestEntry (the last parses caller-supplied plan text
  via the existing parsePlanDocument; no filesystem access).

- src/quick-batch.cts: adds updateBatchItems, resolving the design
  doc's Open Question 1 as ONE additive export on this module instead
  of the second, independent BATCH.json writer the design doc
  originally proposed. Reuses the same withPlanningLock transaction
  shape, computeWaves, and platformWriteSync call resumeBatch/
  completeQuickItem already use; fails closed without persisting on
  an unknown item, an unknown/self dependency, or an introduced cycle.

- src/quick-batch-command-router.cts (new): gsd-tools quick-batch CLI
  family, wired into HOST_COMMAND_ROUTERS (gsd-core/bin/gsd-tools.cjs)
  as a first-party always-on command (like /gsd:quick), not the opt-in
  capability-registry path graphify uses. Verbs: create/update/resume/
  complete (wrap quick-batch.cts) and effective-concurrency/
  merge-eligible/spawn-plan/verification-routing/merge-routing/
  cleanup-entry/parse-args (wrap quick-batch-dispatch.cts).

Design doc rows covered: 3-15, 22-24, 26-28, 30-39, 46-47. Property
rows 51-53. Rows covering workflow markdown / Agent() dispatch /
`git worktree` behavior (16-21, 25, 29, 40-45, 48-50) remain for the
follow-up markdown-authoring pass, per the phase brief's explicit
scope boundary.

* docs(#3676): register quick-batch-dispatch/command-router modules in bookkeeping surfaces

New-.cts-module ripple for the two Phase 4 modules (epic #3344,
ADR-1239 "Quick-batch binding"): .gitignore (compiled .cjs artifacts,
ADR-457 build-at-publish), eslint.config.mjs (lint the .cts source,
not the emitted .cjs), docs/INVENTORY.md + docs/INVENTORY-MANIFEST.json
(via `node scripts/gen-inventory-manifest.cjs --write`, after
`npm run build:lib`), and CONTEXT.md glossary entries for
"Quick-Batch Dispatch Core Module" and "Quick-Batch Command Router
Module", plus an update to the existing "Quick-Batch Core Primitives
Module" entry documenting the new updateBatchItems export.

* test(#3676): fold updateBatchItems tests into quick-batch.test.cjs (fix lint-test-file-count)

scripts/lint-test-file-count.cjs buckets any quick-batch-*.test.cjs
file under the quick-batch production module by longest-prefix match,
and that module is already at its 2-file cap (quick-batch.test.cjs +
quick-batch.property.test.cjs). The standalone
tests/quick-batch-update-items.test.cjs added in the prior commit
pushed it to 3 and failed `npm run lint:ci`. Fold its content into
quick-batch.test.cjs (append-only — no existing test in that file is
modified) and update the CONTEXT.md glossary reference to match.

Surfaced while re-running `GITHUB_BASE_REF=next npm run lint:ci` after
`npm ci` (this worktree previously had no local node_modules, which
also made gen-scripts-cli-exit/gen-hooks-cli-exit/gen-exit-code-*
unable to resolve typescript — resolved by npm ci, no code change
needed there). `npm run lint:ci` and
`npx tsc -p tsconfig.build.json --noEmit` are both green after this
fix.

* test(#3676): add failing tests for the quick-batch command/workflow markdown

Failing-first tests for Phase 4's markdown-authoring pass (epic #3344,
ADR-1239 "Quick-batch binding"): gsd-quick-batch-workflow.test.cjs
covers commands/gsd/quick-batch.md's frontmatter/objective/process,
gsd-core/workflows/quick-batch.md's byte-size boundary (row 49, ADR
1610 NEW_FILE_CAP) and step-fragment count, the isolation model
(rows 20-22), the executor single-writer invariant (row 18), merge
validation reusing the existing bounded primitive (row 25), the
optional research/plan-checker/verification leaves (rows 16,17,19,
30,31), planning-failure blocking execution (row 29), the submodule
guard (rows 36,44), and the new agents/gsd-planner.md quick-batch
mode (rows 13-15). gsd-quick-batch-quick-regression.test.cjs covers
row 48 (ordinary /gsd:quick stays byte-identical). Named
`gsd-quick-batch-*` (not `quick-batch-*`) so lint-test-file-count's
longest-prefix bucketing doesn't fold these markdown-only tests into
the already-capped quick-batch/quick-batch-dispatch/
quick-batch-command-router production-module buckets from the CORE
pass. These reference files that do not exist yet.

* feat(#3676): author the quick-batch command, workflow, and planner mode

Phase 4 markdown-authoring pass (epic #3344, ADR-1239 "Quick-batch
binding") — the orchestration layer that calls into Pass 1's CLI
verbs (src/quick-batch-command-router.cts).

- commands/gsd/quick-batch.md (new): frontmatter/objective/process,
  delegates argument validation to `quick-batch parse-args`
  (parseQuickBatchArgs) rather than re-deriving the grammar.

- gsd-core/workflows/quick-batch.md (new, 11843 bytes — under ADR
  1610's 32768-byte NEW_FILE_CAP for a brand-new file) + 9 lazy-loaded
  step fragments under gsd-core/workflows/quick-batch/steps/:
  resume-mode, batch-init, research-phase (flag:--research),
  planner-wave (+ nested plan-checker-loop when --validate),
  worktree-dispatch, merge-wave, verification-wave (flag:--validate),
  completion. Covers design doc rows 3-45: capacity/isolation
  resolution (reusing dispatch-isolation-gate.md verbatim), per-DAG-
  layer planning with full-task-catalog prompts and always-required
  depends_on/files_modified frontmatter, serialized worktree create/
  merge/cleanup via the existing worktree.cleanup-wave primitive,
  deterministic wave-order merging, verification routing
  (human_needed/gaps_found), the executor single-writer invariant,
  submodule fail-loud guard, and #1941 fork-base auto-degrade.

- agents/gsd-planner.md: additive new `load_mode_context` bullet for
  `**Mode:** quick-batch`, pointing at the new
  gsd-core/references/planner-quick-batch.md reference (documents the
  always-required depends_on/files_modified contract, reusing the
  existing frontmatter grammar — no new keys). Existing modes
  byte-identical, only a new bullet added.

- src/init.cts (+init-command-router.cts, +command-aliases.cts):
  cmdInitQuickBatch / `init.quick-batch` — model profiles,
  commit_docs, roadmap/planning existence checks, and the
  section_manifest field gating research-phase/verification-wave
  (reuses the existing flag:--research/flag:--validate WHEN_VOCABULARY
  atoms — no new atom needed).

Rows 16-21, 25, 29, 36, 38, 39, 44, 46-50 covered structurally by the
prior test(#3676) commit; rows 3-15, 22-24, 26-28, 30-35, 37, 40-43,
45 covered by construction (verb wiring, single-writer prompt
constraints, crash-window resume via unmodified Phase 3 primitives).

* docs(#3676): regenerate skills/inventory/section-manifest/install-tree; baseline the intentional word-splitting pattern

npm run regen:derived output for the new command/workflow/reference
(epic #3344, ADR-1239 "Quick-batch binding"):
- skills/gsd-quick-batch/SKILL.md (generated from commands/gsd/quick-batch.md)
- docs/INVENTORY.md rows for /gsd-quick-batch, quick-batch.md,
  planner-quick-batch.md, and the quick-batch-dispatch.cjs/
  quick-batch-command-router.cjs CLI-module rows' now-live
  `/gsd-quick-batch` cross-reference (was "(separate, follow-up)")
  + docs/INVENTORY-MANIFEST.json (`node scripts/gen-inventory-manifest.cjs --write`)
- gsd-core/workflows/section-manifest.json (`npm run gen:section-manifest`)
  — research-phase/verification-wave gsd:section entries for the new
  quick-batch workflow
- tests/fixtures/install-tree/*.json (`npm run gen:install-tree`) —
  the new command/workflow/skill/reference files now ship to every
  runtime

scripts/lint-workflow-shellcheck-baseline.json: 3 new entries for
gsd-core/workflows/quick-batch.md's intentional flag-token/$ARGUMENTS
word-splitting (SC2046/SC2086) — the same deliberate unquoted-optional-
flag pattern gsd-core/workflows/quick.md already carries baselined
(e.g. `$DISCUSS_PARAM $RESEARCH_PARAM` in quick.md's own Step 2);
quoting would break the intended "omit this arg when the flag is
false" splitting.

* fix(#3676): close prompt-injection and argv/glob-injection gaps in quick-batch leaf dispatch

Security review pass findings, both confirmed real:

1. HIGH — prompt injection, no boundaries. Every leaf-dispatch fragment
   interpolated the raw, attacker-influenced task ${description} (and
   the shared ${TASK_CATALOG_TABLE}, broadcasting every item's raw
   description into every planner's prompt in the layer) straight into
   Agent() prompt bodies with no boundary. Fixed by wrapping every such
   interpolation in a <security_context> + DATA_START/DATA_END
   boundary, matching the CONCRETE convention already implemented in
   this repo (agents/gsd-debug-session-manager.md, agents/gsd-debugger.md,
   gsd-core/workflows/debug.md) — commands/gsd/quick.md's own
   <security_notes> only asserts this convention in prose, so the
   debug-agent files are the real precedent followed here. Added a new
   <security_notes> block to commands/gsd/quick-batch.md (it had none)
   documenting both this fix and the one below.

2. MEDIUM — unquoted $ARGUMENTS -> argv/glob injection.
   gsd-core/workflows/quick-batch.md and commands/gsd/quick-batch.md both
   ran `gsd_run quick-batch parse-args --raw -- $ARGUMENTS` UNQUOTED,
   causing shell word-splitting and pathname expansion on raw task-list
   text before the parser ever saw it. Fixed at the source: added a
   `--text <string>` form to the `parse-args` verb
   (src/quick-batch-command-router.cts) that accepts the ENTIRE
   $ARGUMENTS as ONE quoted argv element and does the whitespace split
   itself, in Node — which is never glob-aware, unlike the shell.
   Both call sites now use `--text "$ARGUMENTS"`. The `-- <tokens>` form
   is kept for direct/test callers that already have a real argv array.

The SC2086 baseline entry added for the original unquoted line is now
stale (`node scripts/lint-workflow-shellcheck.cjs` no longer reports
it) and has been removed; the two SC2046 entries for the UNRELATED,
still-unquoted `$([ "$VALIDATE_MODE" = true ] && echo --validate)`-style
conditional-flag splitting remain — that line only ever expands to one
of a few known-safe literal strings (never raw user text), matching
quick.md's own already-baselined convention exactly.

Tests: quick-batch-command-router.test.cjs covers the new --text form
(token splitting, glob-shaped text passing through literally
unexpanded, whitespace-only input). gsd-quick-batch-workflow.test.cjs
asserts the DATA_START/DATA_END boundary on every leaf prompt
(research-phase/planner-wave/plan-checker-loop/verification-wave,
including the shared task catalog) and the quoted --text call sites.

* fix(#3676): strengthen test-depth gaps in rows 9, 18, 24, 34, 35

Spec review pass findings — the test matrix claimed "yes" coverage
these assertions did not actually support:

- Row 9 (--jobs 0/-1/abc hostile case): previously asserted rejection
  only. Added an end-to-end assertion (tests/quick-batch-command-router.test.cjs,
  committed alongside the security fix that touches the same file) that
  .planning/quick-batches/ is never created for any rejected value —
  createBatch is genuinely never reached.
- Row 18 (--resume <unknown-batch-id>): previously only exercised a
  hand-corrupted BATCH.json, never a genuinely nonexistent batch
  directory. Added the real nonexistent-id case (also in
  quick-batch-command-router.test.cjs).
- Row 24 (post-planning updateBatchItems racing a concurrent
  completeQuickItem for a different item, both through
  withPlanningLock): zero test existed. Added a property test
  (tests/quick-batch.property.test.cjs, appended — Phase 3's own file,
  no existing test touched) exercising both call orders and asserting
  no lost update in the final on-disk manifest — the same technique
  Phase 3's own row-15 lock-contention property test uses (sequential
  calls through the real lock; a working mutex makes any interleaving
  equivalent to some serial order, so this is the same claim a literal
  concurrent-thread test would make without OS-level threading).
- Row 34 (worktree preserved on merge_failed) and row 35 (undeclared-
  deletion detection): both were previously asserted only at the pure
  routeMergeOutcome level. Added tests/gsd-quick-batch-merge-integration.test.cjs
  using the SAME real-git-fixture pattern tests/worktree-safety.test.cjs
  already establishes for executeWorktreeWaveCleanupPlan (real repo,
  real worktree, a REAL merge conflict / a REAL file deletion diffed
  against declared_deletions) — asserting the actual worktree directory
  survives on disk, not just that a pure function returns a
  preserveWorktree:true field. Named gsd-quick-batch-* so lint-test-
  file-count's bucketing doesn't fold it into any capped module bucket.

Row 48 (/gsd:quick regression) intentionally left as-is per the
reviewer's own framing: the byte-identity claim is already
mechanically proven by the changed-path diff (git diff --name-only
empty on those two paths IS byte-identity), and a genuine execution-
level regression test would require actually running the workflow —
out of scope for this repo's unit-test model (no other quick.md
regression test in this repo does that either).

* docs(#3676): add the changeset and user-facing docs the command needed

Standards review pass findings — both HARD:

- Missing changeset. None of the 6 prior #3676 commits touched
  .changeset/*. /gsd-quick-batch is a new user-facing command;
  CLAUDE.md/CONTRIBUTING.md require one. Added
  .changeset/silly-rams-caper.md (type: Added, pr: 0 placeholder —
  backfilled after the PR opens, matching CLAUDE.md's own documented
  convention and Phase 3's own precedent, #4190's
  .changeset/mellow-yaks-squeak.md). Uses the docs-convention hyphen
  form `/gsd-quick-batch` throughout, never the source-artifact colon
  form (`scripts/lint-docs-command-form.cjs` confirms 0 violations;
  that check scans docs/**, not .changeset/, so it was never actually
  in scope for the fragment itself, but the wording still follows the
  doc convention for consistency, matching how Phase 3's own fragment
  named the not-yet-shipped command).
- Missing docs. Added docs/how-to/batch-quick-tasks.md (Diátaxis
  how-to, matching docs/how-to/handle-quick-and-fast-tasks.md's
  existing convention for /gsd-quick /gsd-fast) covering --jobs,
  --validate, --research, --resume, --file, the capacity/isolation
  interaction, and resume/failure recovery. Cross-linked from
  docs/README.md's how-to index and from handle-quick-and-fast-tasks.md's
  own "Related" section. Added a /gsd-quick-batch section to
  docs/COMMANDS.md (same table format as the existing /gsd-quick
  entry) and docs/features/quick-batch.md (REQ-QB-01..12, same
  frontmatter shape as docs/features/quick-mode.md) — regenerated
  docs/FEATURES.md (179 features) and skills/gsd-quick-batch/SKILL.md
  via the standard generators.

* fix(#3676): close docs-parity, attribution, and generated-registry gaps gsd-test caught

gsd-test's real run against 155e8975b3 found 43 failures, all rooted in
this phase's own new command/workflow never being registered across
~10 independent generated/hand-maintained registries this repo keeps
in parity by convention. Root-caused each, no test weakened or
special-cased.

- help.md ↔ commands/gsd/ bidirectional parity (docs-parity-live-
  registry.test.cjs): added a /gsd:quick-batch entry to
  gsd-core/workflows/help/modes/full.md (the real help.md content;
  gsd-core/workflows/help.md is a thin dispatcher) documenting every
  flag (--file/--jobs/--validate/--research/--resume), matching the
  existing /gsd:quick entry's format.

- gen-section-manifest.test.cjs: quick-batch.md's
  `gsd_run query init.quick-batch` invocation used inline
  `$([ ... ] && echo --flag)` substitutions, which never satisfy the
  test's exact-whitespace-token / assigned-variable detection (the
  trailing `))` glued onto `--research` in the compound substitution
  broke the "exact token" match). Rewrote to the same
  VALIDATE_PARAM/RESEARCH_PARAM two-line pattern
  gsd-core/workflows/quick.md's own Step 2 already uses.

- runtime-launcher-parity.test.cjs: the 8 quick-batch/steps/*.md
  fragments that call gsd_run each needed their OWN embedded copy of
  the canonical shim preamble (every workflow .md that calls gsd_run
  carries its own copy — reading one file does not persist shell state
  into another). Ran `node scripts/sync-runtime-launcher.cjs`, which
  inserted it before each file's first gsd_run call.
  plan-checker-loop.md correctly has none — it never calls gsd_run
  directly.

- Namespace routing (skill-manifest.test.cjs, install-nested-
  layout.test.cjs, runtime-artifact-layout-surface.test.cjs): added
  `quick-batch` to commands/gsd/ns-workflow.md's `requires:` array and
  routing table (same namespace `quick` already routes through), and
  to src/clusters.cts's `utility` cluster (same cluster `quick`
  already belongs to). Verified by hand-running installRuntimeArtifacts
  + applySurface for augment/cline against a real temp install: exactly
  6 top-level gsd-ns-* router dirs, gsd-quick-batch correctly nested
  under gsd-ns-workflow/skills/, never re-flattened.

- mcp-server-catalog.test.cjs: hardcoded command count 71 -> 72 (a
  brand-new command is a real count change, not a bug this test should
  hide).

- model-omit-when-inherit-guard.test.cjs: added the canonical
  `<!-- #2517 model-omit-on-inherit -->` marker block to
  gsd-core/workflows/quick-batch.md (every leaf dispatch — planner/
  researcher/checker/executor/verifier — lives in a steps/ fragment,
  read combined with the host by this test's own readWorkflowCombined,
  same as quick.md's own research-phase.md carries it for its gated
  section). Also fixed a genuine pre-existing inconsistency in the
  test's own "#2711: the guarded set is derived from dispatch sites"
  check: its `nonDispatching` computation read the BARE host file while
  `derived` (the set it's checked against) reads the combined
  host+steps content — inconsistent with that same test file's own
  #2994 doc comment explaining why the combined read is necessary.
  quick-batch.md is the first workflow whose EVERY model="{...}"
  dispatch site lives in a mandatory (never gated) steps/ fragment —
  extracted to stay under ADR-1610's tighter NEW_FILE_CAP for a
  brand-new file — which is what exposed the mismatch. Fixed by using
  the same readWorkflowCombined read in both places.

- skill-frontmatter-contract.test.cjs: shortened
  commands/gsd/quick-batch.md's frontmatter `description` from 107 to
  91 chars (<=100 budget), and added `quick-batch.md` to the hand-
  maintained KNOWN_SKILLS consolidation allowlist with a #3676
  justification comment (a genuinely new first-party command, not a
  consolidation of an existing skill).

- workflow-fragments-emission.install.test.cjs: added `quick-batch.md`
  to the hand-maintained MARKED_WORKFLOWS set (composeWorkflow is
  deliberately NOT a no-op for it — its research-phase/verification-
  wave sections are gated).

- Regenerated all downstream artifacts (npm run build:lib && npm run
  regen:derived && npm run gen:plugin-skills -- --write && npm run
  gen:features -- --write): skills/gsd-quick-batch/SKILL.md,
  skills/gsd-ns-workflow/SKILL.md, install-tree fixtures for
  augment/cline/hermes/qwen/trae/zcode.

- emitted-attribution.test.cjs: agents/gsd-planner.md's #3676 addition
  (one new `load_mode_context` bullet pointing at the new
  gsd-core/references/planner-quick-batch.md reference) grew the file
  124 bytes without an acknowledgment trailer. Acknowledged below —
  the growth is the deliberate, additive, single-bullet change from
  the earlier feat(#3676) commit, not drift.

Verified: npm run build:lib clean, npx tsc -p tsconfig.build.json
--noEmit clean, GITHUB_BASE_REF=next npm run lint:ci fully green
(includes lint-workflow-shellcheck, lint-test-file-count,
lint-docs-command-form). The deep install/spawn/registry tests gsd-test
actually runs (docs-parity-live-registry, gen-section-manifest,
runtime-launcher-parity, install-nested-layout,
runtime-artifact-layout-surface, skill-manifest, skill-frontmatter-
contract, mcp-server-catalog, model-omit-when-inherit-guard,
workflow-fragments-emission) are not part of lint:ci — each fix above
was independently verified by hand-invoking the exact production
function the failing test calls (installRuntimeArtifacts, applySurface,
composeWorkflow, the CLUSTERS union, the section-manifest forwarding
regex) against the real repo tree and confirming the expected shape.

Emitted-Drift-Ack-Growth: gsd-planner.md — additive #3676 quick-batch mode bullet in load_mode_context (one new line pointing at gsd-core/references/planner-quick-batch.md); not drift.

* fix(#3676): trim the /gsd:quick-batch help.md entry to fit the LARGE tier line budget

skill-frontmatter-contract.test.cjs's "feature #3039: tiered help —
size budgets" enforces a SEPARATE line-count ceiling for
gsd-core/workflows/help/modes/full.md (FULL_BUDGET = 844 lines,
tighten-only ratchet, scripts/lib/allowlist-ratchet.cjs's
assertTightCeiling) — independent of the skill-frontmatter description-
length budget and consolidation allowlist I touched in the prior round;
those are unrelated checks in the same test FILE, not the same check.

Root cause: the /gsd:quick-batch entry I added to full.md in the
docs-parity fix round was 17 lines, pushing the file from 834 to 851
lines — 7 over the 844 ceiling. Condensed the entry (merged the
per-flag bullet list into one dense "Flags:" line, dropped from 3
Usage examples to 1) to 844 lines exactly — at the ceiling with zero
slack, which assertTightCeiling accepts (it only fails on
actualMax > ceiling, or on slack > grace when the ceiling is too
LOOSE — zero slack triggers neither).

Verified after trimming: full.md still contains a live /gsd:quick-batch
reference (bidirectional parity) and all 5 argument-hint flags
(--jobs/--validate/--research/--resume/--file) still appear as literal
tokens (docs-parity-live-registry.test.cjs's own flag-coverage check,
re-run by hand against the trimmed content).

Verified: npm run build:lib clean, npx tsc -p tsconfig.build.json
--noEmit clean, GITHUB_BASE_REF=next npm run lint:ci fully green.

* docs(#3676): backfill changeset pr number to 4212

Follow-up to fix(#3676) commits — .changeset/silly-rams-caper.md's
pr:0 placeholder backfilled with the real PR number now that
gh api POST /pulls has returned it (#4212). Matches CLAUDE.md's PR
Number Handling convention and Phase 3's own #4190 precedent
(708c5a3f8c). Doc-only (root-level .changeset/*.md fragment), exempt
from a fresh gsd-test run per pre-pr-gate.sh's DOC_ONLY_RE.

* fix(#3676): resolve prompt-injection-scan false positive on test fixture

tests/quick-batch.test.cjs:232's row 11b regression proves the task-list
parser carries a prompt-injection-shaped task description through
createBatch as inert data, never interpreted. The fixture has to be a
real "ignore all previous instructions..." phrase or the test asserts
nothing, but the full-file --diff scan flagged it once unrelated edits
in the same file pulled it into the changed-file set.

Add the file to prompt-injection-scan.sh's ALLOWLIST, matching the
sanctioned, precedented exemption already used for other legitimate
security-regression fixtures (tests/windsurf-conversion.test.cjs,
tests/health-validation.test.cjs, tests/continuation-grammar-parity.test.cjs)
per DEFECT.PROMPT-INJECTION-SCAN-COLLISION.

---------

Co-authored-by: sim <sim@local>
2026-09-02 22:38:31 -04:00
Tom Boucher
cad70f4f3e fix(#4120): replace shellcheck npm dep with dependency-free downloader (#4121)
* fix(#4120): replace shellcheck npm dep with dependency-free downloader

The `shellcheck` devDependency (added in #4109) pulled in decompress@4.2.1
for archive extraction, which carries an unpatched CRITICAL zip-slip
vulnerability (GHSA-mp2f-45pm-3cg9, CVSS 9.1) plus two moderate findings.
decompress's latest published version IS the vulnerable one -- no patched
release exists upstream, so npm audit fix cannot resolve this by upgrading.

Removes the shellcheck package entirely and replaces its role with
scripts/lib/shellcheck-fetch.cjs: a small downloader using only Node's
built-in https/zlib plus a hand-written tar-entry reader, fetching a pinned
koalaman/shellcheck release directly from GitHub releases. The reader never
uses an archive-supplied name as a filesystem path (the exact defect class
decompress had) -- it only returns the matched entry's bytes; the caller
writes those bytes to a path it constructs itself. Bounds the download with
a 30s-per-hop timeout, consistent with the ShellCheck subprocess's own
timeout. Covers linux/darwin on x86_64/aarch64, matching this repo's actual
CI (lint-tests runs only on ubuntu-latest) and local dev needs; Windows
fails with a clear, honest error rather than silently misbehaving.

Adds tests/lint-workflow-shellcheck-fetch.test.cjs covering the tar-parser
(unit cases plus a fast-check property test per CLAUDE.md's parser-testing
requirement), a security behavioral pin confirming traversal-style entry
names are treated as opaque strings never filesystem paths, and boundary
coverage for the redirect-following logic's MAX_REDIRECTS limit
(limit-1/limit/limit+1, via an injectable transport, no real network I/O).

npm audit: 0 vulnerabilities (was 1 critical + 5 moderate). The lint script
reproduces the identical result against the current tree:
"212 pre-existing finding(s) from baseline, 0 new" -- no behavior
regression, no baseline changes needed.

* fix(#4120): register shellcheck-fetch.cjs with the installer

scripts/lib/shellcheck-fetch.cjs shipped without being added to
GSD_SCRIPTS_LIB_FILES in bin/install.js, which would have left it orphaned
on uninstall and broken the golden install-tree fixtures for every runtime.
Adds the entry and regenerates the 19 affected fixtures via
npm run gen:install-tree.

* docs(#4120): add changeset for the decompress CVE fix

---------

Co-authored-by: sim <sim@local>
2026-08-31 22:31:23 -04:00
Cody Anderson
8c9265d4e5 fix(#3724): warning-only Dimension 3b findings no longer force the revision loop (#3758)
* fix(#3724): stop advisory Dimension 3b findings from forcing the revision loop

Dimension 3b (undeclared/temporal coupling, #1954) is spec'd "never a
blocker" but tagged severity: warning — the tier plan-phase's revision
loop counts as must-fix — and the planner is never taught the rule, so
every multi-wave phase touching shared mutable state replans at least
once, and intentionally coupled plans re-flag identically every
iteration to the stall prompt.

Three coordinated changes:
- gsd-plan-checker: retag 3b to severity: info, the tier
  references/revision-loop.md already exempts by design; recognize a
  coupling_justified frontmatter declaration in the Do-NOT-flag list so
  deliberate pairs converge. Additions are offset by trimming 3b
  motivation prose — the checker sits 45 bytes under its LARGE hard cap.
- plan-phase step 12: INFO-only accept — an issues block with zero
  BLOCKER/WARNING entries accepts the plan and surfaces the advisories
  instead of re-entering the revision loop. Real blockers and warnings
  still gate unconditionally.
- gsd-planner: slim pointer in assign_waves to the new
  progressive-disclosure reference gsd-core/references/planner-coupling.md
  (the planner sits 19 chars under its own cap), which carries the
  shared-mutable-state rule and the coupling_justified escape hatch so
  first-pass plans avoid the finding when the coupling is unintentional.

Documented the coupling_justified field in docs/reference/plan-md.md.
Growth acks per #2914; inventory manifest and install-tree fixtures
regenerated for the new reference file.

Closes #3724

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* test(#3724): pin Dimension 3b at severity: info

The severity retag makes the old assertion (severity: warning) stale;
lock the advisory tier from both directions — info must be present,
warning must not — so a future edit cannot silently re-arm the
revision-loop trigger.

Refs #3724

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* chore(#3724): changeset fragment for PR #3758

Refs #3724

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* docs(#3724): roster planner-coupling.md in docs/INVENTORY.md

The new reference was enumerated in the manifest and all 19 install-tree
fixtures but missing its row in the Modular Planner Decomposition table —
the roster half the manifest-sync test cannot check. (Review Blocker.)

Refs #3724

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* test(#3724): cover all four acceptance criteria (review round 1)

- plan-checker-coupling: the 3b severity assertion is now a PARITY check
  deriving the exempt tier from revision-loop.md's flow instead of
  hardcoding info — editing either side alone reds the suite. New
  describe pins the other three criteria: plan-phase's INFO-only accept
  clause (proven failing-first), the BLOCKER + WARNING count staying
  intact, the coupling_justified Do-NOT-flag exemption + fix_hint, and
  the planner pointer + planner-coupling.md content.
- ack fragment: $comment's plan-phase figure corrected to +79B; the 2775
  pin note carried forward into the gsd-planner.md entry, updated for
  upstream's #3761/#3764 Rule-paragraph anchor (which this diff leaves
  verbatim).

The parallel-dependent-plans re-anchor this commit originally carried was
superseded by upstream #3764 during review; this branch no longer touches
that file.

Refs #3724

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): review round 2 — align the stance enumeration, complete the template contract

MAJOR: <adversarial_stance>'s severity enumeration gains the INFO bullet so it
agrees with Dimension 3b's 'ALWAYS INFO' mandate instead of contradicting it.
Funded by extracting the inline <examples> block to the new progressive-
disclosure reference gsd-core/references/plan-checker-examples.md (@-inlined
from the same spot; #1949 precedent), which also restores the 3b motivation
clause round 1 traded away (Nit 4) and nets the agent file SMALLER than base
(49107 -> 48486) — the extraction the byte pressure was owed.

MINOR: gsd-core/templates/phase-prompt.md now carries coupling_justified, and
the field's shape becomes one 'plan-id: reason' string per coupled peer so a
plan justified against two peers can express it; docs/reference/plan-md.md's
Type column names the shape.

NIT: the 3409 ack's plan-phase entry no longer calls the #1168 workflow
ratchet an 'XL tier'.

Acks and derived artifacts updated accordingly (checker entry removed — a
shrink needs no ack; INVENTORY roster row + regen:derived for the new file).

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* test(#3724): derive the 3b negative severity assertion (review round 2)

Every severity token in the 3b span must BE the tier revision-loop.md exempts,
replacing the hardcoded severity:warning negative — if the loop's exemption
ever moves, the failure names the real conflict instead of blaming the agent
file with a mutually-unsatisfiable pair.

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): refit the planner coupling pointer under the char cap

Upstream #3299 (PR #3390) grew agents/gsd-planner.md to 49146 chars at the
base, leaving 5 chars of headroom where the +16-char pointer was measured
against 13 more. The pointer prose shortens to 'Non-file coupling:' —
49150 chars, back under the strict 49152-char cap — and the ack figures
follow. The @-path the tests pin is unchanged.

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): re-home the plan-phase ack after the #3823 spent-fragment sweep

Upstream #3078/#3823 deleted all fully-spent ack fragments, including
3409-unreachable-guard-arms.json, which carried this PR's plan-phase.md
+79B append. Per the collision remedy that sweep added: take the deletion
and home the still-live entry in this PR's own fragment. Figures
re-measured at this merge base (90871 -> 90950 LF bytes).

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): absorb the spent #3172 plan-phase fragment into this PR's ack

Upstream #3825 shipped 3172-stated-failing-direction.json naming only
plan-phase.md, now spent at the base — colliding with this PR's live
plan-phase entry. Per the #3003 pattern the fully-spent single-path
fragment is deleted and this fragment stays the path's one source;
figures re-measured at this base (93073 -> 93152 LF bytes).

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): review round 3 — true up the ack figures, restore the wave comment

The fragment's absolute sizes are re-measured and anchored to base
e40e9670 (planner 47259 -> 47330 chars, checker 45537 -> 44916 B,
plan-phase 91186 -> 91265 LF bytes), with a note that absolutes rot as
next moves — the deltas are the durable claims. The round-1 removal of
the '# Implicit dependency: files_modified overlap forces a later wave.'
pseudocode comment offset headroom base drift had already returned, so
it is restored (findings 2-3). Changeset gains the (#3724) backlink
(finding 4).

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): review round 4 — close the verify-work surface, harden the boundaries

BLOCKER: verify-work.md's verify_gap_plans is the second multi-plan
consumer of the checker's sentinels, and its ISSUES FOUND handler entered
revision_loop with zero severity parsing — the guaranteed replan #3724
fixed in plan-phase, alive on the gap-closure surface. The handler now
counts BLOCKER + WARNING and accepts INFO-only returns with advisories
displayed. The checker's INFO stance bullet is reworded to the claim that
is true everywhere ('revision gates count only BLOCKER + WARNING').

Minor 1: plan-phase's iteration_count >= 3 arm recounts severities, so an
INFO-only third check accepts instead of halting on a '0 issues remain'
user gate. Minor 2: the coupling_justified exemption now requires the
entry to NAME the other plan, closing the blanket-suppression reading.
Nit 1: INVENTORY row states the extraction buys cap headroom, not context.
Nit 2: the advisory display gains a concrete format on both surfaces.

Ack fragment re-anchored at base ddde001a: verify-work.md +264B (new
entry), plan-phase.md +395B, checker still net negative (-512B).

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* test(#3724): pin the verify-work accept and the iteration-cap boundary (review round 4)

Two wiring assertions: verify_gap_plans' ISSUES FOUND handler gates on
BLOCKER + WARNING and accepts INFO-only blocks, and plan-phase's
iteration_count >= 3 arm recounts severities instead of gating advisories
— the limit+1 boundary of the gate this PR fixes.

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): review round 5 — fail closed at the gates, surface the advisory

Blocker 1: the checker's step-10 status rule routes an INFO-only result to
## ISSUES FOUND (with a new ### Advisories (info) template section and a
severity-aware recommendation) so the orchestrator receives the block and
displays the advisory instead of silently accepting a bare PASSED.

Blockers 2+3: all three gate surfaces (plan-phase step 12 both arms,
verify-work verify_gap_plans) carry one canonical clause verbatim — an entry
whose severity is missing or unrecognized counts as a BLOCKER (fail closed) —
making the accept condition an explicit-INFO whitelist while keeping
issue_count coherent for stall math.

Major 1: the INFO stance bullet scopes its claim to the plan-phase and
verify-work gates (quick mode's loop still revises on any ISSUES FOUND).
Major 2: INVENTORY row and ack $comment state the extraction's real trade
(readability, +0.6 KB eager runtime context), not a cap remedy.
Minor 1: plan-md.md marks coupling_justified as prompt convention, unvalidated.
Nit 1: ack absolutes re-anchored at base 1e67ec97; checker now +120B and acked.

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* test(#3724): pin the round-5 contract — fail-closed parity, INFO-only return shape

New: three-surface verbatim parity test for the fail-closed clause (Blockers
2+3); checker return-contract test for the INFO-only ## ISSUES FOUND route and
advisories section (Blocker 1). All seven newly pinned tokens are absent at
f3a5682d, so each new assertion fails pre-fix.

Updated: accept-clause regexes track the explicit-INFO whitelist wording;
the severity sweep scopes to the span's fenced yaml examples via
yamlSeverityTiers (round-5 Minor 3, applied to the blocker negative too);
the iteration-cap comment states it is a prose pin, not an executed boundary
check (Minor 4); splitLines call sites document the line-pin coupling (Nit 2).

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): adopt next's line wrap in the 3b motivation clause — drops a wrap-only hunk from the diff

Byte-identical content; the wrap difference was an artifact of the round-1
base adaptation predating upstream's #3003 landing.

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

* fix(#3724): review round 7 — gate every checker consumer, not just the two audited ones

Blocker: quick/steps/plan-checker-loop.md (issue-named in #3724) gets the
same canonical fail-closed clause and explicit-INFO whitelist accept as
plan-phase/verify-work — an INFO-only result proceeds instead of entering
quick mode's revision loop.
Major: import.md plan_validate handles the checker return by severity
(INFO-only never blocks an import) and is added to agent-contracts.md's
consumer enumeration, which had omitted it.
The checker's INFO stance bullet drops the quick-mode carve-out — the claim
is universally true again now that every consuming gate is severity-aware.
Minor: an applied coupling_justified exemption is surfaced as its own info
advisory so a stale one-sided declaration stays observable.
Nit: plan-phase's revision-iteration Display line is explicitly conditioned
on not having already proceeded to step 13.

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM
Emitted-Drift-Ack-Growth: import.md — #3724 round 7: the plan_validate step's checker-return handler becomes severity-aware — counts BLOCKER + WARNING failing closed and accepts an explicitly-INFO-only return with advisories displayed instead of blocking the import

* test(#3724): pin the round-7 surfaces — five-gate parity, quick/import accepts, exemption visibility

The verbatim fail-closed parity test extends to quick/steps/plan-checker-loop.md
and import.md plan_validate; new assertions pin quick mode's INFO-only proceed,
import's never-blocks accept, import.md's presence in agent-contracts.md's
consumer row, and the surfaced coupling_justified exemption advisory. All four
newly pinned token families are absent at the pre-fix head, so each new
assertion fails first.

Claude-Session: https://claude.ai/code/session_01GshUzpGjoxiw6uNRiFMHvM

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-31 19:08:11 -04:00