Skip to content

ci(zizmor): adopt 1.28.0 — fix what is wrong, justify what is not, and pin the premises - #130

Merged
wshallwshall merged 11 commits into
mainfrom
zizmor-1280-adoption
Aug 2, 2026
Merged

ci(zizmor): adopt 1.28.0 — fix what is wrong, justify what is not, and pin the premises#130
wshallwshall merged 11 commits into
mainfrom
zizmor-1280-adoption

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Adopts zizmor 1.28.0 and clears the five findings it surfaces. Carries #127's dependency commits
— see Sequencing for why that is not optional.

Local run against this branch: No findings to report. Good job! (27 ignored, 39 suppressed), exit 0.

Why these findings only appeared now

zizmor's own pinned version lives in pyproject.toml's [dependency-groups].ci-scanners and reaches
CI via ci/locks/ci-scanners.lock — which was not in zizmor.yml's paths filter. A PR that bumped
only zizmor never ran zizmor. #121 fixed that; #127 is the first bump to be adjudicated pre-merge, and
it went from 1.5.2 to 1.28.0 — a 23-minor jump that turned on audits the old pin could not run.

The five findings

Finding Location Disposition
bot-conditions HIGH dependabot-lock-resync.yml:76 Suppress — fail-closed, premise pinned by a test
ref-version-mismatch ×3 ci.yml:301, security.yml:159, :189 Fix — the comment lied
archived-uses cla.yml:44 Accepted residual, dated, ADR 0034

The HIGH is fail-closed, and #121 needs no correction

zizmor flags github.triggering_actor == 'dependabot[bot]'. Correct observation; it is the second
half of a conjunction
:

github.event.pull_request.user.login == 'dependabot[bot]'
&& github.triggering_actor == 'dependabot[bot]'

Clause 1 is the immutable PR author — the exact context this audit recommends as the replacement. A
conjunction only narrows, so spoofing the actor makes the job skip, never run; and skipping is safe
because DEP-1 lock sync is independently enforced by security.yml's pip-audit, a required context on
an unfiltered trigger. zizmor agrees enough to report at MEDIUM confidence, treating And as
non-dominating.

I had flagged that PR #121's description might assert a control that does not hold. It holds. #121
claimed a human push makes the job skip — the fail-closed direction — while this audit is about a
check wrongly passing. Orthogonal.

zizmor --fix must not be run here. Its fix for this audit is classified safe, so a bare --fix
applies it silently: it substitutes clause 1 for clause 2, collapsing the condition to A && A and
deleting the self-trigger skip that exists to stop a phantom red. Every check stays green. That is
recorded in the suppression comment.

The pin comments were lying, and reverting would have been worse

actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 — that SHA is what refs/tags/v7
and refs/tags/v7.0.0 point at, verified against the upstream refs API. The repo has been running
v7 for a week with a comment asserting v6.

This ratifies a major already in force. Reverting to the v6 line would be less safe, not more: v6
carries fast-xml-parser 5.9.3, inside GHSA-8r6m-32jq-jx6q; v7.0.0's 5.7.2 is in no open advisory
range. SHA unchanged; only the comment moves, and all three move together.

The archived CLA action is a residual, not a false positive

contributor-assistant/github-action is archived (archived: true, last push 2026-03-23); v2.6.1 is
final and our pin equals that tag's commit, so there is no patch to move to. The SHA pin closes
tampering but not the unpatched-code axis the audit names.

Not replaced now: cla is a required context and is the job's own conclusion, so a broken step
blocks every contribution — and pull_request_target/issue_comment workflows run only from the
default branch, so a swap cannot be exercised on the PR that makes it. The forcing function is not this
lint: action.yml declares runs.using: node20 at the pin and at the archived HEAD, so GitHub's
fall-2026 Node20 removal decides it. Recorded as a dated residual in ADR 0034 with a re-check
deadline.

Five rotted claims in the config being edited

.github/zizmor.yml's header calls every entry "a REVIEWED, justified non-finding", which makes a stale
one worse than noise. Each checked against the tree, not taken from the lint:

  • release-sync-check.yml ignored under artipacked — that workflow was deleted in 59fbc938.
  • "The jobs below do not push" — dependabot-lock-resync.yml is in the list and pushes at line 160.
  • "release.yml sets persist-credentials: false on both checkouts" — it has three (73, 371, 498).
  • "cla.yml ... does check out" — cla.yml has zero checkouts.
  • "See the SECURITY note in cla.yml" — no such note exists.

Two guards, both proven to fail first

A green gate is evidence only if it can see the defect class:

  • tests/test_zizmor_config_hygiene.py — every ignore entry must name a live workflow. Fails when the
    deleted entry is restored. Prints what it scanned, so a run that covered nothing is distinguishable
    from a pass.
  • test_the_resync_gate_keeps_its_immutable_author_conjunct — pins the suppression's premise. Fails
    under both exploitable mutations: &&||, and clause-1 deletion.

Sequencing — why this carries #127

These cannot land as separate PRs:

  • Fixes alone would run zizmor 1.5.2 from that branch's own scanner lock. 1.5.2 predates
    ref-version-mismatch (v1.14.0), archived-uses (v1.19.0), and scoped bot-conditions to
    pull_request_target only until v1.11.0 — it cannot see any of the five. The green would be vacuous.
  • build(deps): bump the python-deps group across 1 directory with 3 updates #127 alone puts 1.28.0 on main against unfixed workflows: the 06:00 cron goes red, and nothing
    alerts — zizmor is not a required context and nightly-notice.yml watches CI only.

So commit 1 cherry-picks #127's two commits unmodified, bytes and authorship preserved. #127 should be
closed as superseded, not merged
— and not @dependabot recreated, which would overwrite them.

Verification

  • zizmor 1.28.0: 0 findings, exit 0 — installed hash-pinned from ci/locks/ci-scanners.lock, with
    GH_TOKEN set. Without a token the online audits skip silently and a green means nothing. Re-run
    after merging main. Ignored went 25 → 27: the two new line-anchored entries absorbed exactly their two
    findings, nothing broader. This also settles an open question — line anchors do match inside a
    folded block scalar.
  • Full suite: 9637 passed, 820 skipped, 0 failed. ruff format --check + ruff check clean. mypy
    unchanged — no messagefoundry/ source is touched.
  • No lock is re-derived. The six exports come from build(deps): bump the python-deps group across 1 directory with 3 updates #127's own resync commit, so DEP-1's byte-diff
    sees what Dependabot produced.

⚠️ Known external risk on this PR's CI

This branch merges current main, which includes #74's tests/test_worktree_prune_merged.py (+1506
lines) — the file that pushed test (windows-2025) from 19:35 to 26:07 against a step_timeout of
26, timing out #119 with zero test failures. W22 and W25 both sit at 26/30.

If the Windows legs time out here, it is that, not this change. A fix raising those caps was
announced by another session and is not yet open; I deliberately did not duplicate it. Landing it first
would be the clean order.

Ledger

No ADR or BACKLOG number consumed. ADR 0034 takes an amendment rather than a new number: its own
Consequences require the risk register be updated as findings are triaged, and it already carries three
amendments in this style.

🤖 Generated with Claude Code

dependabot Bot and others added 7 commits August 1, 2026 15:17
…ates

Bumps the python-deps group with 3 updates in the / directory: [fastapi](https://github.com/fastapi/fastapi), [prometheus-client](https://github.com/prometheus/client_python) and [zizmor](https://github.com/zizmorcore/zizmor).


Updates `fastapi` from 0.139.2 to 0.140.4
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](fastapi/fastapi@0.139.2...0.140.4)

Updates `prometheus-client` from 0.25.0 to 0.26.0
- [Release notes](https://github.com/prometheus/client_python/releases)
- [Commits](prometheus/client_python@v0.25.0...v0.26.0)

Updates `zizmor` from 1.5.2 to 1.28.0
- [Release notes](https://github.com/zizmorcore/zizmor/releases)
- [Changelog](https://github.com/zizmorcore/zizmor/blob/main/docs/release-notes.md)
- [Commits](zizmorcore/zizmor@v1.5.2...v1.28.0)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.140.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-deps
- dependency-name: prometheus-client
  dependency-version: 0.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-deps
- dependency-name: zizmor
  dependency-version: 1.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
… 2026-07-26

Three pins read `actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6`
while that SHA is what `refs/tags/v7` AND `refs/tags/v7.0.0` point at — verified
against the upstream refs API, not inferred from the lint. So the repo has been
running setup-node v7 for a week with a comment asserting v6.

This RATIFIES a major already in force; it is not a cosmetic label fix. v7 is
green on both `ide build` legs and on the required `npm-audit` job, and reverting
to the v6 line would be worse, not safer: v6 carries fast-xml-parser 5.9.3, which
sits inside GHSA-8r6m-32jq-jx6q, while v7.0.0's 5.7.2 is in no open advisory
range.

The SHA is unchanged. Only the comment moves, and all three move together —
one Dependabot change introduced them and they must not drift apart.

Surfaced by zizmor 1.28.0's ref-version-mismatch audit, which could not run
before #121 put ci/locks/ci-scanners.lock in the zizmor paths filter.
…ith it

The config header calls every entry "a REVIEWED, justified non-finding", which
makes a stale one worse than noise — it reads as a reviewed risk that is actually
unexamined. Five had rotted, each verified against the tree rather than taken
from the lint:

  * `release-sync-check.yml` is ignored under artipacked, but that workflow was
    deleted in 59fbc93 (2026-07-26). An entry naming a file that cannot produce
    a finding is dead config presenting as coverage.
  * "The jobs below do not push" — `dependabot-lock-resync.yml` is in that list
    and pushes at line 160. The carve-out two lines below already says so.
  * "release.yml sets persist-credentials: false on both checkouts" — it has
    three (73, 371, 498), all false. The claim is right and the count is a
    liability, so the count goes.
  * "cla.yml ... does check out" — cla.yml has zero checkouts. The comparison it
    was drawing still holds on a different axis (third-party code vs none), so
    that is what it now says.
  * "See the SECURITY note in cla.yml" — cla.yml contains no such note. A
    security justification whose only citation does not resolve.

Adds tests/test_zizmor_config_hygiene.py so the first one cannot recur: every
ignore entry must name a live workflow. Validated both directions — it passes on
this tree and fails when the deleted entry is put back. It also prints what it
scanned, so a run that silently covered nothing is distinguishable from a pass.
…on-finding

zizmor 1.28.0 reports `github.triggering_actor == 'dependabot[bot]'` at
dependabot-lock-resync.yml:76 as a spoofable bot actor check. The observation is
correct and the gate is still FAIL-CLOSED, because that expression is the second
half of a conjunction:

    github.event.pull_request.user.login == 'dependabot[bot]'
    && github.triggering_actor == 'dependabot[bot]'

Clause 1 is the immutable PR author — the exact context this audit recommends as
the replacement. A conjunction only narrows, so spoofing the actor can make the
job SKIP, never run, and skipping is safe: DEP-1 lock sync is independently
enforced by security.yml's pip-audit job, a required context on an unfiltered
trigger. zizmor itself agrees enough to report at MEDIUM confidence, treating
`And` as non-dominating.

So this is suppressed, line-anchored, not fixed — and PR #121's description
needs no correction: it claimed a human push makes the job skip, which is the
fail-closed direction, while this audit is about a check wrongly passing.

Two things the suppression does NOT rely on prose for:

  * tests/test_dep1_lock_resync_lockstep.py now pins the premise — clause 1
    present, conjoined, no `||`. Validated by mutation: flipping `&&` to `||` and
    deleting clause 1 each turn it red. Without this the suppression would be an
    assertion about a shape nothing enforces.
  * The comment records that zizmor's auto-fix for this audit is classified SAFE,
    so a bare `zizmor --fix` would apply it silently — substituting clause 1 for
    clause 2, collapsing the condition to `A && A` and deleting the self-trigger
    skip, with every check still green.
…034)

zizmor 1.28.0's archived-uses audit is right: contributor-assistant/github-action
is archived upstream (API archived: true, last push 2026-03-23) and v2.6.1 is the
final release. Our pin equals that tag's commit exactly, so there is no later
patch to move to. Recorded as an accepted residual, not waved away as a false
positive.

The SHA pin closes tampering — a bundled JS action's SHA fully determines the
bytes that run — but not the axis the audit names: a flaw already at that SHA
will never be fixed.

Not replaced now, deliberately. `cla` is a required status context AND is the
job's own conclusion, so a broken step blocks every contribution; and because
pull_request_target/issue_comment workflows run only from the default branch, a
replacement cannot be exercised on the PR that makes it — it would land on main
untested with approvals at 0 and auto-merge armed.

The forcing function is not this lint: action.yml declares `runs.using: node20`
at the pin and at the archived HEAD, so an archived repo can never re-declare
node24 and GitHub's fall-2026 Node20 removal decides it. The suppression is dated
and says to re-check before then, rather than being open-ended.

The ADR amendment records a contingency candidate with its provenance checked
rather than asserted: iainmcgin/cla-github-action is real, Apache-2.0, unarchived
— and its v3.2.0 is an ANNOTATED tag, so the ref resolves to a tag object, not the
commit. The commit `uses:` must pin was dereferenced and confirmed. Pinning the
tag-object SHA would be the same mismatch class fixed two commits ago.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 1, 2026 23:33
…ision gate

Drops this branch's one-line ci.yml hunk (`# v6` -> `# v7.0.0` at :301). The
change is not abandoned — the "Stuck CIs" session is carrying it in the same
commit as the W22/W25 timeout raise.

Why: the collision gate keys on a branch's DIFF against main, not on whether a
session is actively editing. Standing down verbally did not clear it; while this
branch carried any ci.yml hunk, `ci.yml` read as in-flight and the timeout edit
was refused. That mattered because #130 is itself running against the 26:00 cap
the timeout fix raises — so a timeout on this PR would have trapped the fix for
the timeout behind a PR that died of it.

Known consequence, accepted: until that commit reaches main, zizmor reports
ref-version-mismatch on ci.yml:301 again — measured, 1 medium, exit 13 — so this
PR's zizmor check is RED in the interim. It clears when main carries the label
fix and this branch merges it. The other two sites (security.yml:159, :189) keep
their fix here.

Side benefit: #130 is now purely "adopt a scanner and adjudicate its findings",
which is the topic separation this PR argued for.
…the collision gate"

This reverts dcccf9d. The handover did not complete: #131 landed the W22/W25
timeout raise but does NOT touch setup-node, so nothing else carries the
`# v6` -> `# v7.0.0` label.

Taking it back is now urgent rather than tidy. zizmor is deliberately NOT a
required context, so a red zizmor cannot block #130 — and #130 has auto-merge
armed. Left as-is, this PR would have landed zizmor 1.28.0 on main together with
an unfixed ref-version-mismatch, redding the 06:00 cron that nothing alerts on
(nightly-notice.yml watches CI only). That is precisely the outcome this PR
exists to prevent, arriving through the PR that prevents it.

The reason for handing it over is gone: the collision gate keys on a branch's
diff, and #131's ci.yml edit is already committed and pushed, so re-adding this
hunk cannot block it.

Restores one comment. SHA unchanged; the pin has resolved to v7.0.0 since
2026-07-26.
@wshallwshall
wshallwshall merged commit 851c849 into main Aug 2, 2026
37 checks passed
@wshallwshall
wshallwshall deleted the zizmor-1280-adoption branch August 2, 2026 01:01
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ly usage)

#130 merged (851c849); only #125 outstanding. Records two retractions with
corrected forms, including that my pooled W25 table is defective: gh run list
truncates to 20 rows, and filtering by JOB conclusion while measuring STEP
durations drops the tightest cases. Rule and pool recorded separately so the
rule does not inherit the defect.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
… reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
#140)

* fix(coord): overlap gave two different answers the same bytes, twice

Two defects in one script, and they are the same defect: a signal that
cannot distinguish the state it reports from a different state.

1. A -Json query answered "nobody else is in this file" by printing
   NOTHING. `@() | ConvertTo-Json -AsArray` sends zero objects down the
   pipeline, so ConvertTo-Json never runs -- -AsArray only shapes output
   that already exists. On stdout an all-clear was therefore byte-for-byte
   identical to the script dying before it answered, and no consumer could
   tell them apart. Every -Json exit now goes through one emitter that
   always produces an array. (-InputObject is not the fix: with -AsArray it
   double-wraps to [[]].)

   Found by running the real script against the real collision gate rather
   than the test stubs, which had been written to a shape the real script
   never produced.

2. A live session was attributed to a worktree by FIRST prefix hit. Linked
   worktrees live under the primary checkout, so every linked path is also
   a prefix match for the primary's row: the primary was handed whichever
   nested session the hash table enumerated first, and reported LIVE on
   main, "building" a peer's task list. Hash order is not stable, so it was
   a different wrong answer each run -- which is why it read as noise
   rather than as a bug. Longest prefix wins is the only rule that survives
   nesting, and it is resolved once against every worktree instead of per
   row.

   docs/WORKTREES.md already named this exact trap for the announce hook's
   id rule, where the cure was "never match by prefix". Here a prefix match
   is genuinely required -- a session may sit in any subdirectory -- so the
   cure has to be longest-prefix instead.

Both are pinned against a real nested-worktree git fixture; a sibling
layout would pass under the old rule and prove nothing. Each new assertion
was checked against the unfixed script first: the attribution test reports
the primary as Live/main/<peer session id>, and the array test sees ''.

* fix(coord): the collision gate reported an all-clear when it had checked nothing

Every fail-open path in this hook -- overlap script missing, throwing, or
printing garbage -- exited 0 with EMPTY STDOUT. On a PreToolUse hook whose
stdout is parsed as a decision, empty stdout means "allow", which is
byte-for-byte what "checked, nobody else is in this file" looks like. So a
gate that had consulted nothing was indistinguishable from a gate reporting
all-clear, and its own failure reached the session as reassurance.

That is the silent-control class this repo has now hit five times, and it
is the same shape as the wired-but-inert announce shim: the surface that
was supposed to report sat downstream of the failure it existed to detect.

The posture does not change -- every one of these paths still ALLOWS. Only
the silence does. It now emits a hookSpecificOutput.additionalContext
notice naming which reason (overlap-missing / overlap-failed /
overlap-empty / overlap-unparseable / payload-unreadable). It must be that
JSON shape and never a bare line: this hook's stdout is a decision, so a
stray line risks a misparse on every Edit and Write -- a diagnostic that
would be a worse fault than the one it reports. There is deliberately no
permissionDecision key: a notice that blocked would invert the fail-open
posture that is the whole point of this gate.

Rate-limited per reason (30 min, -NoticeCooldownMinutes) so a persistently
broken overlap cannot narrate itself into every edit -- this gate's own
docstring records where a gate that cries wolf ends up. The stamp lives
under -StateDir, defaulting to the repo's coordination dir and resolved
ONLY when about to report, so nothing new runs on the hot path. If the
stamp cannot be read or written the notice is emitted anyway: the failure
mode of a noise-suppressor must be noise, never quiet, or an unwritable
directory silently restores exactly the behaviour this removes.

Distinguishing overlap-empty from a resolved "nobody" required fixing the
producer first (previous commit) -- you cannot detect a difference the
producer never encoded. Verified against the real overlap script, not only
the stubs: an ordinary edit to an untouched file is silent.

Tests: -StateDir isolates the throttle per test, or the first notice would
silence the next test's and the suite would pass on run order.

* fix(coord): claim.ps1 accepted a new note, reported success, and discarded it

-Take documented itself as idempotent -- "re-taking your own claim just
refreshes the note" -- and did not refresh anything. A new -Note was taken,
acknowledged and dropped.

That is worse than an outright failure, because of what the note is for.
It is the only field written deliberately to say what a session is doing,
and announce-session.ps1 broadcasts it to every session joining the repo
while telling them to prefer it over the worktree name. So the one field
elevated to authoritative was the one field that could not be corrected.
Measured 2026-08-02: a claim note was still announcing "NO PR OPENED --
honouring the #119 merge freeze" to every joining session hours after both
that PR and the one it gated had merged.

The workaround people reached for -- -Release then -Take -- drops the claim
in between, re-opening the race the claim exists to close.

Re-taking a key you hold now rewrites the file in place: note, branch (a
worktree can have switched branches, and a claim naming a branch nobody is
on is another confidently-wrong coordination fact) and a new `refreshed`
stamp, leaving `claimed` untouched -- which is what proves the claim was
never let go. Write-then-rename, not a truncating write: claim_check.py
swallows a JSON parse error into "not claimed", so a torn file is a
silently disabled gate, and a crash mid-refresh must leave the old note.
Mutual exclusion is unchanged and pinned: a peer's key is still refused.

One trap found by the test rather than by reading. ConvertFrom-Json
silently coerces an ISO-8601 string to [datetime], so [string]$c.claimed
returns the local short form -- sub-second precision and UTC offset gone.
Writing that back would have downgraded the stamp on every refresh, and it
would still have parsed, so nothing would ever have complained. Stamps now
round-trip through "o", and the test asserts byte equality rather than
"still parses". The same coercion is handled where announce reads it, with
an invariant-culture parse for the string case.

announce-session.ps1 now prints each claim note's AGE (from `refreshed`
else `claimed`, "age unknown" when it cannot be determined -- an unknown
age must not render as a fresh one). Elevating a note to authoritative
makes a stale one strictly more dangerous than none, and age is the cheap
signal that lets a reader discount it.

Not taken here: claim -List's staleness-vs-liveness rendering, which is
already open as its own change.

* docs(coord): record the three fixes, and correct a claim that has expired

SESSION-DRIFT-CONTROLS.md: a fifth instance of the silent-control class,
in the collision gate itself, added to the callout that names the class.
It carries the part worth reusing -- the fix was not "check harder", it was
giving two states different bytes, and the first attempt failed because the
PRODUCER had never encoded the difference. Status-table rows for the three
controls, and the claim-refresh behaviour beside claim.ps1's entry.

WORKTREES.md: the announce id rule already warned that a prefix match
resolves a peer in the primary to an arbitrary worktree session, because
every worktree cwd extends the primary's. overlap.ps1 had that same trap
live at the same time. Noted there, with the distinction that matters:
overlap genuinely needs a prefix match, so the cure is longest-prefix
rather than exact-match.

And a correction. The broadcast-constraints list said of last week's merge
freeze that "#119 never merged (it died on an unrelated CI timeout)". It
merged the following day, 2026-08-02 01:45Z. Verified against the API
rather than restated. The lesson is unchanged and in fact sharper: the
recipients could not evaluate the predicate, so the freeze outlived its own
condition in both directions -- five sessions held while it had not
arrived, and a claim note was still announcing it hours after it had.

* docs(coord): announce-on-join merged and was never installed

Found while checking a peer session's report, not by looking for it. That
session announced itself by hand on 2026-08-02 and gave the reason as "the
hook is on an unmerged branch". It had merged (#133, 3389aa2) hours
earlier, so the observation was right and the diagnosis was not, and
nothing would have corrected it.

Measured across all five config roots:

  - no `mefor-announce` UserPromptSubmit entry anywhere
  - the one UserPromptSubmit entry installed is `# mefor-web-announce`,
    which resolves scripts/hooks/announce.ps1 -- a different script in a
    different repo, and one the installer's own comment already warns is
    easy to confuse with this marker
  - <git-common-dir>/mefor-coord/announce/ does not exist, so there is not
    a single receipt: it has never executed

install-coordination.ps1 was last run before the announce row existed, and
merging a hook does not install one. Its two other entries -- the
SessionStart banner and the collision gate -- were wired then and are
present, which is precisely why nothing looked wrong.

The part worth carrying: the missing-script notice was built so this class
could not hide, and it CANNOT FIRE when the hook is not wired at all,
because it lives inside the shim. Same shape as the defect this document
already records one level down -- the detector sat downstream of the
failure it existed to detect. So the status table now distinguishes rule
4's inert-BY-DESIGN from this one's inert-BY-ACCIDENT, and the confirmation
step is a receipt on disk rather than a reading of the settings file.

Not installed here: that writes ~/.claude/settings.json, which is shared
with every session on this machine. Owner's call, from a plain terminal.

* fix(coord): five defects this PR's own first pass introduced or left

Found by an adversarial review of the preceding commits, then each one
reproduced by execution before being touched. Two were regressions I had
introduced; three were gaps.

1. THE CLAIM FILE'S EXISTENCE IS THE LOCK, and the refresh unlinked it.
   `Move-Item -Force` is delete-then-rename. The take path is an exclusive
   CreateNew, so any instant the name does not exist is an instant another
   worktree can claim a key we hold -- i.e. the note refresh could hand a
   claim away. Measured on this box: 400 moves left the destination absent
   on 2,559 of 154,506 polls. [IO.File]::Move with overwrite is
   MoveFileEx(MOVEFILE_REPLACE_EXISTING), and the same harness never once
   saw the name missing across 134,581 polls. It fails transiently instead
   (13.5% under back-to-back churn, nothing like one refresh per run), so
   it retries five times and then reports; failing is the safe direction --
   the old note survives and the claim stays ours.

   The catch around it is deliberately UNTYPED: PowerShell wraps a .NET
   method's exception in a MethodInvocationException, so the typed catch I
   wrote first never matched, the failure escaped to ErrorActionPreference
   = Stop, and the temp file was orphaned in the claim registry. The
   orphaned-temp assertion is what caught it.

2. `overlap.ps1 -Json` emitted `[null]` for an empty map. Build-Map returns
   AutomationNull, which PARAMETER BINDING converts to a real $null at the
   call -- and `@($null).Count` is 1, so the zero-rows guard was dead in
   exactly the case it was added for and the whole-map query printed a
   phantom row. Strictly worse than the nothing it replaced. The -File path
   I had verified by hand was fine; the two call sites do not fail alike.

3. The unresolved-notice throttle was repo-wide. The stamp lives in the
   SHARED git-common-dir and production invokes the gate with no arguments,
   so the first session to hit a broken gate silenced it for every other
   session -- and those sessions read that silence as "checked, nobody is
   here", which is the precise defect the notice exists to remove. One
   session's diagnostic must never become another's false all-clear. Keyed
   per worktree now.

4. An empty payload or a literal `null` on stdin does not throw, so that
   was the one unreadable-input path still exiting silently.

5. A ghost session could outrank a live one. UNVERIFIED is the shape a
   crashed session's record takes once its pid is recycled; last-write-wins
   had no opinion about which record it kept for a directory, so a ghost
   could supply the id and branch reported for a worktree somebody is
   really sitting in. Fenced records now win, then sorted cwd.

Each fix is pinned, and the two regressions were checked against the
unfixed code: the phantom-row test sees `[null]`, and the claim test
asserts the file name never disappears while a refresh is failing.

* docs(worktrees): "is it live yet" has two answers, and they are different

I broadcast a merged claim.ps1 improvement to seven sessions as something
they could use immediately. A peer tried it, got the old behaviour, and
measured why: claim.ps1 is invoked BY HAND from the session's own worktree,
so it runs that worktree's copy, and their branch predated the change. The
in-force check I had given them was for the hook-run path and returned 0
for them.

Both halves of what I said were individually true. The combination was
wrong, because there are two rules and I collapsed them into one:

  hook-run   (collision_gate.ps1, and overlap.ps1 as its callee) -- the
             installed shim resolves the PRIMARY first, so it is live when
             the primary advances, whatever any branch contains
  hand-run   (claim.ps1, overlap.ps1, presence.ps1) -- resolved from the
             session's OWN tree, so it is live when that branch has it,
             and the primary is irrelevant

Tabulated, with the check spelled out per path. The point generalises past
this PR: test the property where the script will actually run from, because
a token that resolves in the primary says nothing about a hand-run script.

Also surfaces `collision_gate.ps1 -PathOverride <path>` as the read-only
"who holds this file right now" query. It is documented in-script only as a
test affordance, and the peer above found it by reading the source after it
answered a question nothing else would.

Both points are theirs, not mine.

* docs(worktrees): the freeze bullet had the right lesson and the wrong reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.

* docs(worktrees): put the two numbers behind the freeze bullet, with their sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.

* docs(ledger): the CI backstop does not re-check ownership, and said it did

Found while unblocking another session that could not commit a rescued ADR:
its number is allocated to a worktree that is not theirs.

LEDGER-GATE.md §3 said "CI re-runs the same rules with --ci", and Limits
said the --ci leg "is the backstop, and it cannot be bypassed from a
branch". Both are true of every rule except the one a reader is most likely
to be relying on. ledger_check.py:196 and :241 are each guarded by
`not self.ci`, so "was this number allocated to you" runs LOCALLY AND NEVER
IN CI.

It has to be that way, and the reason is worth keeping: owns() reads the
allocation store from <git-common-dir>/mefor-coord/alloc, and a CI runner
clones fresh with no store, so the check would return False for every ADR
and no ADR could ever merge. This is not a bug to fix. It is a limit that
was documented as its own opposite.

The consequence is now stated rather than left as an inference: a green CI
on an ADR or BACKLOG PR is NOT evidence the number was allocated to anyone.
And the residual is bounded in both directions -- after --no-verify a number
belonging to another session's unmerged branch can be committed with nothing
objecting, but the collision rule still blocks whichever of the two merges
second. Late, loud and recoverable, rather than silent, which is the
property the gate was actually built for.

Same defect class as the freeze bullet corrected two commits ago, and as the
collision gate this PR started with: a compensating control resting on a
false premise -- CLAUDE.md §11 -- this time inside the document describing
the control.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ore it is cut off (#152)

* fix(coord): overlap gave two different answers the same bytes, twice

Two defects in one script, and they are the same defect: a signal that
cannot distinguish the state it reports from a different state.

1. A -Json query answered "nobody else is in this file" by printing
   NOTHING. `@() | ConvertTo-Json -AsArray` sends zero objects down the
   pipeline, so ConvertTo-Json never runs -- -AsArray only shapes output
   that already exists. On stdout an all-clear was therefore byte-for-byte
   identical to the script dying before it answered, and no consumer could
   tell them apart. Every -Json exit now goes through one emitter that
   always produces an array. (-InputObject is not the fix: with -AsArray it
   double-wraps to [[]].)

   Found by running the real script against the real collision gate rather
   than the test stubs, which had been written to a shape the real script
   never produced.

2. A live session was attributed to a worktree by FIRST prefix hit. Linked
   worktrees live under the primary checkout, so every linked path is also
   a prefix match for the primary's row: the primary was handed whichever
   nested session the hash table enumerated first, and reported LIVE on
   main, "building" a peer's task list. Hash order is not stable, so it was
   a different wrong answer each run -- which is why it read as noise
   rather than as a bug. Longest prefix wins is the only rule that survives
   nesting, and it is resolved once against every worktree instead of per
   row.

   docs/WORKTREES.md already named this exact trap for the announce hook's
   id rule, where the cure was "never match by prefix". Here a prefix match
   is genuinely required -- a session may sit in any subdirectory -- so the
   cure has to be longest-prefix instead.

Both are pinned against a real nested-worktree git fixture; a sibling
layout would pass under the old rule and prove nothing. Each new assertion
was checked against the unfixed script first: the attribution test reports
the primary as Live/main/<peer session id>, and the array test sees ''.

* fix(coord): the collision gate reported an all-clear when it had checked nothing

Every fail-open path in this hook -- overlap script missing, throwing, or
printing garbage -- exited 0 with EMPTY STDOUT. On a PreToolUse hook whose
stdout is parsed as a decision, empty stdout means "allow", which is
byte-for-byte what "checked, nobody else is in this file" looks like. So a
gate that had consulted nothing was indistinguishable from a gate reporting
all-clear, and its own failure reached the session as reassurance.

That is the silent-control class this repo has now hit five times, and it
is the same shape as the wired-but-inert announce shim: the surface that
was supposed to report sat downstream of the failure it existed to detect.

The posture does not change -- every one of these paths still ALLOWS. Only
the silence does. It now emits a hookSpecificOutput.additionalContext
notice naming which reason (overlap-missing / overlap-failed /
overlap-empty / overlap-unparseable / payload-unreadable). It must be that
JSON shape and never a bare line: this hook's stdout is a decision, so a
stray line risks a misparse on every Edit and Write -- a diagnostic that
would be a worse fault than the one it reports. There is deliberately no
permissionDecision key: a notice that blocked would invert the fail-open
posture that is the whole point of this gate.

Rate-limited per reason (30 min, -NoticeCooldownMinutes) so a persistently
broken overlap cannot narrate itself into every edit -- this gate's own
docstring records where a gate that cries wolf ends up. The stamp lives
under -StateDir, defaulting to the repo's coordination dir and resolved
ONLY when about to report, so nothing new runs on the hot path. If the
stamp cannot be read or written the notice is emitted anyway: the failure
mode of a noise-suppressor must be noise, never quiet, or an unwritable
directory silently restores exactly the behaviour this removes.

Distinguishing overlap-empty from a resolved "nobody" required fixing the
producer first (previous commit) -- you cannot detect a difference the
producer never encoded. Verified against the real overlap script, not only
the stubs: an ordinary edit to an untouched file is silent.

Tests: -StateDir isolates the throttle per test, or the first notice would
silence the next test's and the suite would pass on run order.

* fix(coord): claim.ps1 accepted a new note, reported success, and discarded it

-Take documented itself as idempotent -- "re-taking your own claim just
refreshes the note" -- and did not refresh anything. A new -Note was taken,
acknowledged and dropped.

That is worse than an outright failure, because of what the note is for.
It is the only field written deliberately to say what a session is doing,
and announce-session.ps1 broadcasts it to every session joining the repo
while telling them to prefer it over the worktree name. So the one field
elevated to authoritative was the one field that could not be corrected.
Measured 2026-08-02: a claim note was still announcing "NO PR OPENED --
honouring the #119 merge freeze" to every joining session hours after both
that PR and the one it gated had merged.

The workaround people reached for -- -Release then -Take -- drops the claim
in between, re-opening the race the claim exists to close.

Re-taking a key you hold now rewrites the file in place: note, branch (a
worktree can have switched branches, and a claim naming a branch nobody is
on is another confidently-wrong coordination fact) and a new `refreshed`
stamp, leaving `claimed` untouched -- which is what proves the claim was
never let go. Write-then-rename, not a truncating write: claim_check.py
swallows a JSON parse error into "not claimed", so a torn file is a
silently disabled gate, and a crash mid-refresh must leave the old note.
Mutual exclusion is unchanged and pinned: a peer's key is still refused.

One trap found by the test rather than by reading. ConvertFrom-Json
silently coerces an ISO-8601 string to [datetime], so [string]$c.claimed
returns the local short form -- sub-second precision and UTC offset gone.
Writing that back would have downgraded the stamp on every refresh, and it
would still have parsed, so nothing would ever have complained. Stamps now
round-trip through "o", and the test asserts byte equality rather than
"still parses". The same coercion is handled where announce reads it, with
an invariant-culture parse for the string case.

announce-session.ps1 now prints each claim note's AGE (from `refreshed`
else `claimed`, "age unknown" when it cannot be determined -- an unknown
age must not render as a fresh one). Elevating a note to authoritative
makes a stale one strictly more dangerous than none, and age is the cheap
signal that lets a reader discount it.

Not taken here: claim -List's staleness-vs-liveness rendering, which is
already open as its own change.

* docs(coord): record the three fixes, and correct a claim that has expired

SESSION-DRIFT-CONTROLS.md: a fifth instance of the silent-control class,
in the collision gate itself, added to the callout that names the class.
It carries the part worth reusing -- the fix was not "check harder", it was
giving two states different bytes, and the first attempt failed because the
PRODUCER had never encoded the difference. Status-table rows for the three
controls, and the claim-refresh behaviour beside claim.ps1's entry.

WORKTREES.md: the announce id rule already warned that a prefix match
resolves a peer in the primary to an arbitrary worktree session, because
every worktree cwd extends the primary's. overlap.ps1 had that same trap
live at the same time. Noted there, with the distinction that matters:
overlap genuinely needs a prefix match, so the cure is longest-prefix
rather than exact-match.

And a correction. The broadcast-constraints list said of last week's merge
freeze that "#119 never merged (it died on an unrelated CI timeout)". It
merged the following day, 2026-08-02 01:45Z. Verified against the API
rather than restated. The lesson is unchanged and in fact sharper: the
recipients could not evaluate the predicate, so the freeze outlived its own
condition in both directions -- five sessions held while it had not
arrived, and a claim note was still announcing it hours after it had.

* docs(coord): announce-on-join merged and was never installed

Found while checking a peer session's report, not by looking for it. That
session announced itself by hand on 2026-08-02 and gave the reason as "the
hook is on an unmerged branch". It had merged (#133, 3389aa2) hours
earlier, so the observation was right and the diagnosis was not, and
nothing would have corrected it.

Measured across all five config roots:

  - no `mefor-announce` UserPromptSubmit entry anywhere
  - the one UserPromptSubmit entry installed is `# mefor-web-announce`,
    which resolves scripts/hooks/announce.ps1 -- a different script in a
    different repo, and one the installer's own comment already warns is
    easy to confuse with this marker
  - <git-common-dir>/mefor-coord/announce/ does not exist, so there is not
    a single receipt: it has never executed

install-coordination.ps1 was last run before the announce row existed, and
merging a hook does not install one. Its two other entries -- the
SessionStart banner and the collision gate -- were wired then and are
present, which is precisely why nothing looked wrong.

The part worth carrying: the missing-script notice was built so this class
could not hide, and it CANNOT FIRE when the hook is not wired at all,
because it lives inside the shim. Same shape as the defect this document
already records one level down -- the detector sat downstream of the
failure it existed to detect. So the status table now distinguishes rule
4's inert-BY-DESIGN from this one's inert-BY-ACCIDENT, and the confirmation
step is a receipt on disk rather than a reading of the settings file.

Not installed here: that writes ~/.claude/settings.json, which is shared
with every session on this machine. Owner's call, from a plain terminal.

* fix(coord): five defects this PR's own first pass introduced or left

Found by an adversarial review of the preceding commits, then each one
reproduced by execution before being touched. Two were regressions I had
introduced; three were gaps.

1. THE CLAIM FILE'S EXISTENCE IS THE LOCK, and the refresh unlinked it.
   `Move-Item -Force` is delete-then-rename. The take path is an exclusive
   CreateNew, so any instant the name does not exist is an instant another
   worktree can claim a key we hold -- i.e. the note refresh could hand a
   claim away. Measured on this box: 400 moves left the destination absent
   on 2,559 of 154,506 polls. [IO.File]::Move with overwrite is
   MoveFileEx(MOVEFILE_REPLACE_EXISTING), and the same harness never once
   saw the name missing across 134,581 polls. It fails transiently instead
   (13.5% under back-to-back churn, nothing like one refresh per run), so
   it retries five times and then reports; failing is the safe direction --
   the old note survives and the claim stays ours.

   The catch around it is deliberately UNTYPED: PowerShell wraps a .NET
   method's exception in a MethodInvocationException, so the typed catch I
   wrote first never matched, the failure escaped to ErrorActionPreference
   = Stop, and the temp file was orphaned in the claim registry. The
   orphaned-temp assertion is what caught it.

2. `overlap.ps1 -Json` emitted `[null]` for an empty map. Build-Map returns
   AutomationNull, which PARAMETER BINDING converts to a real $null at the
   call -- and `@($null).Count` is 1, so the zero-rows guard was dead in
   exactly the case it was added for and the whole-map query printed a
   phantom row. Strictly worse than the nothing it replaced. The -File path
   I had verified by hand was fine; the two call sites do not fail alike.

3. The unresolved-notice throttle was repo-wide. The stamp lives in the
   SHARED git-common-dir and production invokes the gate with no arguments,
   so the first session to hit a broken gate silenced it for every other
   session -- and those sessions read that silence as "checked, nobody is
   here", which is the precise defect the notice exists to remove. One
   session's diagnostic must never become another's false all-clear. Keyed
   per worktree now.

4. An empty payload or a literal `null` on stdin does not throw, so that
   was the one unreadable-input path still exiting silently.

5. A ghost session could outrank a live one. UNVERIFIED is the shape a
   crashed session's record takes once its pid is recycled; last-write-wins
   had no opinion about which record it kept for a directory, so a ghost
   could supply the id and branch reported for a worktree somebody is
   really sitting in. Fenced records now win, then sorted cwd.

Each fix is pinned, and the two regressions were checked against the
unfixed code: the phantom-row test sees `[null]`, and the claim test
asserts the file name never disappears while a refresh is failing.

* docs(worktrees): "is it live yet" has two answers, and they are different

I broadcast a merged claim.ps1 improvement to seven sessions as something
they could use immediately. A peer tried it, got the old behaviour, and
measured why: claim.ps1 is invoked BY HAND from the session's own worktree,
so it runs that worktree's copy, and their branch predated the change. The
in-force check I had given them was for the hook-run path and returned 0
for them.

Both halves of what I said were individually true. The combination was
wrong, because there are two rules and I collapsed them into one:

  hook-run   (collision_gate.ps1, and overlap.ps1 as its callee) -- the
             installed shim resolves the PRIMARY first, so it is live when
             the primary advances, whatever any branch contains
  hand-run   (claim.ps1, overlap.ps1, presence.ps1) -- resolved from the
             session's OWN tree, so it is live when that branch has it,
             and the primary is irrelevant

Tabulated, with the check spelled out per path. The point generalises past
this PR: test the property where the script will actually run from, because
a token that resolves in the primary says nothing about a hand-run script.

Also surfaces `collision_gate.ps1 -PathOverride <path>` as the read-only
"who holds this file right now" query. It is documented in-script only as a
test affordance, and the peer above found it by reading the source after it
answered a question nothing else would.

Both points are theirs, not mine.

* docs(worktrees): the freeze bullet had the right lesson and the wrong reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.

* docs(worktrees): put the two numbers behind the freeze bullet, with their sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.

* docs(ledger): the CI backstop does not re-check ownership, and said it did

Found while unblocking another session that could not commit a rescued ADR:
its number is allocated to a worktree that is not theirs.

LEDGER-GATE.md §3 said "CI re-runs the same rules with --ci", and Limits
said the --ci leg "is the backstop, and it cannot be bypassed from a
branch". Both are true of every rule except the one a reader is most likely
to be relying on. ledger_check.py:196 and :241 are each guarded by
`not self.ci`, so "was this number allocated to you" runs LOCALLY AND NEVER
IN CI.

It has to be that way, and the reason is worth keeping: owns() reads the
allocation store from <git-common-dir>/mefor-coord/alloc, and a CI runner
clones fresh with no store, so the check would return False for every ADR
and no ADR could ever merge. This is not a bug to fix. It is a limit that
was documented as its own opposite.

The consequence is now stated rather than left as an inference: a green CI
on an ADR or BACKLOG PR is NOT evidence the number was allocated to anyone.
And the residual is bounded in both directions -- after --no-verify a number
belonging to another session's unmerged branch can be committed with nothing
objecting, but the collision rule still blocks whichever of the two merges
second. Late, loud and recoverable, rather than silent, which is the
property the gate was actually built for.

Same defect class as the freeze bullet corrected two commits ago, and as the
collision gate this PR started with: a compensating control resting on a
false premise -- CLAUDE.md §11 -- this time inside the document describing
the control.

* feat(coord): publish the account's plan limits so a session knows before it is cut off

Sessions were hitting the plan limit mid-task and losing work. The real
quota state exists -- Settings > Usage shows it -- but nothing inside a
session could see it.

WHERE THE NUMBERS COME FROM, because it determines the whole shape. Claude
Code hands `rate_limits` to a statusLine command's stdin and NOWHERE ELSE;
the hook payloads were enumerated in the shipped binary and it appears in
exactly one of them. Quota state therefore cannot be subscribed to. It has
to be collected by a statusLine and published somewhere shared, which is
why this is scripts/coord/usage-collect.ps1 and not a hook.

ONE PUBLISHER, N READERS. The quota is account-wide, so any one session's
reading is true for all of them. The publish path is user-level because the
data is a property of the ACCOUNT, not of a checkout. Summing across
sessions would double-count one shared pool.

Three defects found by testing rather than by reading, each now pinned:

  - AN EMPTY READING CLOBBERED A GOOD ONE. Every session runs the
    statusLine, so every session is a publisher; one that has not yet had
    its first API response carries no rate_limits and blanked the account's
    only reading for all of them. Windows are absent INDEPENDENTLY per the
    docs, so the carry-forward is per window and keeps each window's own
    captured_at -- a stale number must not wear a fresh timestamp.
  - HISTORY MUST RECORD ONLY FRESH OBSERVATIONS. A carried-forward
    percentage against a new timestamp tells the burn rate that
    consumption stopped, which is the one lie that matters here.
  - RATE MUST NOT SPAN A WINDOW RESET. The percentage legitimately
    collapses at the boundary; a rate across it is large and NEGATIVE.
    Mutation-checked: removing the epoch filter yields -101.63 %/hr at the
    exact moment a fresh window starts being spent.

And a fourth, which is the same ConvertFrom-Json date coercion that
downgraded the stamp in claim.ps1: captured_at arrives already typed as a
[datetime]. Stringifying it drops the 'Z', re-parsing assumes local, and a
reading taken 90 seconds earlier reported as 299 minutes IN THE FUTURE --
exactly this machine's UTC offset. The sign is what made it dangerous: a
negative age passes an `age -gt max` test unconditionally, so the staleness
guard would have been disarmed on every non-UTC machine while still looking
present. Bounded both ways now.

WHAT IT CANNOT SEE, printed on every run rather than buried: the per-model
weekly buckets (Fable/Opus/Sonnet) and the plan tier are not in the payload
at all, and the request to expose them was closed as not-planned. If Opus
is burned hard across many sessions, the bucket most likely to stop you is
the one this cannot report. Two green bars and an invisible third is worse
than no tool.

Exit codes 0/10/11/20 so a coordinator branches without parsing prose.
UNKNOWN is a real answer and is returned for stale, undateable or
future-dated readings; nothing is ever extrapolated from a dead publisher,
and the statusLine does not run headless, so a dead publisher is the
expected steady state for the coordinator itself.

Not built on ccusage: it measures tokens and dollars, not plan limits,
despite being the tool everyone recommends and several summaries claiming
otherwise. Its own docs contradict them.

Not installed here -- it writes user-level settings shared by every session
on the machine, so that stays the owner's call from a plain terminal.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ready pointed at (#145)

* feat(coord): announce yourself to the other sessions in this repo

Every coordination control in this repo is PULL-based: a new session discovers
its peers from the SessionStart banner and the peers learn nothing until someone
trips the collision gate. That is too late for the collision that costs the most
-- two sessions building the same THING in different files, where nothing
file-shaped can catch it. This closes the push direction.

It ASKS, it cannot send. Hooks are shell commands and session messaging is MCP,
so the hook prints the instruction, the live peer roster and the id-resolution
rule at the first prompt that has intent to report; the model does the sending.

UserPromptSubmit, not SessionStart: at SessionStart a session knows it exists and
nothing else, so it can only say hello -- the interrupt without the information.

THE ID RULE IS THE PAYLOAD, and it is counter-intuitive enough that the text
states it with its evidence. The registry id in this repo's banners is NOT the
MCP session id; measured, a registry id and an MCP id for one session shared no
characters. Branch does not join them either -- the two rosters reported
different branches for the same checkout in 2 of 6 cases. Only cwd joins, and it
must be matched EXACTLY: every worktree cwd is an extension of the primary's, so
a prefix match resolves a peer in the primary to an arbitrary worktree session.
A registry id passed to send_message fails SILENTLY, which reads as the peer
ignoring you.

EVERY DECISION LEAVES A RECEIPT, because the bug being fixed was a hook that was
wired, fired, resolved nothing and exited 0 for weeks -- byte-identical to a
healthy hook with no peers. For the same reason the shim carries its OWN
missing-script notice: every receipt the hook writes lives INSIDE the script,
strictly downstream of the resolution failure that IS the bug, so the shim is the
one surface that still reports when the script does not resolve. It is gated on
presence.ps1 so the entry stays silent in every unrelated repo on the machine.

It always exits 0 -- a UserPromptSubmit hook that fails can block the user's
prompt. It consumes presence.ps1 and therefore the single liveness fence; it does
not invent a second notion of live. A separate 'mefor-announce' marker keeps it
outside install-coordination's mefor-coord strip and outside the website repo's
mefor-web-announce entry in the same settings file, so no installer can delete
another's hook, and -Only UserPromptSubmit -Uninstall removes announce alone
without disarming the collision gate.

* test(coord): pin the announce hook, and the anti-no-op wiring class

Most tests for a hook like this assert an ABSENCE, and a hook that does nothing
at all satisfies every one of them -- which is precisely the production failure
being fixed. So the silence assertions are paired with a positive arm: two tests
run the SAME runner against fixtures differing only in whether a peer exists, and
if the silence tests ever start passing for the wrong reason the positive one goes
red first.

test_announce_wiring.py is the class the repo had no test for AT ALL: does the
thing that gets INSTALLED reach a script that EXISTS, and does it say so when it
does not? Its absence is exactly how a wired-but-inert shim survived for weeks.
test_every_wired_script_exists_in_this_checkout was written FIRST and watched
fail, naming the missing script and printing all three paths it scanned; a green
gate is only evidence if it was shown it can see the failure.

Also pinned, each because it was got wrong somewhere first:

- The foreign UserPromptSubmit entries -- another repo's shim and an unmarked
  waiting-flag cleanup -- survive install AND uninstall byte-identical. That is
  the only thing standing between a one-line wiring edit and deleting a hook this
  repo does not own.
- A peer with no StartedAt ranks LAST, not first. ConvertFrom-Json coerces
  ISO-8601 to DateTime while the '' fallback stays String; Sort-Object over that
  mixed column raises ZERO errors and puts the empty string FIRST, so without an
  explicit projected key the least-trustworthy row silently takes the top of a
  capped target list.
- NO_SESSION_ID and DISABLED write their receipt with NO injected -StateDir. An
  earlier draft resolved the state dir after those branches, so the receipt was
  unwritable in production while a test that always injected one went green.
- Self is excluded by BOTH nets independently: a roster that cannot tell you from
  a sibling makes the session message itself.
- Hostile peer text cannot escape the peer-data block or emit a non-ASCII byte,
  a hostile session id cannot escape the state dir, and two ids that sanitise
  identically get two markers.
- Two concurrent runs announce exactly once. session-context.ps1 is registered
  twice on this box today, so double firing is a live pattern, not a hypothetical.

* docs(coord): document announcing yourself, and correct a false claim about .claude

WORKTREES.md gains the "Announcing yourself" section that the hook's own emitted
text and the shim's missing-script notice both cite by name, so the pointer has to
land on main in the same merge. It states the id rule ONCE, as the source of
record: registry id is not the MCP id, cwd is the only join key and must be
matched exactly rather than by prefix, a usable id starts with local_, and a wrong
one fails silently.

It also states what the change does NOT do. There is no receive-side hook, so the
rule that an announcement is peer DATA -- not an operator instruction, and not
something to reply to -- lives in the prose and in the fixed message shape and
nowhere else. Reachability is given honestly: presence.ps1 is authoritative for
who EXISTS, list_sessions only for who can be MESSAGED, and measured, they
disagreed 6-to-1. Cost is stated rather than left to be discovered.

CORRECTION, and it is why this doc change is in scope rather than deferred: the
same chapter claimed ".claude/settings.json is tracked (shared across worktrees)".
It is not. /.claude/ is git-ignored, and git ls-files .claude/ returns nothing --
so a worktree's copy is a creation-time snapshot nothing refreshes and several
siblings have none at all. That sentence sat at the exact point a reader decides
where to install a hook, and it argues for the wrong answer; the new section
directly contradicted it.

SESSION-DRIFT-CONTROLS.md records announce as the only PUSH control in the D4
layer, plus the two new guarantees worth tracking separately: that wiring reaches
a script that exists, and that a resolution failure is now reported by the shim.

* fix(coord): stop the collision gate blocking files a peer committed and finished

Reported by another session with a repro: it committed a file, went clean, said
in writing it was done and handed the file over -- and the peer it handed off to
was still refused the edit.

overlap.ps1's `Files` is the UNION of what a branch COMMITTED-and-not-yet-landed
with what is dirty in its tree. The gate denied on any live row in that set, so
"this branch authored it" was treated as "someone is typing in it right now".
Those are different claims. The first stays true for the branch's whole life;
only the second is what the gate exists to detect.

It self-clears on merge -- overlap already intersects three-dot with two-dot so a
LANDED branch stops claiming its files. But nothing clears it before landing, and
with PRs currently unable to merge, "until it lands" is indefinite: the blocked
set grows monotonically and is never released. Two sessions that coordinated
correctly and explicitly still cannot hand a file over. That is precisely the
failure this gate's own docstring names -- a gate that cries wolf gets
uninstalled.

overlap.ps1 already told callers to treat its signals differently ("block on
live, mention dormant"), but no caller COULD: the row unioned the two signals
away. So the row now carries `Dirty`, and the single-file query sets
`MatchedDirty` saying which signal actually matched.

The gate now DENIES only on an uncommitted edit in a live worktree, and REPORTS
committed-and-clean as context instead -- the peer may already have done what you
are about to do, which is worth knowing and not worth refusing over.

Fails SAFE across the upgrade: a cached row predating `MatchedDirty` has no such
property and is treated as dirty, so the gate degrades to its previous
over-blocking rather than silently permitting a real collision.

Also, while in the file: `git status` now runs with --no-optional-locks. A plain
status REWRITES the index of the repo it inspects, and this walks every peer
worktree -- so merely asking "what is in flight" was mutating other sessions'
checkouts.

Verified against the live repro and both directions: the reported file now
allows with context; a file with uncommitted changes in a live worktree still
denies; an untouched file stays silent.

* feat(coord): lead the announce roster with the claim note, not the worktree name

Reported by the session it happened to: its worktree is named
inter-session-communication-*, auto-generated at creation from a task that
session has never worked on -- it has been doing ASVS scorecard work for its
entire life. The directory name is the most visible identifier in presence.ps1,
overlap.ps1 and this hook's output, and it had already misled TWO sessions
(including this one) into guessing that session was building the announce hook.

A worktree name is a creation-time label, not a statement of current work, and
nothing keeps the two in sync. The claim note is the only field written
DELIBERATELY to say what a session is doing, so the roster now prints it, and the
legend tells the reader to prefer it over the name.

Joined on the claim's `worktree` path, normalised the same way as every other cwd
key here. Fail-open throughout: no claims directory, an unreadable claim, or a
peer with no claim all just mean the name is the only thing we have -- which is
exactly the status quo, never an error.

Same session also flagged that the branch I read for it from list_sessions was
stale (a spent, merged branch). The announce text already refuses to join on
branch and says why; this is a second, independent reason not to trust it.

* docs(coord): name the silent-control defect class in the drift inventory

A control that cannot distinguish 'ran and resolved' from 'ran and found nothing'
is not installed, however it looks. The announce shim outlived every other
silent-control defect found the same day BECAUSE it printed a status message --
which is more convincing than silence.

The structural cause is the reusable part: every receipt that hook would have
written lived inside the script the shim failed to find, so every check sat
strictly downstream of the failure it existed to detect. Looking was not
neglected, it was impossible. The question to ask of a new control is which
surface still reports when the control itself fails to load.

Formulation owed to a peer session that hit four instances of this class in one
day and named it more sharply than I had.

* docs(coord): record the broadcast constraints six sessions learned the hard way

Announce-on-join introduces a session; it does not let an established one push an
operational notice. That increment is deferred, and on 2026-08-01 six sessions
rehearsed it by hand for four hours. Three constraints fell out, recorded so the
next attempt does not rediscover them:

- A broadcast needs an EXPIRY or a predicate the RECIPIENT can evaluate, never a
  promise from the sender. A merge freeze shipped with 'lift when #119 merges';
  #119 died on an unrelated CI timeout, so five sessions held on a condition that
  could not arrive and a second round was needed to retract it.
- 'Don't do X' is the wrong primitive when automation already has X armed. The
  freeze asked for restraint while six PRs had auto-merge ARMED and would have
  landed with nobody clicking anything. The right ask was an action: disarm.
- Coordination a tool cannot read does not count. Two sessions agreed IN WRITING
  to hand over a file and the gate still refused, because the agreement was prose
  and the gate reads git.

Field data from the sessions that lived it, not speculation.

* test(coord): pin overlap's dirty-vs-committed signals against real git

Nothing drove overlap.ps1's row computation against a real repository, so the
question "does MatchedDirty hold when a file is dirty AND committed at once" was
unanswerable by the suite. Raised by the session that spent an evening in exactly
that state.

THAT CASE IS THE ONE THAT FAILS SILENT, which is why it gets a real fixture
rather than a stub row. A peer with uncommitted edits in one region and landed
work in another is a genuine collision. Had MatchedDirty been derived from the
committed diff instead of the working tree it would read FALSE there, the gate
would allow, and two sessions would write one file with nothing reported. The
over-block this replaced was loud and annoying; that would be quiet and cost
someone their work.

Verified the tests can SEE it rather than assuming: sabotaged the row to publish
an empty Dirty set -- the precise mis-implementation warned about -- and both
MatchedDirty assertions went red; restored, all five green. A test written after
the code, never observed failing, is a test of nothing.

Also pins that overlap does not rewrite a peer worktree's git index, by comparing
the index mtime across two queries. An observer must not perturb what it
observes, and this one was doing so on every PreToolUse before f55d6c6.

Stub rows would only have asserted that the plumbing carries a value someone else
computed; the whole question here is what git actually reports.

* test(coord): assert a wired coordination hook resolves to a script that exists

Raised by the session that traced the shim: the coordination hooks are not
installed copies, they are inline commands that locate their script in a working
tree at every invocation. If neither base yields the file, Test-Path fails, the
loop ends, nothing runs, and the tool call proceeds with no hook and no signal.
"The hook is uninstalled" and "the hook ran and permitted this" are
indistinguishable from outside, and nothing was watching.

Not hypothetical: a foreign UserPromptSubmit entry sat in this same settings file
for weeks probing a script that exists only in another repo.

The risk composes badly for collision_gate.ps1 specifically, which now (a) fails
OPEN on any error, (b) denies less by design after the dirty-vs-committed split,
and (c) silently no-ops when unresolvable. Individually defensible; together the
realistic bad day is "the gate was never running and nobody noticed". This closes
(c) -- the observation is not mine, and it is a good one.

Found immediately on writing it: FIVE user settings files across account
directories, not the one I knew about. The informational test also prints the
original defect as output rather than leaving it invisible:
  FOREIGN UserPromptSubmit [mefor-web-announce] -> scripts/hooks/announce.ps1:
  RESOLVES NOTHING HERE
It is another repo's entry, so this reports it and does not touch it.

Carries a NEGATIVE CONTROL, because the assertion passed on the first run and a
green that has never been shown to fail is not evidence. The real hooks cannot be
unwired to prove the predicate works -- the primary checkout is shared with live
sessions -- so it is exercised against a path known not to exist.

Local-machine only: CI has no user settings and these skip there, which means CI
does NOT guard this property. Said plainly, and every test prints what it scanned
BEFORE it can skip, per test_gate_installed_parity.py -- the pytest config has no
-rs, so a skip would otherwise render as a bare dot with no reason.

* docs(adr): ADR 0158 silent controls, plus a session handoff

Session ended on an owner stop-work instruction at 96% weekly account usage, so
this lands the two things that would otherwise have existed only in a transcript.

ADR 0158 records a defect class that recurred at least a dozen times across
independent surfaces in one working day, in at least two sub-classes: a bound
stated independently of the thing it bounds, and a control that cannot observe or
act on its own failure. Its spine is that a signal carrying too little
information to act on makes every reader re-derive significance by hand until one
of them derives it wrong -- so a correct-but-useless RED costs what a silent green
costs.

EVERY FIGURE IN IT WAS RE-DERIVED BY SOMEONE WHO DID NOT PRODUCE IT, against the
repository and the GitHub API. That pass refuted six claims, including four CI
numbers that were already merged, and including corrections this session had
itself issued hours earlier. Seven retractions are recorded INSIDE the document,
each carrying a found-by tag -- because the central empirical finding is that no
retraction was made by the author of the claim it retracts, and that is invisible
if attribution is smoothed into one voice.

Shape over detection is reported as a ratio rather than flattered: three fixes are
covered by tests in required CI legs, two by tests that always skip in CI, one by
a workflow change with a live residual, and the rest are corrected prose or still
open. The Decision separates ENFORCED rules, each naming its gate, from CONVENTION
that is knowingly re-breakable.

The handoff records what is pushed, what is filed-not-built, and the traps -- a
linked worktree's .git being a FILE, a Windows Python unable to read MSYS paths, a
raw hasher giving a false mismatch against a git blob on CRLF, and claim.ps1
silently discarding a note refresh. Each is stated as a fact plus its measurement.

It also records, first, the five claims this session got wrong -- including
retracting a CORRECT estimate on the strength of an incorrect measurement, and
sending that false claim to four sessions and the correction to only three.

One more arrived while committing this: the leak gate rejected the handoff for a
branch slug, on a line a standalone run of the same scanner had passed. The hook
scans STAGED files; the standalone run scanned tracked ones. Two scopes, one tool,
and only the fail-closed gate could see it. Recorded in the handoff.

No engine behaviour changes.

* docs(adr): land ADR 0158 -- silent controls, green signals that mean nothing

ADR 0158 was authored and committed in 994bfb1 on
claude/intersession-communication-hooks-a52335, a trailing commit pushed about an
hour and a half AFTER that branch's PR (#133) had already squash-merged. It
therefore never reached main and no PR carried it, while the coordination ledger
had already allocated the number: docs/adr/README.md stopped at 0156 and 0158 was
taken, so the index pointed at a document that did not exist.

That gap had a cost. At least four sessions cited this silent-controls taxonomy as
"ADR 0157" -- an unrelated HA demotion-safety document allocated to another
worktree and still in flight on PR #139. The document that settles the citation
was the one sitting unmerged.

This branch is cut from 994bfb1 itself, so the original commit stays in history
and authorship is exact. The prose, voice and ASCII-only convention are its
author's. This commit drops the session handoff and makes three factual
corrections where main moved underneath the branch after it was written, each
tagged inline in the ADR's own update convention rather than silently rewritten:

  * 0fdc326 is unreachable from main (this repo squash-merges). It is now given
    as "merged as 851c849 (#130)", matching the mapping the ADR already uses for
    7ebb2ff/2a6649fb.
  * transports/email.py and transports/direct.py were cited as carrying the same
    bare starttls() call. 093db33 (#132) gave both an explicit verifying context;
    pipeline/alert_sinks.py:384 is now the only remaining instance.
  * The "the false sentence is still there" claim (five sites, one of them
    numbered Decision rule 13) is closed out: on main the clause survives only
    inside its own CORRECTED block at :5270 and as a quotation at :7476. The
    interval is recorded; the rule it produced is unchanged.

HANDOFF-announce-hook.md from 994bfb1 is deliberately not landed: it is session
state rather than project documentation, no root HANDOFF-*.md has ever existed on
main, and it would publish local shim mechanics into a public repo. It stays on
its own branch.

Verified: exactly one commit in the repository ever added a 0158 ADR and exactly
one 0158 filename exists across all refs, so nothing competes for the number. The
index row is unchanged from 994bfb1 and appears exactly once.

No engine behaviour changes.

* docs(adr): make the 0158 TLS update non-perishable

The correction I added said 093db33 (#132) left alert_sinks.py as "the only
remaining instance on main". That is a checklist-shaped claim with an expiry
date: BACKLOG #323 layer 3 (PR #142) closes the alerts call site, and the
sentence goes false the moment it lands. Dating the observation does not help a
reader who greps for it in a month and finds nothing.

Restated as what happened rather than what is currently true -- #132 closed the
two connectors, the alerts call site is tracked as #323 layer 3 -- so it holds
whether or not #142 merges, and it says outright that the current state must be
grepped rather than cited from here.

Deliberately does NOT assert that #142 closed the cell: #142 is open at time of
writing, and asserting a merge that has not happened is the same defect pointing
the other way.

found by: the repo-security-review session, which owns #142 and re-derived all
three call sites against origin/main before raising it.

* docs(adr-0158): replace rotting line-number citations with greppable strings

The document's own rule, applied to itself: a quoted string survives a file
edit, a line number does not. Ten citations replaced.

WHY NOW. All three ci.yml citations (:229, :233, :254) resolve to unrelated
text the moment #138 lands, and six docs/BACKLOG.md citations had ALREADY
rotted on main before that -- +14 to +40 lines of drift from #345/#346/#347
being appended, with every cited claim surviving verbatim at a new address.
Measured fresh against origin/main and against #138's branch, not reused from
the report that found them.

TENSE, not just addresses. Two of the quoted strings do not survive #138 --
"Measured over the 11 PASSING windows-2025 runs" and "1.46x" are both deleted
by it, because #138 ADOPTS this ADR's retractions 1-3 wholesale (12:31, 21:34,
25:51, 1.006x, 1.206x, pools 42/39/36). Left in the present tense those two
sentences would ship knowingly false the hour #138 merges, so they now say
what ci.yml stated when this was written. The retractions themselves are
unchanged and are vindicated by #138, not contradicted.

ANCHORS ARE SINGLE-LINE ON PURPOSE. A first pass rewrapped two quotes across a
newline, which makes them ungreppable and would have swapped one rot for
another. Every anchor is now verified to grep as one line AND to resolve in
the tree it points at -- "ZERO tests failing" resolves in ci.yml both on main
and after #138.

pyproject.toml:266 was simply wrong: the zizmor pin is at :271, in the group
opening at :268. Replaced with the group name, which is what the sentence
needed and cannot rot. The residual it reports -- that the pin's home is
outside zizmor's paths filter -- is verified TRUE and unchanged.

OUT OF SCOPE, deliberately: line numbers into less volatile files remain
(test_stage_dispatcher.py, claim.ps1, zizmor.yml, install-coordination.ps1,
freethread-smoke.yml, collision_gate.ps1). So the ADR does not yet "state no
line numbers" outright -- see the handoff note.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant