Skip to content

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

Merged
wshallwshall merged 19 commits into
mainfrom
claude/announce-hook-intersession-d11524
Aug 2, 2026
Merged

feat(coord): publish the account's plan limits so a session knows before it is cut off#152
wshallwshall merged 19 commits into
mainfrom
claude/announce-hook-intersession-d11524

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Sessions were hitting the plan limit mid-task and losing work. This publishes the account's real quota state from Anthropic's own statusLine payload, plus a burn rate, so a session can act before it is cut off.

pwsh -NoProfile -File scripts\coord\usage.ps1 -Json — exit 0 OK / 10 WARN / 11 CRITICAL / 20 UNKNOWN, so callers branch without parsing prose.

The band is not "is the number big" but does this window run out before it resets — 80% with two hours left and nothing running is fine; 45% with 30 minutes left and six sessions compiling is not. The advice string is an ACTION (commit now and write your handoff), not restraint, because that is what makes a cutoff cost nothing.

Four caveats, stated in the tool's own output

  1. Not installed by merging. The installer refuses until the collector is in the PRIMARY checkout — deliberately, since wiring a machine-wide statusLine to a nonexistent path is the merged-but-inert failure that left the announce hook dead for hours. After merge: advance the primary, then run the installer from a plain terminal.
  2. UNKNOWN is a real answer, not OK. The statusLine does not run headless (never under claude -p), so a dead publisher is the expected steady state. Stale, undateable and future-dated readings all return 20 and refuse to project.
  3. ONE publisher, N readers — never sum. The quota is account-wide; any one session's reading is true for all of them. Summing double-counts one pool. Hence a user-level path, not the repo.
  4. Two of the four Settings>Usage numbers are absent. The payload carries five_hour and seven_day only — per-model weekly and plan tier do not exist here. Two green bars and an invisible third is worse than no tool, so this alone never establishes headroom.

Verification

64 tests pass (20 new + the coord suite); ruff check and ruff format --check clean.

Merge-safety checked three-dot: 5 files, +1,095, -0 — no revert. The branch is currently BEHIND main (#151 landed after it was built), so a two-dot diff reports ~308 deletions including a whole test file; those are main's gains the branch lacks, confirmed by git cat-file -e origin/main:tests/test_coord_claim_liveness.py. It needs gh pr update-branch before it can land.

Authored by the announce-hook-intersession session; pushed and opened by the coordinator session at the owner's request. Merge remains the owner's call.

🤖 Generated with Claude Code

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 ''.
…ked 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.
…arded 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.
…ired

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.
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.
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.
…rent

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.
… 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.
…heir 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.
…t 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.
…ore 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.
…intersession-d11524

# Conflicts:
#	docs/WORKTREES.md
@wshallwshall
wshallwshall enabled auto-merge (squash) August 2, 2026 17:34
@wshallwshall
wshallwshall merged commit 41a8c49 into main Aug 2, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/announce-hook-intersession-d11524 branch August 2, 2026 17:52
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