v2.2.0 - the rest of the residue #90

Merged
john merged 146 commits from fix/rung-effort-check into main 2026-08-04 18:15:31 +00:00
Owner

The six requirements v2.1.0 left unpicked, delivered: the config read face merges a layer once whatever its spelling (CFG-02), the git guard's parser is deleted for one small anchored reader (TOK-02), the release seam refuses downgrades and promotes Unreleased into the dated heading (REL-03), every shipped rung-ladder claim is true or gone (DOC-01), per-role effort is configurable and update-surviving (RNG-02), and the documented Forgejo install path is proven live from a fully cold state with committed transcripts (HST-02). Audit gate PASS (6/6 traced, 32/32 criteria covered). Manifest at 2.2.0; tag to be cut on main after merge.

The six requirements v2.1.0 left unpicked, delivered: the config read face merges a layer once whatever its spelling (CFG-02), the git guard's parser is deleted for one small anchored reader (TOK-02), the release seam refuses downgrades and promotes Unreleased into the dated heading (REL-03), every shipped rung-ladder claim is true or gone (DOC-01), per-role effort is configurable and update-surviving (RNG-02), and the documented Forgejo install path is proven live from a fully cold state with committed transcripts (HST-02). Audit gate PASS (6/6 traced, 32/32 criteria covered). Manifest at 2.2.0; tag to be cut on main after merge.
john added 146 commits 2026-08-04 18:10:27 +00:00
Six completed phases leave ROADMAP's live list and Phase Details, and their
.planning/phases/<N>/ directories leave the working tree. The v2.0.0 tag and
git history are their archive; the phase-6 deferrals are already carried in
CAPTURE.md. Overview now states no active cycle and records v2.0.0 in the
shipped lineage.
REQUIREMENTS: the seven v2.0.0 rows move from Traceability to Shipped so the
next cycle's audit starts clean; Active and Traceability are emptied.
PROJECT: the v2.0.0 Active block becomes seven Validated rows, and Active now
holds the v2.0.1 candidate pool - phase 6's seven deferred defects, the
fields_version blind spot the closing audit surfaced, and the untriaged
CAPTURE/issue backlog - plus the three items carried forward unchanged.
STATE: cursor reset to phase 1 of 0 (no active cycle), next /cad-phase add.
COV-01 the coverage gate's fieldless-checklist exemption plus the plugin-version
assertion and the persisted verifier envelope (#77), TRI-02 the review triage
gate plus dropping the reviewer's pre-suppression clause (#66), REV-03
concurrent reviewer dispatch (#88) plus a per-reviewer prompt-token cap (#16),
TOK-02 the guard parser redirected off rail 3's evasion grammar and onto
destructive-local ops behind git.on_destructive (#25), REL-03 the release seam's
silent downgrade (#87) and empty release section (#86), CFG-02 phase 6's seven
deferred config-reach defects and their shared layer-identity root, DOC-01 the
false rung-ladder claim and route.mjs's unsurfaced warnings, RNG-02 per-role
effort configurability (#54), HST-02 the install path verified live.

A minor, not a patch: TOK-02 removes a shipped documented capability and adds
both a new config key and a newly guarded command class. REL-03 is why that is
recorded explicitly rather than assumed - the release seam derives the shipping
number from PROJECT.md's Active prose and has no downgrade guard.

TOK-02 was scoped as a straight rip-out of the rail-3 tokenizer and changed
shape during the triage: #25 needs the flag-aware parsing a rip-out deletes, and
it guards the class that destroys uncommitted work rather than the recoverable
one, so the parser is redirected instead of removed.
Phase 1: The gate that proved nothing (COV-01). The cycle opens here because
every later requirement is checked through this gate, and v2.0.0's closing audit
proved it could report green over a phase it never checked.

Five success criteria, all falsifiable against artifacts that already exist:
phase 6's shipped checklist is the fixture for the exemption defect, phase 3's
is the fixture for the exemption that must survive, and the version assertion
targets the skew that caused the downgrade rather than the symptom.

Overview rewritten from 'no active cycle' to the v2.1.0 cycle, cursor
re-derived to phase 1 of 1.
Cursor set to paused with the resume pointer. Nothing in flight: the tree was
already clean, so there is no WIP commit to make.
A dispatch carries the agent file NAME and nothing else, so the effort in
that file's frontmatter is the depth that actually runs, and nothing
compared the two. rungBodyIssue holds a file against its own frontmatter,
so a file that is internally consistent and externally wrong passed it,
and check 8 reads the rung out of the filename rather than out of the
file. Give cad-planner-xhigh.md an effort: high and the resolver, the
transcript's subagent_type and the escalation reason all report xhigh
while nothing ran at it. Subagent turns log no effort at all, so no
observable downstream contradicts them either.

All 19 rung files already agreed, so this is a guard, not a repair.

- lib/rung-agent.mjs: rungEffortIssue(stem, effort), pure, reverse lookup
  through RUNG_FILES so a filename suffix is never the source
- self-verify.mjs: check 7b over every agent file's frontmatter, problem
  kind rung-effort-mismatch, named in the checked list
- rung-agent.test.mjs: 6 rows, including the analyzer pair a
  suffix-derived check would invert
- INTERNALS.md: two things keeping the price honest is now three
Both were ignored to keep candid working notes out of the public repo.
/cad-capture's commit step is structurally impossible while
.planning/CAPTURE.md is ignored, which is what surfaced it.

RESUME.md stays ignored.

- design-notes/, 49 files, 576K
- .planning/CAPTURE.md, 96K
The repo config was empty, so every value came from the global layer. Pin
only the deltas this project actually needs: critical stakes, retry at the
harder rung, a real test command, a 15 minute subagent timeout, six
concurrent agents, an auto-created integration branch off main, no tag at
close (tags get cut after the merge), a 10 minute provider timeout, second
model consults on, an adjudicated diff gate, and deepseek's cheap tier
pointed at v4-flash so no trigger resolves to null. Everything else stays
inherited.

worktree.baseRef was unset everywhere, so worktrees forked from the remote
default branch and arrived without this phase's unpushed CONTEXT and PLAN
files. That made parallel execution fall back to sequential on every run.
Setting it to head in the project settings fixes it for every clone.
`criteria-coverage` read an empty `criteria` list as "this CONTEXT declares no
AC<N> ids", so an id written in any shape the grammar refuses collected the
legacy exemption instead of the `fieldless-checklist` break. Seven shapes
reproduced it: `- [ ] AC1 no colon`, `- [ ] **AC1**: x`, an indented bullet,
`- AC1: x`, `* [ ] AC1: x`, `### AC1: x` and `1. AC1: x`, each returning
`legacy` with no breaks while `context_issues` named the id in the same
envelope. The heading half of that hole was already closed by the near-miss
gate; the criterion-line half was not, and it is the larger population.

`classifyAcceptanceCriteria` now reports `declaresIds`: `some` when a criterion
parsed or a reported line carries an id in declaration position, `none` when
nothing does, `unknown` for a near-miss heading whose section is never walked.
The exemption requires a provable `none`, which is what keeps LEGACY_REASON's
stated sentence true of every phase that collects it. Reported by the
classifier rather than re-scanned from `issueText`, which is a display copy.

The criterion grammar itself is unchanged: admitting those shapes as criteria
stays deferred. What changed is what an empty list is allowed to prove.
The adjudicated gate handed its survivor list back to the firing workflow
"to act on", which reads as a work order the model starts executing. It now
presents the survivors as a numbered list, ends the turn on which to act on,
defaults to NONE, and forbids acting on anything the user did not name.

The gate is authored HERE only (D-01); the firing sites point at it rather
than restating it. The auto_close carve-out (D-04) keeps the one documented
unattended close from blocking forever on a prompt. cad-verify's fire()
fix list is named as reaching the same gate (D-11).
Step 3 now runs the triage gate (pointed at review-triggers.md § 6, not
restated) when the resolved gate is adjudicated, acts only on the survivors
the user names, and re-fires pre_ship ONCE - bounded, so the sentence cannot
install the review->revise->review convergence loop the gate forbids.

The two asks that now collide are named where they run: triage defaults to
NONE, the step-4a publish ask has no default and never gets one. The
git.auto_close carve-out (D-04) keeps the unattended close unattended: the
gate does not prompt, triage is NONE by construction, and land-cleanup.mjs
gate's blocker/high halt stays the only consequence.

Budget regenerated in the same commit (7898 -> 8978); the file sat at exactly
its budget, so the prose alone would report budget-overrun.
plan.md, verify.md and execute.md are the adjudicated firing sites whose
skills preload the workflow rather than review-triggers.md, so each carries
the one-line rule plus an explicit RE-READ of § 6 Consequence instead of a
bare pointer (D-10) - a pointer alone leaves the gate out of context, which
is the current behaviour AC1 exists to remove.

plan.md's "adjudicated -> apply the surviving, grounded findings to the plan
file(s) directly" becomes triage-then-apply-only-what-was-picked, with a
success criterion to match. verify.md gates the fix LIST a fire() returns,
not the per-item apply/re-plan/leave ask that already triages correctly
(D-11). execute.md carries it at all three fires: the sequential diff, the
parallel per-plan diffs, and phase_diff, which is adjudicated at critical.
The risk_surface arm is stated as untouched - a matched surface still halts
and triage is not an override for it.

Budgets regenerated in the same commit; all three sat at exactly budget.
The guardrail read "No severity inflation; no padding with style nits that do
not change behavior." Only the padding half is gone. Keeping "No severity
inflation" is DELIBERATE, not a half-done edit: the blocking gate keys off the
blocker/high threshold, so a reviewer that inflates severity can hard-stop a
phase on a nit (#66's "what must not be lost").

The padding half is the one filter that cannot read the surrounding code - the
reviewer calls a finding a style nit without knowing what the adjudicator, who
CAN open the file, would have made of it - so it suppressed before the only
step qualified to judge. No positive "report everything" replaces it: <stance>
already demands a falsification attempt and <returns> already accepts an empty
findings list.

Neighbouring filters left alone (D-12), cad-plan-checker-contract left
byte-unchanged (D-05), no agents/ rung file touched (D-13).
review-triggers.md step 4 opened "For each reviewer in the set, in parallel
where the host allows:" - a loop shape wrapped in a capability hedge, which
produces exactly the serial behaviour it means to forbid (#88). It now issues
the resolved set in ONE message, cites seams.md Concurrent dispatch, and says
serialization is correct only when one dispatch consumes another's output,
which a reviewer set never does. The hedge is gone: a rule the model may
decline is a suggestion.

Step 3 states what seams.md:122 already required and step 4 never said: one
resolve serves the set, reused by every dispatch, because payloads differ and
routing does not (#88 AC2).

Two surfaces move in the same change. decision-review.md quoted the deleted
sentence verbatim and now cites step 4's batch (D-15); no self-verify check
detects a citation of prose that no longer exists. execute.md's parallel item
1 said "one dispatch per message, in the background" while citing the seams.md
rule that names those very executors as a ONE-message batch - the two surfaces
disagreed about the same batch.
Check 10, `unbatched-dispatch`: a block under cadence-core/workflows or
cadence-core/references that claims concurrency for a set of dispatches while
handing it out one at a time - or while hedging on what the host allows - and
never says the set goes out in one message. Without it the prose repair in
58ab673 is UAT-walk-only and the loop-shaped restatement returns on the next
edit (#88 AC3).

The rule is a pure lib (lib/dispatch-phrasing.mjs, the config-reach /
route-cells split): MASK code spans and fences, BLOCK at blank lines, headings
and list markers so one compliant item cannot excuse its neighbour, then FLAG.
The domain is the concurrency CLAIM rather than every dispatch instruction, so
prose that describes dispatch without issuing it and prose that serializes on
purpose both stay legal.

Scoped to those two directories: references/ is outside the weighed walk, so no
other check reaches it, and skills/agents/templates carry no dispatch
instructions. Accepted cost, stated in the lib: a heuristic over prose can
false-positive, and the fix is to narrow the pattern there rather than bend a
correct surface around it.

8 lib rows (including the exact sentence #88 filed and its shipped
replacement) + 3 self-verify rows, the third pinning the directory scope.
review.max_prompt_tokens (default 120000 estimated tokens) bounds review AND
consult. Bounding review alone would leave the identical defect one function
away for the next sweep to refile: consult is the same script hitting the same
paid provider over the same HTTPS path (#16, D-07). The free claude-subagent
reviewer never runs this script, and the exemption is stated rather than
implied.

Over-cap is a REFUSAL before any request - {ok:false, reason:"over-cap"} -
not truncate-and-send, which still pays the provider and returns findings on a
fragment while reporting as though it saw the whole artifact, and not
warn-and-send, which changes no outcome at all. It needs no new caller
machinery: review-triggers.md step 4 already handles any ok:false.

The estimate reuses lib/surface-weight.mjs's chars/4 proxy rather than
reimplementing it - zero runtime deps forbids a real tokenizer, and both #16
and REV-03 ask for a token cap. The default sits just under DeepSeek's ~128k,
the tightest window of the three shipped providers. Unlike the timeout the cap
is not clamped from above: no host ceiling to overflow, and raising it is a
call the user made in writing. No new CLI flag, so the CONTRACTS table in
self-verify.mjs is unchanged.
A key without its four surfaces turns CI red three different ways, so the
schema entry, the reach row, the catalog row and the prose land together.
The reach is genuinely narrow - the free claude-subagent reviewer never runs
review-provider.mjs - so "cross-model provider calls only" appears verbatim in
the key's own purpose, where a user setting the value reads it, or check 9
reports unstated-reach.

seams.md gains over-cap in the call-review-provider degradation vocabulary
with the cap, its units and the subagent exemption; review-triggers.md step 4
says an over-cap payload arrives as reason: over-cap, handled by the ok:false
rule already there.

Also repaired while in config.md: the review.triggers.<t>.gate row described
adjudicated as "ground then hand off" - the exact work-order framing 7d3b60a
removed from review-triggers.md § 6. Same drift species as decision-review.md
in 58ab673, and no self-verify check detects it: the checks read config
tokens, invocations and paths, never prose semantics.

[deviation] self-verify.test.mjs's <t>-expansion fixture enumerates every
schema key so none reports inert; the new key made it incomplete and failed
the row. Added the key to the fixture.
METHOD.md and README.md both ended adjudication at the verdict ("the main
model grounds and owns the verdict"), which is the claim this phase makes
false. docs-verify catches drift, not omissions, so the update is an
execution task rather than a later sweep.

METHOD's "Adjudication inverts the hierarchy" gains a third paragraph: what
survives is not a work order, it is a numbered list the session asks about,
default none, at the four firing sites - with the opt-in unattended close as
the one exception. README gains one sentence in the paragraph's own voice.

skills/cad-plan-review/SKILL.md's step 3 cited the plan-creation flow as its
contrast case ("not the plan-creation flow where cad-plan applies them"),
which stopped being true when plan.md started triaging. The clause before it
("Do NOT auto-apply changes to the plan") was always correct and is why the
file was otherwise scoped out.
The cap measured string parts only, so a non-string instruction/artifact/
situation cleared a truthiness-only shape check, estimated ~0 tokens, and was
serialized into the request anyway - the unbounded payload the cap exists to
stop was the one input that walked past it. Verified before the fix in a
network namespace: the same 480KB body returned over-cap as a string and
transport as an object, i.e. the request was built and attempted.

Both paid commands now require their payload fields to be strings. The
non-string filter in estimatePromptTokens stays as a safety net and says so;
the type gate is upstream, because the cap can only bound what it can measure.
The no-prompt carve-out sat in the shared adjudicated arm that plan.md,
verify.md and execute.md all re-read, so a repo opting into the unattended
land close suppressed the triage ask at three sites it was never scoped to -
discarding grounded survivors while citing a halt (land-cleanup.mjs gate) that
exists nowhere outside /cad-land. D-04 scoped it to the unattended close; the
prose did not. It now says pre_ship ONLY and states why no other trigger reads
the key.

verify.md conditioned its gate on 'When that fire() is adjudicated', but that
fire names no wiring-table trigger and fire() step 1 keys the gate off the
trigger name, so the condition had no source while review-triggers.md asserted
the same list was gated unconditionally. Both surfaces now say the fix list is
always triaged, and say why it has no resolved gate.

review-triggers.md is preloaded into two skills per invocation, so the
additions are paid for by subtractions: the effort-mismatch example, the
timeout anecdote's second measurement, and the convergence-loop restatement.
File lands at 12900, exactly D-21's ceiling.
'Four gates end this way' named the /cad-execute diff review, which is
off/advisory/blocking by level and never adjudicated by default - METHOD.md
says so itself sixty lines earlier - and omitted phase_diff, which is
adjudicated at critical and is wired to the gate. The list was written from
the plan's 'four sites' framing rather than from the shipped table.

It now separates the three that triage as shipped from the two that triage
wherever their gate resolves adjudicated, and names the unattended exception
at pre-ship rather than in general.
The shipped check fired only when a concurrency claim was accompanied by a
loop head or a host hedge, and one compliant sentence excused the rest of its
block - so it returned [] on the exact sentence this phase deleted from
workflows/execute.md when that sentence is read on its own. Five changes, each
measured over the 33 markdown files under cadence-core/workflows and
cadence-core/references: table rows segment per line, evaluation is per
sentence with a line map so the detail names the offending sentence's own
line, the flag rule grows a third arm (concurrent + dispatch verb +
distributive) scoped to the sentence while the claim scopes to the block,
LOOP_HEAD becomes SERIAL_SHAPE and BATCHED accepts the batch-affirming forms,
and the quote length rises to 200 so the phrase that failed is in the detail.
maskCode now tracks which marker opened a fence, so a ~~~ example cannot be
closed early by a nested ``` fence.

Zero false positives on both scoped directories. The four rejected variants
cost, measured: no table boundary -> 2 (config-reach.md, review-triggers.md);
shipped BATCHED vocabulary -> 1 (execute.md:188); the verb arm scoped to the
block -> 2 (git.md:78, seams.md:59); the claim scoped to the sentence -> 0 but
the deleted execute.md sentence goes unnamed again.
No behaviour change: the call site, the directory scope, the kind and the
checked string are all correct. What was false after the widening is what the
file SAYS - the header entry and inline comment described a two-arm,
per-block rule. Both now state the per-sentence rule, and the inline comment's
scope rationale is corrected: skills/ does carry a concurrent-dispatch
instruction, so the true reason for the two directories is that they are where
dispatch instructions are authored and that references/ is reached by no other
check at all. Widening the scope stays a separate decision, still pinned as
out of scope by its own test row.

Three seam rows added: the bare concurrent set-dispatch (0 problems on the
first-shipped rule), the references/ half of the scope, which no test had ever
pinned, and the two-sentence item where a compliant sentence used to excuse
its neighbour.
The widened BATCHED vocabulary accepted `in the batch` / `across the batch` /
`the whole batch` as proof the set went out in one message, and it was tested
first, so it suppressed every arm. That made the guard blind to the regression
it exists to prevent: restoring `one dispatch per message, in the background`
into the shipped execute.md item 1 - which already says `the whole batch` -
returned no problem at all, and `For each reviewer in the batch, in parallel
where the host allows` went from one problem under the pre-widening rule to
none under the widened one.

Split the vocabulary: the MANDATE (`in/as one message|batch`) still excuses a
sentence outright, while a BATCH_AFFIRMING form excuses an elaboration only
when the sentence neither serializes nor hedges on the host. SERIALIZES names
the hard shapes for that carve-out, leaving the bare loop heads (`for each`,
`for every`) out of it so execute.md's "reuse it for every executor in the
batch" stays legal.

Three failing-capable rows pin it, all of them [] before this change.
Two mechanical defects in the widened rule, both latent - the repo is green
before and after.

Sentence splitting cut at any period followed by whitespace, so `e.g.` and
`i.e.` split a compliant sentence and reported the half that no longer carried
the mandated phrasing: `Dispatch each reviewer in parallel, e.g. all four of
them, in one message.` was one problem. That abbreviation is house style in
both scoped directories - 10 files carry it, 4 of them references/
review-triggers.md - so one edit adding a batch instruction beside one reds CI
on correct prose.

Fence tracking captured only three marker characters and ignored the info
string, so a ``` shown inside a ```` container closed it early and leaked the
deliberately-wrong example back into prose, and `~~~ not a closing fence` was
read as a boundary. It also matched any indentation, so a four-space-indented
`~~~` sample - an indented code block, not a fence - opened a fence that
blanked every remaining line, silently disabling the check with ok:true.

Four rows pin both, each one red before this change.
The widened rule matched vocabulary, not mood, so a compliant block reported
its own reasons for being compliant. Four shapes, all measured at e9bd733:

  rationale  "Issue the whole set in ONE message. Serializing it - one
             dispatch per message - only adds latency on the parallel path."
             -> 1, on the sentence that EXPLAINS the rule
  negation   "Dispatch all reviewers in one message; never for each reviewer
             in turn, and never in parallel where the host allows." -> 1, on
             the clause that FORBIDS the shape
  inflection "On the parallel path every worktree issues its own findings, and
             all of them land in one report." -> 1 (`issues` as a third-person
             verb, `all of them` as a back-reference)
  catalog    a `phase_diff` wiring row ("... after all worktree batches are
             sent ...") and a `plan` row ("fired for every plan on the
             parallel path") -> 1 each

All four are 0 now. A sentence ISSUES work when a BARE-FORM dispatch verb
opens a clause - the sentence itself past any list marker, or the position
after `|`, `:`, `;`, `,`, `(`, `[`, or a conjunction, modal or infinitive
`to`. `never` and `not` are not lead words, so a negation excludes itself from
the shape it forbids; an inflection marks every mood but the imperative. Both
arms require it, and the serial arm also takes a trailing colon, which issues
the list under it - the exact shape #88 filed, which carries no verb at all.

Sentence splitting now cuts at bracket depth zero only, so
`(conventions.md Parallel work; seams.md concurrent dispatch)` stays one
citation instead of a compliant half and an unbatched-looking half; that style
appears at config-review.md:21, decision-review.md:69, phase.md:49,
plan.md:139 and new-project.md:279. Only a bracket that CLOSES suppresses a
split, or one stray `(` would glue a block into a single sentence where any
compliant clause whitewashes every offender after it.

Measured after: 0 issues over the 33 files under cadence-core/workflows and
cadence-core/references; `Dispatch each reviewer concurrently.` still 1; the
pre-phase execute.md item 1 still 1, quoting `one dispatch per message`; the
two-sentence whitewash still 1, quoting the second sentence; the shipped
execute.md item 1 still 0. Eight rows added, six of them red before this
change.
METHOD.md, self-verify.mjs's numbered header entry and its inline comment all
described a rule that matched vocabulary. The rule matches MOOD: only a
sentence that ISSUES the set can violate a rule about how the set is issued,
and the serial arm also takes the trailing colon that introduces a list. All
three now say so, and the lib's doc comment says why - a rationale, a negation
and a catalog row carry the same words in another mood.

CAPTURE.md's two "deliberately NOT fixed in the gaps closure" rows (the serial
arm needing no dispatch verb, the eager arm flagging descriptive prose) are
closed by e9b05d4, marked with the measurement the deferral asked for: 33
in-scope files at 0, and every false negative the gaps plan closed still
reported. The table-row row stays open and is restated accurately - it is now
the accepted cost of one row per block, not a wording accident, and a test pins
both sides of it. SUMMARY.md's matching deferral paragraph and its stale
"four open items" / 1130-test count are corrected.

Comment-only in self-verify.mjs; no behaviour changes.

Measured: node --test cadence-core/bin/*.test.mjs -> 1138 pass / 0 fail;
npx tsc -p tsconfig.ci.json exit 0; self-verify.mjs ok:true, problems [],
dispatch-phrasing in checked.
mergeLayers compared nothing between GLOBAL_CONFIG and the repo file, so one
file reached under two spellings (a symlink, a relative path, --global) was
read twice: one broken file earned two identical warnings, and `source` named
a repo layer the user does not have. It now takes each path's filesystem
identity BEFORE either read - the same realpath/parent-dir fallback the write
face uses - and reads a shared file once, into the REPO slot, which is what
keeps a waiver in that file honoured. The merged value never moves
(deepMerge(x, x) is a no-op); provenance and warnings do. A collapsed file is
labelled by the path the caller addressed, so `get --global` reports
source:global instead of inventing a repo layer.

Closes the phase-2 CAPTURE item at .planning/CAPTURE.md:46 (both arms).
The first cut decided a shared file's `source` with `repoFile === GLOBAL_CONFIG`,
so the identical situation read `global` when the two paths were spelled the
same and `repo` through a symlink - a string compare deciding provenance, which
is what this task removed everywhere else. mergeLayers now takes `asGlobal`,
which config.mjs's --global arm sets, and a collapsed file is `global` only when
the caller addressed the user-global layer itself.
Two rows for the case where CADENCE_GLOBAL_CONFIG names the repo config -
directly and through a symlink: the waiver is honoured, no IGNORED warning is
emitted for a layer that is the repo file, and the routing reason names one
layer. Both fail against the pre-fix merge lib on the IGNORED warning, with
stakes already solo either way - the honouring never moved, only what the
resolver claimed about it (.planning/CAPTURE.md:169).

The existing row for two DIFFERENT files naming the same waiver is unedited and
still passes: no collapse applies there.
config.mjs get returns the merged config by contract, so a risk.override.<s>
only the user-global layer holds read back as an effective true while route.mjs
ignored it and warned - one situation, two read faces, nothing said. get keeps
returning the merged value (a per-key differently-scoped answer was rejected:
a workflow batching keys through get would read a different scope per key with
no way to tell which) and now names the key in warnings.

Scoped to risk.override.* alone, never to the schema's src: repo generally - 41
of 73 keys carry it, stakes among them, whose global-layer inheritance is
ordinary and pinned. Truthy only, matching route.mjs's filter. The null-slot
guard is load-bearing: flatten() throws on null and an absent
~/.claude/cadence/config.json is the ordinary case.

Closes the first half of .planning/CAPTURE.md:164.
A global-layer risk.override.athu earned "set it in this repo's own
.planning/config.json", which config.mjs set refuses outright as no risk
surface; a non-boolean value earned the same instruction though the repo layer
would not honour it either. riskFloor's two shape arms and the declared-surface
list move to module-level helpers that BOTH read faces call, so a global entry
gets the move-it remediation only when the repo layer would honour it - a
declared surface at a strict true - and every other shape gets the repo layer's
own diagnostic, naming the user-global layer.

The repo-layer strings are byte-identical, the waive decision is unchanged, and
the accepted-surface list was cross-checked against config.mjs check as a SET
(route lists table order, check lists schema order).

Closes .planning/CAPTURE.md:166.
`--file` with nothing after it - what `--file $CFG` on an unset variable
produces - fell through as file:undefined. Measured at HEAD: set answered
reason:"internal" with a raw Node TypeError, validate said "cannot read/parse
undefined", and get answered ok:true, returning a full effective read of the
user-global layer alone as if it were the file the caller named. optFile now
refuses it with reason:"usage" naming the flag, on every subcommand.

fsIdentity is total as well, so the class cannot come back through another
caller: a non-string or empty path yields null, the last fallback can no longer
throw outside its try, and null equals nothing - an unresolvable path must not
land on the global layer by accident. The refusal set is unchanged; the alias
rows that pin it are untouched and still pass.

Closes .planning/CAPTURE.md:168.
Two silences in the check whose stated point is that nothing about a key's
reach is skipped silently. A second row for a declared key was dropped with
`continue` and no issue, so narrowing a key by appending a row left the stale
row winning, the purpose test running against the reach the author had just
replaced, and self-verify ok:true - it is now duplicate-reach-row naming the
key and BOTH lines, with first-occurrence-wins unchanged. A Reach cell reading
`Universal` or `universal.` fell through to the purpose test and reported
unstated-reach, which tells the author to paste the wrong phrase into the key's
purpose rather than to fix the cell; the sentinel is now read
case-insensitively with one optional trailing period.

The NARROW phrase stays compared verbatim, and a row proves it: a purpose
differing only in case is still reported. Both new rows fail against the
pre-fix lib.

Closes .planning/CAPTURE.md:170 and :171.
By the reach doc's own human test the answer became YES at e09a0e5 - the value
is resolved from either layer and honoured from one - so the eight rows reading
`universal` were the check's blind spot: reachIssues returns at the sentinel
before the purpose test, which is why check 9 could not see the narrowing its
own phase introduced. The rows now read `repo config layer only` and all eight
purposes carry the phrase verbatim, so the pair is coupled. Proved by mutation:
dropping the phrase from one purpose in a scratch copy makes self-verify report
unstated-reach naming that key.

Only `purpose` strings changed in the schema - key set, types, defaults and src
are untouched. The stale "Four phrases are in use today" line was already false
against the fifth and sits in the lines this edit touches, so it is corrected to
all six.

Closes .planning/CAPTURE.md:165, and the low item at :181.
The CFG-01 marker ended on "the read face is deliberately unchanged", which
this phase makes false. v2.0.0's record is left intact and a sibling marker
records what changed and what was deliberately kept: get still returns the
merged value by contract, it now names a truthy global-layer waiver, mergeLayers
collapses two paths that resolve to one file into the repo layer, route's
global-layer diagnostics come from the repo layer's own helpers, and the eight
reach rows carry the narrowed phrase.
CAPTURE:164 had two live halves and PLAN-1 closes one. get now names a truthy
global-layer risk waiver, so the read face and the resolver agree; validate
--global still returns ok:true on the file set --global refuses as repo-scoped,
measured again at 27d9a86. Every layer is validated on its own by design, so
closing that half means teaching validate which file it is looking at - a shape
change no task here carried, and claiming :164 closed outright would make the
roster true only in appearance.
claudepluginhub.com only indexes GitHub, and it rejects a plugin whose
manifest names a different origin than the copy it scraped: "this plugin
manifest points to a different repository than the source indexed". The
same fix already landed in burnrate (PR #8).

homepage stays on Forgejo, which is still where the project lives.
Each finding run as its own repro against a249232, not read for plausibility.
Eight survive, deepseek's blocker is refuted: route.mjs reads risk.override
from layers.repo alone (route.mjs:311, :252) and route.test.mjs:882 already
pins the floor standing for a global-only waiver.

Two rulings move. OA#1 drops high to low - a hard link duplicates provenance
but leaks no waiver. OA#2 drops high to medium, and is the one that matters:
set --file through a hard link to the global config writes the repo-scoped
waiver the guard exists to refuse.

CS#2 is a divergence from PLAN-1's own D-02 amendment, which required source
and the warning slot to key off filesystem identity; the shipped code keys off
the asGlobal caller flag. CS#1's cited remedy is corrected: overrideShapeWarning
is module-local, so sharing it means moving it, not importing it.

Survivors group into three edits. PLAN-2 covers none of them - it touches
config-seams.test.mjs alone.
config.schema.json gave git.auto_close no src, so it read as universal, and
references/config-reach.md said reach universal naming bin/land-cleanup.mjs
alone. But git-publish.mjs's repoAutoClose reads the REPO layer only, citing
D-08, while land-cleanup.mjs gate() read the merge - one key, two seams, two
layer semantics. With a global-only auto_close the gate halted as if an
unattended close were running while publish refused with auto-close-off, so
gh pr create found no branch on the remote.

D-08 makes the narrowing intentional, so the declaration now matches the
decision: the key carries src: repo and states the repo-only reach in its
purpose, the reach row reads 'repo config layer only' and names both
consumers, and gate() reads layers.repo exactly as repoAutoClose does.
Three arms passed a mutation that severed the very seam they claim to pin.
The git-branch arm compared the seam's ECHOED `mode` field, so hardcoding
the DECISION's mode to 'milestone' left all nine green - and its
`action === 'stay'` corroboration was vacuous, because the fixture built no
git repo, readCurrentBranch returned '' and the milestone arm exited the same
way. It now runs over a real repo parked on the protected base with a
derivable integration name, the one state where trunk (stay, no branch) and
milestone (ask, cadence/v9.9.9) decide differently.

The review-provider arm put its decisive cap in the REPO layer, so both faces
read one file: severing this seam's global-layer read entirely left the file
green while the live seam does honour a global-only cap. A second arm drives
it from the global layer like every other one, with a 1ms request timeout so
a mutation that defeats the cap fails fast instead of waiting 540s on a real
request.

Every arm with a single value gains a contrasting one - land-cleanup base,
route stakes, git-branch mode - so a hardcoded constant fails it.
Every arm drives a seam and `config.mjs get` over the same two layers, and
both sides call the same mergeLayers - so a merge bug moves them identically
and the file's stated question answers 'yes' on a wrong value. Inverting the
precedence to `deepMerge(repoValue, globalValue)`, the user-global layer
silently beating the repo's own config, left 8 of 9 arms green.

Two arms now set one key in BOTH layers and name the winning value literally,
so repo-wins is pinned by something other than the shared helper: `stakes`
as a top-level scalar through route.mjs, and `review.max_prompt_tokens`
nested under an object, which deepMerge reaches by a different path.
The consult ordering is the opposite of what the arm's comment left implied:
cmdConsult resolves the provider FIRST and asserts the cap SECOND
(review-provider.mjs:591,596), so the stub key file is what lets the flow
reach the cap at all. Verified by dropping --key-file with the three key env
vars unset and XDG_CONFIG_HOME at an empty dir - the seam answers no-key, not
over-cap.

That experiment also found a hermeticity hole: resolveKey falls back to
${XDG_CONFIG_HOME:-~/.config}/cadence/providers.env, so on a machine that has
one, an arm without --key-file reads a REAL key and reports over-cap while a
clean machine reports no-key. Every child env now pins XDG_CONFIG_HOME at an
empty fixture dir, making the file's hermeticity claim true for the key file
as well as the config layers.

The land-cleanup arm's `action === 'cleanup'` was vacuous (on_land_cleanup
defaults true) and the default state of every unconfigured install was a third
get-vs-seam divergence the file neither pinned nor recorded: with
git.base_branch unset, get reports null while cleanup reports base 'main' from
the protectedBranches[0] fallback. It is recorded now, with the contrast that
shows the fallback follows the configured list rather than the literal 'main'.

The auto_close arm gains land-cleanup's gate beside git-publish, so the file
proves the two seams AGREE on the narrowing its reach row now declares.
0b1c322 narrowed gate() to the repo layer to make it agree with git-publish's
repoAutoClose. That removed a safety halt, and all four reviewers caught it.

The two seams ask different questions of one key. repoAutoClose asks whether a
push is authorized here, which D-08 answers repo-only so a user-global value
starts no close in an unrelated project. The gate asks whether anybody is
watching, and that has to match what the prose branched on: cad-land reads the
merged value and suppresses the pre_ship triage ask under it, so the gate's
blocker/high halt is what replaces the human it switched off. Suppression and
halt are a matched pair and must read one value.

Collapsing them aligned the values and broke the pairing. With a global-only
auto_close the prose still entered the unattended chain and still suppressed
triage while the gate believed no chain was running. On GitHub the chain died
at the publish seam; on GitLab nothing gates it, since glab mr create publishes
the source branch itself, so a surviving blocker merged unreviewed.

gate() reads the merge again and says why. The schema purpose and the reach row
now state the split instead of claiming both seams narrow. Tests move with it:
global-only auto_close plus a blocker halts, and a repo false beating a global
true proceeds - the merge is what this gate reads.

Verified live: global-only auto_close with a blocker answers halt; repo false
over global true answers proceed. Suite 1172 pass / 0 fail, self-verify ok:true.
`get`'s new global-layer warning filtered on truthiness alone, so an
undeclared surface or a non-boolean value earned the move-it-to-your-repo
remediation that `config.mjs set` and `config.mjs check` both refuse -
the CAPTURE.md:166 defect task 4 removed from route.mjs, re-created on
the get face by task 3. AC2 asks the two faces to report one situation
the same way; they reported it two ways.

The shape check and the layer traversal move to lib/risk-surfaces.mjs
and both seams call them, so the agreement is structural rather than two
copies that match until one is edited. Only an entry the repo layer
would actually honour now earns the move-it line.

The accepted-surface list is sorted before it is printed. The two
vocabularies hold the same set in different orders - route-table.json in
declaration order, the schema keys alphabetically - and unsorted, the
faces emitted different text for one entry. The new equality row caught
that; sorted also matches surfaceKeyError, so the write face agrees too.
AC4's guard tested `tokens[i + 1] === undefined`, which is the unquoted
`--file $CFG` spelling: the shell drops that token entirely. The quoted
`--file "$CFG"` passes an EMPTY token instead, and that fell straight
through to a full effective read of the user-global layer - `get`
answering ok:true with source "global" and a real waiver value, about a
file the caller never named.

Worse in kind than the case AC4 closed: that one was loud, this one is a
silent wrong answer, and the quoted spelling is the one a careful script
writer uses.
AC4's fix landed in config.mjs alone, so the identical input on the other
read face still degraded to reason:"internal" carrying a raw Node type
error - `o.file` reaches dirname() on the way to the layer read, and
parseArgs assigned it unguarded.

Refused alongside the existing --role and --attempt usage checks, in both
spellings the shell produces. Defaulting a missing value to
.planning/config.json would answer about a file the caller never named,
which is the config.mjs defect one seam over.
All eight `risk.override.*` schema purposes and all eight reach rows
ended on the unconditional claim that a user-global waiver is ignored and
named in the resolver's warnings. AC3 made that false in the case it
covers: with both layer paths resolving to one file, there is a single
layer, it IS the repo layer, and the waiver holds - which is what
route.test.mjs's own row asserts.

Sixteen surfaces, one clause, qualified in place. The reach phrase
itself is untouched, so the literal purpose/row comparison self-verify
runs still matches.
D-12 had this phase correct "Four phrases are in use today"; the
correction said six, and the phase's own last commit added a seventh
(`repo config layer only for the unattended publish`) without touching
the list. Same defect, same mechanism, one phase apart.

The seventh is listed and the count is gone. Nothing machine-checks the
list, so a stated number is a claim that goes stale in whichever commit
adds a phrase - and a wrong count reads as authority where a missing one
reads as a list to check.
The deep verify pass auto-verified six of the eight criteria with
severance proofs and found AC2 and AC6 unmet, plus seven gaps - six of
them the diff-review survivors nothing had fixed since f452f80. Five
were fixed in this session (84f0816, 130c696, 4f40641, 50b25da,
436e117); the hard-link identity case and the caller-intent-vs-identity
split between the read and write faces are carried to CAPTURE with their
reasons, both out of this phase's stated scope.

SUMMARY.md names each of the seven roster items with the test that pins
it, records :164 as partially closed, and states the PLAN-2 scope
deviation and the deliberate git-publish/land-cleanup value divergence.
A subcommand alone cannot tell an invocation that destroys uncommitted
work from an ordinary one: `git checkout .` and `git checkout main` were
byte-identical to every caller. Each git invocation now reports
{sub, args, denyable}, and `subs`/`denyable` are derived from that one
list rather than accumulated beside it.

Where an invocation's args END is decided by position, not word shape. A
git word at command position owns its whole simple command; a git word
elsewhere is a speculative reading of another command's operands and
stops where the next such reading begins. A word-shape boundary matches
ordinary arguments - vendor/git, src/git, a message word `git` - as
exactly as it matches a second invocation, and reported
`git clean vendor/git -fdx` as a no-op `git clean`.

Purely additive: subs, unplaced and denyable are unchanged against HEAD
over 149,248 distinct inputs.
The args boundary shipped in 776e6a0 only closed the command-position half.
Behind any transparent prefix the git word is off command position again, so
the args still truncated at the next git-SHAPED word:

  git clean vendor/git build -fdx        -> args ["vendor/git","build","-fdx"]
  sudo git clean vendor/git build -fdx   -> args []
  timeout 60 git clean vendor/git build -fdx -> args []

Live git 2.55 prints "Removing build/" and "Removing vendor/git/" for all
three, so a policy reading those args answered "no force flag" about a command
that force-deletes untracked files. Same miss for nohup, xargs -I{} and sudo -u.

No boundary can do this job: `sudo git clean vendor/git build -fdx` (one
command git owns to the end) and `xargs -I{} git add . git push` (one argv
xargs hands to git) are the same word shapes at the same positions. So the
readings OVERLAP - every invocation runs to the end of its simple command -
which is the safe direction: an extra reading costs at most a prompt, a
dropped flag costs the work.

Position now decides AUTHORITY only (denyable), never reach. `xargs -I{} git
add . git push` reports `add` with ['.','git','push'] and `push` with none;
that row is rewritten rather than deleted, since it pins the trade.
A pure, total, zero-dep destructiveInvocation(invocations) with four arms -
reset --hard, clean with force, a checkout/restore worktree overwrite, and a
branch force-delete - and no others. It can only ever produce an ASK.

It parses git's option grammar rather than matching spellings. Matching exact
strings and letter clusters let live git 2.55 through three ways, each a
silent unrecoverable deletion:

  (a) a long option abbreviates: `git reset --ha` and even `--h` ARE `--hard`;
      `git branch -d --forc x` deleted an unmerged branch.
  (b) an option consumes its value, glued or as the next word:
      `git clean -fdxenode` is `-e node` and deletes, `git clean -fe -n` is the
      pattern `-n` and deletes, `git restore -sSTABLE .` is `--source=STABLE`
      and overwrote the worktree - while `git clean -e -f` has no force flag at
      all and git refuses to clean.
  (c) `--` ends the options: `git clean -fdx -- -n` is a path named `-n`.

Long-option resolution is deliberately permissive (a prefix is read as every
option it prefixes), because an abbreviation git calls ambiguous is one git
refuses to run: it can over-fire on a command that destroys nothing and can
never under-fire. Option tables are transcribed from `git <sub> -h` on 2.55 and
every asserted behaviour was run in a throwaway repo.

Two exclusions, recorded in the module header rather than in a test row's name:

  1. `git checkout <path>` with no `--` stays SILENT and does destroy - the
     word is byte-identical to a branch switch and only the repo can tell them
     apart, so firing means prompting on every `git checkout feature/x`.
     Checkout fires where the words settle it: `--`, --pathspec-from-file, a
     force flag, two or more operands, or an operand no ref name can be.
     `git restore <path>` fires, which covers the same edit under the verb
     whose operands are always pathspecs.
  2. stash drop, push --force and rebase stay out; TOK-02 names four classes.

101 rows, 24 of which fail against the previous matcher.
Schema, reach row, catalog row and template, plus the budget the catalog row
grew. The enum is `ask | off` and carries NO refusal value, so "never
hard-blocks" holds by construction rather than by a downgrade rule: `refuse` is
rejected at the write face naming the key, and the test asserts the ENUM error
rather than `ok:false` alone - before the key existed the same command already
printed ok:false with the key in `detail`, for the opposite reason.

cadence-core/workflows/config.md sat at exactly 18168/18168 and the row put it
at 18433; weight-budgets.json is regenerated in this commit, verified by
reverting the entry and watching self-verify report the 265B overrun.

Also corrected the pre-existing git.protected_branches reach row, whose
Honoured by cell omitted bin/git-publish.mjs though that seam has read the key
since it shipped.

No code plumbs the value yet - it is generic off the schema, and the reader
lands with rail 5.
decideReap joins decidePublish in lib/publish-decision.mjs rather than taking a
lib of its own: it needs the same SAFE_BRANCH rule, and duplicating a
security-relevant regex across two decision modules is how the two drift.

Gates run first-failing-wins and every refuse is total (argv:[]): no branch, a
name failing SAFE_BRANCH, a protected branch, the checked-out branch, then an
absent branch as a SKIP so cad-land's stated idempotency survives the platform's
own --delete-branch having already removed it.

It deliberately does not re-derive merged-ness (land-cleanup.mjs owns that
verdict, and the auto_close arm's merge lands on the platform) and does not gate
on git.auto_close (deleting an already-merged local branch publishes nothing).

Carried out of the halted phase-2 execution unchanged. It drew no surviving
finding across three panels; it went uncommitted only because the panel it rode
in on failed on the destructive rail, which is now deleted rather than fixed.
lib/shell-tokens.mjs, lib/destructive-git.mjs and both their test files are
gone: 2,251 lines replaced by lib/git-segments.mjs, about thirty. A segment
counts only when its command word is `git`, and the verb is its first non-flag
word. Nothing else is inferred.

WHY NOW, since the widener argument that justified the tokenizer is still
sound. A detection widener is safe to get wrong, which is not the same as cheap
to get wrong, and the bill came due three ways in one phase. The escape surface
behind `bash -c`, `$(...)`, `${...}`, aliases and `ssh` is unbounded, so three
consecutive blocking review panels each found new holes and each patch bought
more grammar; the reader still went silent on `git switch -f main`. And the
scan was O(K x N) in memory - 3.1GB at 224KB of input, a V8 abort at 280KB - in
a hook that runs on EVERY Bash call and fails OPEN, so a long enough command
line switched the guard off and let the push inside it run unprompted. The new
reader is total and linear; that same 336KB input decides in milliseconds, and
a test pins it above the measured abort point.

The anchor also retires a second rule. Detection used to be any-position, so
`rg -t sh "git commit"` was read as a commit, so refusal had to be narrowed
back to command position by an enumerated prefix set that three review rounds
kept finding new members of. Reading only the command word makes those silent
up front: `denyable` and `unplaced` are deleted with nothing to replace them.

ACCEPTED COST, stated rather than discovered later. A wrapped, substituted or
prefixed invocation is now invisible: `bash -c "git push"`, `sh -c`, `eval`,
`$(...)`, backticks, a subshell, `sudo`/`timeout`/`xargs` prefixes, `env -S`,
`ssh host "..."`, `git -C "<path with space>" push`, and a line continuation.
Each is a pinned test row in git-guard.test.mjs and git-segments.test.mjs
rather than an absence.

INTERNALS.md's guard section carries the reversal, including that this is its
own line 29 rule ("delete the thing you would have had to parse") applied to
this codebase a milestone late.
930dda3 landed the key across the four surfaces a key needs, on the stated
premise that "the reader lands with rail 5". Rail 5 is not landing: the
destructive rail was the parser's whole reason to exist, and the parser is
deleted. A key whose only documented consumer never shipped is a promise the
config face cannot keep, so it goes back out the way it came in.

What it was claiming, specifically. config-reach.md named `bin/git-guard.mjs`
as the honouring seam and workflows/config.md offered the user a prompt-or-
silence choice over `reset --hard`, `clean -f`, a `checkout`/`restore`
overwrite and `branch -D`. None of that reached a hook on any commit of this
branch. Setting it to `ask` and setting it to `off` did the same nothing, and
self-verify could not catch that because it reads the reach TABLE, not the
call site - the table was the lie.

Removed from config.schema.json, templates/config.json, config-reach.md,
workflows/config.md, and the pair of tests that pinned its enum. The
weight-budgets entry for workflows/config.md goes back to 18168 as well: the
265B of slack was granted for a catalog row that no longer exists, and leaving
it would quietly widen the budget for the next thing.

930dda3's OTHER change is kept deliberately: the `git.protected_branches` reach
row still names bin/git-publish.mjs, which was a pre-existing omission
corrected in that commit and has nothing to do with the destructive rail.
references/git.md loses 169 lines: the "What the guard sees" grammar and the
"Out of grammar (rail 3)" table both described a tokenizer, and a rail that now
reads a segment's command word does not need a page and a half of shell
semantics to explain itself. Rails 1-4 keep their numbers. In their place: the
one rule the reader actually implements, and a table of the shapes that
consequently go silent - wrappers, substitutions, transparent prefixes, `env
-S`, a quoted path with a space, line continuation, `ssh` - each row already
pinned by a test.

CHANGELOG gets an `## [Unreleased]` section stating the removal and the accepted
cost in the same subsection, including the measurement that settled it (the
O(K x N) scan, the V8 abort at 280KB, and the fail-open hook that turns an abort
into an unprompted push).

The three public docs stop claiming reach this deleted:

- README.md said the guard "reads shell quoting properly enough to notice
  `git -C "my repo" push`, `bash -c "git push"`, a backtick, a subshell and an
  `&`". It does not, and each of those is now a named silent row.
- METHOD.md said the hook is one "the model cannot talk its way around". With an
  anchored reader that is plainly false - `bash -c "git commit"` walks around it
  - so the sentence says what the guard IS instead: a rail against drift, not a
  boundary against an adversary.
- DESIGN.md's R2 note argued the tokenizer was not what R2 was about. It gets
  the v2.2.0 addendum: correct in kind, wrong in cost, and R2's own rule
  eventually applied to R2's successor.

TOK-01's Shipped rows in PROJECT.md and REQUIREMENTS.md read as superseded on
both halves rather than as still-true. TOK-02's requirement text is corrected to
what was actually built: the destructive rail is not built and
`git.on_destructive` is gone, scope changed by user decision after the third
blocking risk_surface FAIL, because the rail that key served was the parser's
only consumer and specifying it meant modelling git's option grammar again.
AC6's open half. Task 7 shipped `git-publish.mjs reap` (decideReap, the argv
delete, the self-verify contract row) at 1fcf51e; task 8 was the prose rewire
and never ran, so cad-land step 5 still instructed a Bash
`git branch -D <decision.branch>` against a seam that was sitting right there.

It matters more after the parser deletion, not less. With rail 5 cut, the guard
reads a segment's verb and nothing else, so a Bash `git branch -D` is now
SILENT - no prompt, no record. The seam is the only thing left that checks the
name at all: it refuses a missing, unsafe, protected or checked-out branch and
builds `branch -D -- <name>` as subprocess argv, so the branch name can never be
read as an option.

The two `action:"skip"` clauses are merged while the paragraph is open: an
already-removed branch and `git.on_land_cleanup: off` reach the same instruction
(leave HEAD and the branch in place), and they were stated separately.

weight-budgets.json is regenerated in this commit, per the phase's own AC7. The
file sat at exactly 8978/8978, so the seam invocation could not land without it;
the prose was trimmed from +429B to +101B first rather than taking the budget as
free room.
Three loose ends from the phase-2 close, none of them code.

**v2.1.0 had no CHANGELOG section.** The milestone closed in the planning docs
at `e457e47` and REQUIREMENTS carries three requirements marked Complete against
it (COV-01, TRI-02, REV-03), but no `## [2.1.0]` heading was ever written, so
`## [Unreleased]` sat directly on `## [2.0.0]` with a whole release's notes
missing. Reconstructed from the phase summaries at `643663e~1` and the range
`fe2310f..e457e47`, and labelled as backfilled rather than passed off as
contemporaneous. Worth naming: this gap is an instance of exactly what REL-03
(phase 3) exists to fix, so it is now also that phase's proof case.

**Phase 2's SUMMARY said `status: partial`** and narrated a halt, which stopped
being true when the phase completed. Rewritten to what shipped, with the scope
change stated at the top rather than implied. The halted first attempt is kept
VERBATIM under its own heading instead of being replaced: those three blocking
panels and the O(K x N) measurement are the evidence the deletion decision was
made from, and a summary that erased them would leave the decision looking
arbitrary.

**Five CAPTURE items closed as moot.** `#29`, `#31`, `#32` and `#201` all
describe behaviour of `lib/shell-tokens.mjs` and `lib/destructive-git.mjs` -
the wrapper-with-no-operand rule, the sudo/wrapper refusal asymmetry, the
decidable-but-unclosed shapes, and the three families of silent destruction.
None has code left to be true of. `#13` is the v1.4.0 open decision itself
("rip out vs keep investing, user leans rip-out"), decided rip-out on
2026-08-03; its part (a), the triage-gate design half, had already shipped as
TRI-02. 158 open items to 153.
chore: keep the capture queue and design notes out of the published tree
All checks were successful
test / node-test (22) (pull_request) Successful in 1m28s
test / node-test (24) (pull_request) Successful in 56s
test / self-verify (pull_request) Successful in 16s
test / typecheck (pull_request) Successful in 19s
0e9f99a193
john merged commit 0bf62847dd into main 2026-08-04 18:15:31 +00:00
john referenced this pull request from a commit 2026-08-04 18:15:33 +00:00
Sign in to join this conversation.
No description provided.