fix(coord): the statusline installer wrote a config root no session reads, and five accounts shared one publish path - #650
Open
wshallwshall wants to merge 11 commits into
Open
Conversation
A box can run several Claude config roots at once -- ~/.claude for a bare `claude`, and one ~/.claude-account-<N> per launcher, each pinned through CLAUDE_CONFIG_DIR. Three scripts in scripts/coord need the same three answers about that: which roots exist, which one am I in, and where does this root's usage state live. Answering separately is what let install-usage-statusline.ps1 write ~/.claude/settings.json while every session on the box read a pinned root. The predicate itself is not new -- it is carried from install-gate.ps1 and its Python twin in tests/test_gate_installed_parity.py, along with both measured incidents behind its anchors, rather than re-derived a third time. One correction to that inheritance: the array return drops the comma. The comma is right for install-gate.ps1's HashSet, where unrolling destroys the type, and wrong for an array. Measured: with `return ,@(...)`, a caller writing `@(Get-ClaudeConfigRoots ...)` gets ONE element that IS the array, whose string form is every path joined by a space. That reached this branch as a single -AllRoots target named "<root-1> <root-2>\settings.json", reported as "Roots examined: 1", and it survived a smoke test because Split-Path happens to accept arrays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eads It defaulted -SettingsPath to ~/.claude/settings.json and printed "statusLine INSTALLED (user level -- every session on this machine)". Claude Code reads settings from the root named by CLAUDE_CONFIG_DIR, and every launcher on the box this was written for pins one, so the claim was false: the statusLine never fired, nothing ever published, and usage.ps1 correctly reported the collector as not installed. An install success followed by a reader saying it was never installed -- two instruments disagreeing, the wrong one louder and earlier. Five rules now decide the target set, first match wins: explicit -SettingsPath, explicit -ConfigDir, -AllRoots, the CLAUDE_CONFIG_DIR pin, then ~/.claude. -AllRoots is opt-in and covers account roots only, diverging from install-gate.ps1 (which defaults to all) because a security gate fails by under-reach while this writes into vendor-owned directories belonging to different accounts. The message is the other half of the fix, and it is why this was expensive to find. "every session on this machine" is deleted rather than conditioned -- a completeness claim is a liability, and naming each file written is shorter and true. Every line is now paired with a check that backs it, WROTE and REWIRED are distinguished, and no line says "publishes to" when all that happened is that a settings key was written. Four defects found while building it, each measured rather than reasoned: - Explicitness must be captured at SCRIPT scope. A function's own $PSBoundParameters is EMPTY, so a resolver testing it there returns False for every caller -- and `-SettingsPath <fixture>` from the test suite would have been ignored in favour of the caller's live pinned root. - Get-Settings closed over a script-scope $SettingsPath, which turns dangerous the moment that parameter is [string[]]: `Test-Path -LiteralPath @(a,b)` returns "True False", `-not` on it is False so the guard PASSES, and Get-Content -Raw over two existing files returns them CONCATENATED, which ConvertFrom-Json accepts as an array of two objects. It is now read per path. - The ownership test had to stop being a substring match. The wired command now contains "\mefor-usage" inside its -StateDir argument, so `-like "*mefor-usage*"` would judge any foreign statusLine that merely mentions the publish path to be ours, and silently replace it in up to five roots at once. It is anchored on the first line, and three-way: a statusLine present with an empty command is NONE, not FOREIGN, which was a state with no exit. - ConvertTo-Json -Depth 20 truncation is NOT caught by parsing the result back. Measured with a 24-level document: it warns, and the truncated text still parses cleanly with the deep node replaced by its type name. Both guards run. -Status and -Uninstall follow the same five rules. -Status now reads each root's publish path and collector back OUT of its wired command instead of recomputing them, reports login markers, and runs before the git guard, because auditing is not installing. -Uninstall going single-root under a pin was the worse lie of the two: the operator believes they turned something off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
usage-collect.ps1's header asserted "the quota is ACCOUNT-WIDE, so any ONE session's reading is the truth for all of them" and published to a single user-level file. That premise is true per Anthropic ACCOUNT and false on a box running several config roots: a config root holds one credential set and therefore one account, and the box this was written for has five roots carrying five different account emails and five separate 5h and 7d pools. One shared file across them is last-writer-wins across unrelated quotas, and the damage compounds. The percentage flaps. The carry-forward can leave five_hour from one account beside seven_day from another in one document. And usage.ps1's staleness guard never fires, because some other account keeps the file warm -- so the guard looks present and is disarmed, which is the worst of the three. Measured before changing anything: latest.json and history.jsonl were absent in all six roots, so nothing had ever published and none of this had fired yet. The installer's new -AllRoots is what would have armed it, which is why the two ship together rather than in sequence. No migration and no fallback: there is no file to move, and a fallback reading the old path would serve the unpinned root's document to a pinned session -- the exact bleed being removed. The publish path is now <config root>/mefor-usage, derived by one function that the collector, the reader and the installer all call. The filesystem is the partition key, so two roots cannot collide however CLAUDE_CONFIG_DIR is spelled. ~/.claude/mefor-usage keeps its existing contents untouched; only latest.json and history.jsonl are scoped. The published document now records where it came from, in three fields, because one would detect nothing. config_root is a label derived from the write path, so it agrees with that path by construction. config_root_env is the ambient pin read live, and it is what usage.ps1 gates on: a document whose config_root_env names a root other than the one it sits under is refused, not reported. Whether CLAUDE_CONFIG_DIR reaches a statusLine child process is UNMEASURED, so a document with no stamp is read and labelled UNVERIFIED rather than refused -- absence of provenance and wrong provenance are different facts. usage.ps1's "no data" message replaces one sentence with five diagnosed states. The old one said "not installed or has not run yet" and printed the bare installer command with no root, so following its advice re-ran the invocation that produced the false INSTALLED claim. The state that matters most is WIRED_ELSEWHERE: without it the reader tells the operator to restart and wait, forever, while nothing anywhere says the two halves disagree. Two guards that are easy to leave out. New-Item -ItemType Directory -Force creates every missing ancestor, so an unvalidated pin would have a live session manufacture a config root nothing can launch from; the collector now creates the leaf only, against a parent that already exists. And usage.ps1 runs under SilentlyContinue, which turns a failed dot-source into a confidently wrong answer rather than an error, so it refuses on the library, not on the path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirty-five tests, driven as real subprocesses against a fixture home shaped
like the real box -- including all three kinds of look-alike directory, because
each defeats a different plausible predicate. .claude-account-2.lock carries a
settings.json and no .claude.json, so "has settings" adopts it. .claude-desktop-N
carries a .claude.json and nothing launches from it, so "has .claude.json"
adopts four directories no session can boot from.
Two tests exist because a fixture can lie. The pin is POPPED from the child
environment rather than merely overwritten: this suite runs inside a Claude Code
session, which on the box in question is itself pinned, so a child would inherit
CLAUDE_CONFIG_DIR and every "no pin" test would quietly exercise the pinned path
and pass. And -HomeDir is passed rather than USERPROFILE overridden, because
measured, [Environment]::GetFolderPath('UserProfile') ignores that override --
so a script resolving home itself could not be redirected, and -AllRoots would
enumerate and WIRE the real account roots. One test asserts both properties
directly, since everything else here depends on them.
The anchor test runs the whole chain with nothing recomputed by the test:
install into a pinned root, execute the string the installer actually wired
exactly as Claude Code would, read it back as a session in that root would, and
confirm a second root still sees nothing. A hand-written stamp would have let it
pass whether or not the collector produces one.
One existing test changes and gets stronger. It used to rewrite the wired
command to inject -StateDir, because the command carried no publish path; now
that the installer bakes a per-root path in, that injection binds -StateDir
twice and pwsh refuses it. Deleting the rewrite is what lets the test run the
production string verbatim, which its own docstring always claimed it did.
Stated rather than implied: the -Force on the directory glob is structurally
untestable here. Its absence is invisible on Windows and collapses the set to
nothing on Linux, and this module is skipif'd to nt, so only a CI ubuntu leg
could ever observe it. This file has none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h one The "one publisher, N readers" paragraph asserted the publish path is user-level "because the data is a property of the account, not of a checkout". Half right: it is a property of the ACCOUNT, and a config root is what selects one. On a box running several roots the user-level path merges five accounts into one file. Also pins the predicate against its other three copies rather than migrating them. config-roots.ps1 exists so install-usage-statusline.ps1 would not write a fourth, but it could not simply absorb the rest: install-gate.ps1 pairs its copy with a deliberately WIDER audit population that must not be selected by the predicate it checks, and tests/test_gate_installed_parity.py holds that copy in parity with a Python reader. Folding them in is its own migration with its own test surface, so the rule is asserted as one BEHAVIOUR across all four instead -- an edit to any of them turns the test red rather than going silent. The one place they genuinely differ is named rather than left latent: install-coordination.ps1 uses -match, which is case-insensitive, while [regex]::IsMatch is not, so a `.Claude-Account-2` would be accepted by one and rejected by the other. No such directory exists here, and the installer's -Status audit reports any ~/.claude* directory with a settings.json that the predicate rejected, which is what keeps an under-reach loud. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ALL THREE ARE THE SAME SHAPE AS THE BUG THIS BRANCH EXISTS TO FIX -- an instrument reporting something it had not checked -- which is why they are worth their own commit rather than a squash. 1. -Uninstall counted a root as removed OUTSIDE the ShouldProcess guard, so `-Uninstall -AllRoots -WhatIf` printed "removed: 5" and exited 0 having removed nothing. An operator dry-running before committing reads that as "the collector is off" while every root keeps publishing, and a coordinator branching on the documented exit codes gets 0, which the header defines as every root reaching the desired state. Worse than the install claim this branch deletes, because a person believes they turned something OFF. The counter moves inside the guard and a WOULD REMOVE line and column appear beside it, mirroring the install path. 2. The provenance stamp described the WRITER of the document, not the ORIGIN of the numbers in it, and the carry-forward is exactly the hop between the two. Root A publishes into root B's directory; B's next session fires before its first API response, carries A's percentages forward and rebuilds published_by with B. A document-level check then compares B against B, passes, and reports A's headroom as B's. The window kept its older captured_at and would have been called stale eventually -- never FOREIGN. So the origin now travels WITH the window, exactly as captured_at already does: a freshly observed window is stamped, a carried one keeps the stamp it arrived with, and usage.ps1 applies the refusal per window as well as per document. 3. The WIRED_ELSEWHERE diagnosis was computed only inside the no-data branch, which made it unreachable in the case where it misleads most. A root re-wired to publish into a sibling still holds its own older latest.json, so the reader served that stale percentage as current for twenty minutes and then said "no live session is publishing" -- also false; the session is publishing, just elsewhere. The diagnosis now runs on every invocation and a mis-wire prints ABOVE the numbers, because saying it afterwards is too late to stop the wrong decision. Also from the same review: Get-ClaudeConfigRoots was a name already taken in scripts/coord by session-registry.ps1, which eleven scripts dot-source and which answers a different question (roots that have RUN a session, by a sessions/ subdirectory) with a different parameter set. Sharing the name would make the winner depend on dot-source order, and in one direction silently: that function resolves home from $env:USERPROFILE itself, so a caller expecting the -HomeDir seam would enumerate the real home. Renamed to Get-LaunchableConfigRoots. And a "skipped" tally column that nothing could increment: the catch reports a failed backup as FAILED, so the column was structurally always zero, which reads as "nothing was skipped" -- a claim about the run rather than a fact about the code. Each fix is pinned by a test validated against its own reversion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same defect class as the rest of this branch, found by the same review. - The install path printed a "backup <path>" line for every root, including ones that had no settings.json to copy. An -AllRoots run over five roots would send an operator looking for five backups and finding two. Write-SettingsFile now reports whether it took one and the caller prints accordingly, naming the no-backup case explicitly rather than staying silent about it. - -Status reported an unparseable settings.json as "carries no statusLine of ours". A corrupt file is not a clean one: it may carry a working statusLine the audit cannot see, so that wording steers an operator AWAY from a stray publisher. Both the per-root report and the independent audit now say the file could not be parsed and that ownership is therefore unknown. - usage.ps1's staleness message asserted "no live session is publishing", which nothing in the script checks and which is often false -- a session may be publishing perfectly well, into another root. That is now the mis-wire warning's job, printed above the numbers; the staleness line states the age and stops. - The -AllRoots survey heading read "Every config root on this box" while enumerating by an anchored, case-sensitive name shape with no second opinion beside it. A root spelled `.Claude-Account-7` and burning quota would be absent from a list an operator reads as complete. The heading now names its rule and its bound, and a second pass selected by a DIFFERENT rule (any ~/.claude* directory carrying a settings.json) names anything the survey did not cover -- so the survey can no longer confirm only its own predicate. - A CLAUDE_CONFIG_DIR naming a directory that does not exist was diagnosed as "this root has no settings.json", whose remedy is an installer invocation the installer REFUSES, because it will not create a config root. The operator follows the advice, gets a refusal, and nothing has named the actual fault. NO_SUCH_ROOT is now its own state and its remedy is to fix the variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting the staleness line's "-- no live session is publishing" was right: nothing in the script checked it, and it is false whenever a session is publishing fine into another root. But the WARNING that replaced it fires on a MIS-WIRE, and a healthy root reports WIRED_HERE, so the commonest real failure fell through to a bare "reading is N min old" -- the line a reader skims past. That is not hypothetical. Measured on this box tonight: every root wired, every collector present, and latest.json frozen for 47 minutes across nine live sessions. The instrument was validated first -- a statusLine fire carrying NO rate_limits still rewrites the file, because the carry-forward stamps a fresh captured_at -- so a frozen mtime means not firing at all rather than firing empty. So wired-and-silent gets its own arm, and every clause in it is backed by a check already run: "wired, and the collector it names exists" is what WIRED_HERE tested (ownership, an extracted -StateDir matching this directory, Test-Path on the collector); "nothing fresh" is the window states. The two possible CAUSES -- no session started here since wiring, or a statusLine that never runs -- are offered as alternatives rather than asserted, because nothing available can tell them apart, and the reader is pointed at the one instrument that can: the status bar itself. The properties the staleness guard already provided are asserted alongside it in the same test, because this arm sits next to them: the number still shows, its age still shows, and it is still never projected from. Found by the Steward seat, whose stated concern is that a quiet plausible stale reading is more dangerous than a loud failure -- and which pointed out that the case I had just measured was precisely the one my replacement did not cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every one is the same shape as the bug this branch exists to fix: a guard added in one place and not its twin, or a line asserting a check that never ran. 1. THE SURVEY NEVER APPLIED THE PER-WINDOW REFUSAL. Get-RootSummary checked only the DOCUMENT stamp, which a carry-forward passes -- the document is written by this root and sits under this root while the numbers inside came from another. Measured: in one run the single-root reader refused both windows with "refusing to report another account's headroom" and the survey twelve lines lower printed those exact percentages as the other root's own. The survey is the ONLY place accounts are compared, so it is where the refusal matters most and it was the one place it did not run. The test is now one function, Test-WindowFromRoot, called by both paths. NOT by calling Get-WindowReport per row, which was the obvious fix and is wrong: Get-Rate closes over the reader's own $histPath, so every other root's row would compute its burn rate from this root's history. 2. THE SURVEY AGED ROWS BY THE DOCUMENT STAMP, which the collector rewrites on every fire including a pure carry-forward. A six-hour-old reading rendered as "[seen 0 min ago]" -- a stale number wearing a fresh timestamp, the exact shape honesty rule 1 forbids. Rows are aged by the older of their two windows, and a row past -MaxAgeMinutes renders STALE. 3. THE VERDICT IGNORED THE WIRING DIAGNOSIS. A root the script itself called mis-wired printed the WARNING and then exited 0 with state=OK, beside statusline_state=WIRED_ELSEWHERE in the same document. Two instruments disagreeing inside one script. The state list is hoisted so the prose and the verdict cannot drift, and an untrusted diagnosis makes the reading UNKNOWN. An explicit -StateDir is exempt: there the operator named the directory. 4. THE WIRED-AND-SILENT WARNING USED -or WHERE IT NEEDED -and, so it fired on a document published 0.15 seconds earlier: a fresh five_hour beside a seven_day that had simply never been published leaves one window UNKNOWN, and the arm claimed "nothing fresh has published here" over a reading taken moments ago. 5. THAT SAME WARNING ASSERTED "the collector it names exists" WHEN NONE WAS NAMED. A wired command carrying a -StateDir but no recognisable collector assignment yields a null collector path, which skips the Test-Path guard entirely -- so the clause asserted a check that never ran. It is now conditional on one having been named. 6. test_opus_is_not_claimed_as_a_blind_spot COULD NOT FAIL FOR ITS OWN DEFECT. Written as a single disjunction, re-inserting the exact false sentence while leaving the "NOT gaps" text in place kept the expression true and the suite green. Split into two independent assertions. 7. TWO INSTALLS IN THE SAME SECOND DESTROYED THE ONLY PRE-INSTALL BACKUP. The stamp has one-second resolution; measured, run 2 overwrote run 1's original with run 1's post-install content. A backup holding post-install content is worse than none, because the operator is told one exists. The backup now picks a free name and the output reports where the copy actually landed. Two findings were REFUTED by their verifiers and are not acted on: that -AllRoots writes the ambient CLAUDE_CONFIG_DIR root despite -HomeDir, and that a live root points at a temp checkout. One test fixture changed rather than the code: a root holding a fresh document while carrying no statusLine is a state that cannot occur, since something must have been wired to publish there, and with the diagnosis now reaching the verdict that contradiction made the test assert OK over a root the script calls unwired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wiring diagnosis now reaches the verdict, and the assertion pinning that went into the no-data test next door -- which exits 20 because there is nothing to read, whatever the diagnosis does. It could not have failed. Moved to the test whose fixture can actually distinguish the two: a mis-wired root holding a FRESH reading. Without the fix that is a clean OK and exit 0, printed in the same run as a WARNING and statusline_state=WIRED_ELSEWHERE. Found by reverting the fix and watching the test stay green, which is the only reason it surfaced. The first fail-first pass reported it green too and I read that as "the test does not pin it" -- but three of the five reversions had silently not applied at all, because inline `python -c` inside a double-quoted bash string ate the dollar signs before python saw them. A reversion that does not apply is indistinguishable from a test that does not pin, and it is the more flattering of the two readings. The reversion helper now fails loudly when its target string is absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by the owner running -AllRoots against their real roots. One block read:
replaced a mefor-usage statusLine that published somewhere else
was: ...\.claude-account-5\mefor-usage
now: ...\.claude-account-5\mefor-usage
The publish path had not moved at all. The COLLECTOR had. A sentence
contradicting the two lines printed under it is exactly the defect this branch
exists to remove, shipped inside the fix for it.
The cause is that REWIRED is entered whenever the command is not byte-identical,
which is a WEAKER fact than any single reason for it, and the message named one
reason unconditionally. The reason is now DERIVED from what actually differs --
no -StateDir at all, a different publish path, a different collector, or
something else -- and when the collector is what changed, both paths are printed
so the operator can see which one moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects in the usage tooling, the second found while fixing the first. Authored by the statusline seat; opened by the LANDER on their handover. Not armed — see the last section.
The install lied and the reader was right
install-usage-statusline.ps1wrote~/.claude/settings.jsonunconditionally and reportedINSTALLED (user level -- every session on this machine). Claude Code reads settings from the root named byCLAUDE_CONFIG_DIR, which every launcher on this box pins, so the statusLine never fired — whileusage.ps1correctly reported the collector as not installed.An install success followed by a reader saying it was never installed. Two instruments disagreeing, and the wrong one was louder and earlier.
The publish path was shared across five different accounts
A config root holds one credential set and therefore one Anthropic account. This box runs five roots with five different account emails and five separate 5h/7d pools — all publishing to one file. That is last-writer-wins across unrelated quotas.
The worse half: it disarms
usage.ps1's staleness guard, because another account keeps the file warm. The guard looks present and is not.Publish path is now
<config root>/mefor-usage, derived by one shared function that the collector, the reader and the installer all call. Provenance travels per window, not per document, so a carry-forward cannot launder one account's percentages into another's.Verification, reported by the author
ruff check,ruff format --check,mypystrict all clean.Behaviour change to know about
A bare
usage.ps1now answers for this session's account rather than for the box.-AllRootssurveys every root.docs/WORKTREES.mdis updated in the same branch.After merge, the installer must be re-run per root, or with
-AllRoots, from a plain terminal — every root currently carries a legacy command with no-StateDir.Stated so the merge is not misread as fixing it
Nothing publishes on this box at present, and this branch is not the cause.
latest.jsonsat frozen for 47 minutes across nine live sessions, every root correctly wired. The instrument was validated first — a fire carrying norate_limitsstill rewrites the file — so this means not firing at all, not firing empty. Most likely the desktop client renders no status bar; unconfirmed. No seat's hook reads that file, so it costs nothing operationally today (Steward's measurement, three ways).LANDER notes
Checked before opening: tip
b35157d16matches origin exactly, 10 ahead / 0 behinda490993b4, and the file set is disjoint fromworktree_gate.ps1and frommessagefoundry/— so no interaction with the held set (613, 615, 636, 640) or withb1-1067.This PR is deliberately NOT armed. Review approvals required on this repo is 0, so arming merges unread. The owner's sign-off reached me relayed through the author's session rather than in my own, and opening a PR is reversible while arming is not. Arming is the owner's to trigger, or to tell me directly.