diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 7c620754..2a6b0068 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -686,14 +686,33 @@ whole shape: | | | |---|---| -| [`usage-collect.ps1`](../scripts/coord/usage-collect.ps1) | the statusLine. Publishes to `~/.claude/mefor-usage/latest.json` | -| [`usage.ps1`](../scripts/coord/usage.ps1) | reads it, adds burn rate, answers *will this run out before it resets* | -| [`install-usage-statusline.ps1`](../scripts/coord/install-usage-statusline.ps1) | wires it (owner, plain terminal) | - -**One publisher, N readers.** The quota is **account-wide** — every session in every repo draws down the -same 5-hour and 7-day pools — so any one session's reading is the truth for all of them. Do not run a -collector per session expecting to sum them; that double-counts a shared pool. The publish path is -user-level for the same reason: the data is a property of the account, not of a checkout. +| [`usage-collect.ps1`](../scripts/coord/usage-collect.ps1) | the statusLine. Publishes to `/mefor-usage/latest.json` — one per account root | +| [`usage.ps1`](../scripts/coord/usage.ps1) | reads it, adds burn rate, answers *will this run out before it resets*. `-AllRoots` surveys every root | +| [`install-usage-statusline.ps1`](../scripts/coord/install-usage-statusline.ps1) | wires it (owner, plain terminal). Defaults to this session's pinned root; `-ConfigDir ` names one, `-AllRoots` does every account root | +| [`config-roots.ps1`](../scripts/coord/config-roots.ps1) | definitions the other three share: what a config root is, which one am I in, where does its state live | + +**One publisher, N readers — per account.** The quota is **account-wide**: every session in every repo +draws down the same 5-hour and 7-day pools, so any one session's reading is the truth for all sessions +*on that account*. Do not run a collector per session expecting to sum them; that double-counts a shared +pool. + +**It is not machine-wide, and an earlier version of this section said it was.** A box can run several +config roots at once, and **a config root holds one credential set and therefore one Anthropic account** +— measured on this box, five account roots carrying five different account emails and five separate +pools. Publishing them all to one user-level file 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. That last one is the worst — the guard looks present and is disarmed. +So the publish path is per config root, derived by one shared function that the collector, the reader and +the installer all call. The full statement of the rule lives in +[`usage-collect.ps1`](../scripts/coord/usage-collect.ps1)'s header; everything else links to it. + +**The installer writes the root a session actually reads.** It used to write `~/.claude/settings.json` +unconditionally and report *"INSTALLED (user level — every session on this machine)"*. Claude Code reads +settings from the root named by `CLAUDE_CONFIG_DIR`, which every launcher here pins, so the statusLine +never fired, nothing ever published, and `usage.ps1` correctly said the collector was not installed — an +install success followed by a reader saying it was never installed. The success message now names each +file it wrote and nothing else. **It only runs in an interactive session.** The statusLine is part of the TUI's render tree and never executes under `claude -p` or the SDK. A headless coordinator can *read* what this publishes and can @@ -713,14 +732,23 @@ blind spot is its own defect**, and a worse one than an omission: a session told unknowable stops trusting a reading that was accurate. Corrected against the actual panel, 2026-08-02. ```powershell -pwsh -NoProfile -File scripts\coord\usage.ps1 # human -pwsh -NoProfile -File scripts\coord\usage.ps1 -Json # coordinator +pwsh -NoProfile -File scripts\coord\usage.ps1 # human -- THIS session's account +pwsh -NoProfile -File scripts\coord\usage.ps1 -Json # coordinator +pwsh -NoProfile -File scripts\coord\usage.ps1 -AllRoots # every config root, side by side ``` +The bare invocation still works and now means **this session's account**, not the box. `-AllRoots` is a +**survey, never a merge**: the roots are different accounts with different pools, so nothing is summed, +averaged or worst-of'd across them, and the exit code stays this session's verdict. + Exit codes so a coordinator can branch without parsing prose: **0** ok, **10** warn, **11** critical, **20** unknown. `UNKNOWN` is a real answer here and is returned whenever the reading is stale, undateable or future-dated — a percentage is never extrapolated from a dead publisher, and every number is printed -with its own age. **Do not read a missing bucket as an empty one.** +with its own age. **Do not read a missing bucket as an empty one.** Two more states return it: a document +stamped with a config root other than the one it sits under is **refused** rather than reported as this +session's headroom, and when there is no data at all the message diagnoses *which* state this root is in +— not wired, wired to publish somewhere else, wired but naming a collector that is gone, someone else's +statusLine, and so on — each with a different fix, instead of saying "not installed or has not run yet". > **`ccusage` does not do this**, despite being the tool everyone recommends and despite several > summaries claiming it "fetches real rate limit data". It parses transcripts for tokens and dollars; its diff --git a/scripts/coord/config-roots.ps1 b/scripts/coord/config-roots.ps1 new file mode 100644 index 00000000..e7b7fbba --- /dev/null +++ b/scripts/coord/config-roots.ps1 @@ -0,0 +1,277 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +<# +.SYNOPSIS + Definitions only: what a Claude config root is, which one THIS session boots against, and where + the usage collector publishes for it. Dot-source it; it does nothing on its own. + +.DESCRIPTION + WHY THIS FILE EXISTS. A box can run several Claude config roots at once -- ~/.claude for a bare + `claude`, and one ~/.claude-account- per launcher, each pinned through CLAUDE_CONFIG_DIR. Claude + Code reads settings from the PINNED root. Three scripts in this directory need the same three + answers about that (which roots exist, which one am I in, where does its usage state live), and + when they answered separately they agreed only by luck: install-usage-statusline.ps1 wrote + ~/.claude/settings.json while every session on this box read a pinned root, and reported + "INSTALLED (user level -- every session on this machine)". The install succeeded, the collector + never fired, and usage.ps1 said the collector was not installed. Two instruments disagreeing, the + wrong one louder and earlier. + + The same blind spot has now been fixed three times in this codebase (scripts/worktree/ + install-gate.ps1 for the worktree gate, install-coordination.ps1 for the coordination hooks, and + the out-of-repo _lift_from.py for a CLI grant). This file is where the rule stops being re-derived. + + FOUR CONSTRAINTS ON THIS FILE, because usage-collect.ps1 dot-sources it from a statusLine bound by + NEVER THROWS, NEVER BLOCKS -- and dot-sourcing runs in the CALLER's scope, so anything at top level + here happens to them: + 1. NO top-level param() block. It would consume the caller's own arguments. + 2. NO assignment to $ErrorActionPreference or any other preference variable. It would override + the caller's, turning "worst case is a bare line of text" into a throw on the render path. + 3. NO I/O and NO pipeline output at load time. The statusLine fires on every assistant message + behind a 300ms debounce. + 4. NO reading of $env:USERPROFILE inside a function -- take the home directory as an argument. + Measured: with USERPROFILE overridden to C:/fake/home a child pwsh still reported + [Environment]::GetFolderPath('UserProfile') = C:\Users\. A callee that resolves home + itself CANNOT be redirected by a test, and one dropped environment variable stands between a + -AllRoots test and enumerating the owner's live account roots for real. + tests/test_coord_usage.py asserts all four, because a comment cannot enforce them. + +.EXAMPLE + . (Join-Path $PSScriptRoot 'config-roots.ps1') + $root = (Resolve-CurrentConfigRoot -HomeDir $HomeDir).Path + $state = Get-UsageStateDir $root +#> + +# The statusLine we own, named ONCE. install-usage-statusline.ps1 writes it as the command's first +# line, -Status and usage.ps1 recognise it, and -Uninstall removes only what matches. A second literal +# in any of them is a second definition of "ours", and the copy that drifts is the one that decides +# whether somebody else's status bar gets silently replaced. +$script:UsageStatusLineMarker = "mefor-usage" + +# The NAME SHAPE of a config root, and the entire predicate. Carried from install-gate.ps1:95-113 and +# its Python twin at tests/test_gate_installed_parity.py:151 rather than re-derived, with the two +# measured incidents that produced the anchors: +# +# * ~/.claude-account-2.lock IS A DIRECTORY carrying a settings.json of its own, and no .claude.json. +# An unanchored `.claude-account-*` glob adopts it, and install-gate.ps1 wired it on every run for +# weeks (BACKLOG #1024). So does any "looks like a config root because it has settings" test. +# * ~/.claude-desktop-1..4 carry a .claude.json and NOTHING launches from them (measured 2026-08-27 +# against ~/claude-launchers/*.ps1: all ten launchers assign a literal .claude-account-; the +# .claude-desktop- dirs are the Desktop app's --user-data-dir). So "has a .claude.json" is also +# the wrong predicate on this box -- it admits four directories no session can boot from. +# +# `\z`, NOT `\Z`. .NET's \Z also matches BEFORE a trailing newline; .NET's \z is what Python's \Z +# means. Spelling it \Z would look like the Python twin and mean something slightly wider. +# +# CASE-SENSITIVE, matching that twin. -Filter is case-insensitive on Windows, so a `.Claude-Account-2` +# reaches this predicate and is rejected. That is deliberate but NOT FREE: every caller must pair it +# with Get-ClaudeConfigCandidates below, which reports such a directory BY NAME. Without that pairing +# the anchors create a silent under-reach, which is the failure mode this whole file exists to end. +$script:ClaudeAccountRootName = [regex]'\A\.claude-account-\d+\z' +$script:ClaudeDefaultRootName = [regex]'\A\.claude\z' + +function Get-LaunchableConfigRoots { + <# + .SYNOPSIS + Config roots a session can LAUNCH from, under $HomeDir. A READING function: it returns what it + found, including nothing. + .DESCRIPTION + NOT Get-ClaudeConfigRoots, AND THE NAME IS DELIBERATE. session-registry.ps1 already defines a + function by that name in this same directory, dot-sourced by eleven scripts, and it answers a + DIFFERENT question: which roots have RUN a session (it filters on a `sessions/` subdirectory). + This one answers which roots a session could BOOT from, by name shape. Both are correct for + their own caller and neither substitutes for the other -- install-coordination.ps1 records why + the `sessions/` filter is wrong for wiring: a root that exists but has not run a session yet + has no `sessions/` and still needs wiring, because the first session it runs is exactly the one + that would come up unconfigured. + + Sharing the name would make the winner depend on dot-source ORDER. It would also be worse than + a plain error in one direction: that function resolves the home directory from + $env:USERPROFILE itself, so a caller expecting this one's -HomeDir seam would silently + enumerate the real home -- the exact test-safety hole constraint 4 above exists to close. + + NO EMPTY-SET FALLBACK, deliberately. Seeding ~/.claude when the glob finds nothing would make + every caller's "no config root found" guard dead code, and would manufacture a target that by + definition does not exist. A caller that WANTS that seed keeps it at its own call site, where + the choice is visible (install-coordination.ps1 does exactly that, and says why). + + -AccountsOnly drops ~/.claude. The statusLine installer's -AllRoots uses it: writing into + ~/.claude means writing into a directory this repo's coordination tooling treats as shared + state, for a launch mode no launcher on this box uses. + #> + param( + [Parameter(Mandatory)][string]$HomeDir, + [switch]$AccountsOnly + ) + # -Force IS LOAD-BEARING AND ITS ABSENCE IS INVISIBLE ON WINDOWS. Get-ChildItem omits hidden + # entries without it. On Windows a dot-prefixed directory carries no hidden ATTRIBUTE, so every + # ~/.claude-account-N enumerates either way and the omission cannot be reproduced locally. On + # Linux the dot prefix IS the hidden convention, so this glob returns NOTHING. install-gate.ps1 + # shipped that bug and only the CI ubuntu leg caught it. + $found = @( + Get-ChildItem -LiteralPath $HomeDir -Directory -Filter ".claude*" -Force -ErrorAction SilentlyContinue | + Where-Object { + $script:ClaudeAccountRootName.IsMatch($_.Name) -or + (-not $AccountsOnly -and $script:ClaudeDefaultRootName.IsMatch($_.Name)) + } | + ForEach-Object { $_.FullName } + ) + # RETURNED WITHOUT A COMMA, AND CALLERS MUST WRAP IN @(). install-gate.ps1:192-200 makes the + # opposite choice, correctly, for a HashSet -- unrolling destroys that type. For an ARRAY the comma + # is the bug. Measured all four combinations: + # return ,@(...) caller @(f) -> count 1, element is Object[] <- SILENT NESTING + # return ,@(...) caller f|% -> $_ is the whole array <- SILENT NESTING + # return @(...) caller @(f) -> count 0 / 1 / N, elements String <- correct at every arity + # return @(...) caller f -> ONE result arrives as [String] <- the caller's @() fixes it + # The nesting arms are silent and produce a single bogus element whose string form is every path + # joined by a space; that reached this repo as one -AllRoots target named + # " C:\...\\settings.json". Wrap at the call site, always. + return @($found | Sort-Object) +} + +function Get-ClaudeConfigCandidates { + <# + .SYNOPSIS + Every ~/.claude* directory carrying a settings.json, WITHOUT judging what it is. + .DESCRIPTION + Deliberately WIDER than Get-LaunchableConfigRoots and deliberately selected by a DIFFERENT rule. + This is the independent audit population: it exists to catch a directory the name predicate + rejects, so it must not be chosen by the predicate whose correctness it checks. A validator + satisfied by construction reports only its own opinion back to itself. + #> + param([Parameter(Mandatory)][string]$HomeDir) + # No comma, and callers wrap in @() -- same measured reason as Get-LaunchableConfigRoots above. + return @( + Get-ChildItem -LiteralPath $HomeDir -Directory -Filter ".claude*" -Force -ErrorAction SilentlyContinue | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "settings.json") -PathType Leaf } + ) +} + +function ConvertTo-NormalRootPath { + <# + .SYNOPSIS + One spelling of a root path, so two spellings of the same root compare equal. + .DESCRIPTION + RESOLVE-PATH IS NOT A CANONICALISER and it is the obvious wrong choice here. Measured on one + existing directory, spelled three ways: + Resolve-Path 'C:/Temp/Demo' -> C:\Temp\Demo + Resolve-Path 'c:\temp\demo' -> C:\temp\demo (only the SEPARATORS were changed) + Resolve-Path 'C:/Temp/Demo/' -> C:\Temp\Demo\ (trailing separator KEPT) + so two spellings of one directory compare unequal under -ceq. GetFullPath does not fix the + case either (measured: 'c:\temp\demo' stays lowercase), which is exactly why every comparison + built on this uses -ieq and never -ceq. A key that is not stable is not a key. + + It also requires no filesystem access, so it works on a path that does not exist -- which is + the case the installer has to report on rather than create. + #> + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + try { return ([System.IO.Path]::GetFullPath($Path)).TrimEnd('\', '/') } catch { return $Path.TrimEnd('\', '/') } +} + +function Test-SameRoot { + <# + .SYNOPSIS + Do two paths name the same config root? Case-INSENSITIVE, for the reason above. + #> + param([string]$A, [string]$B) + $na = ConvertTo-NormalRootPath $A + $nb = ConvertTo-NormalRootPath $B + if ($null -eq $na -or $null -eq $nb) { return $false } + return $na -ieq $nb +} + +function Get-UsageStateDir { + <# + .SYNOPSIS + Where the usage collector publishes for a given config root. + .DESCRIPTION + THE FILESYSTEM IS THE PARTITION KEY, so two roots cannot collide by construction. The + alternative -- one shared tree keyed by a derived name -- needs a key derived from a path, and + a leaf-name key collides the moment CLAUDE_CONFIG_DIR points at a `.claude` outside the home + directory. That would reintroduce the exact defect this partitioning removes. + + WHY IT IS PARTITIONED AT ALL: see usage-collect.ps1's header. Briefly, a config root holds one + credential set and therefore one Anthropic account, and separate accounts have separate 5-hour + and 7-day pools. One shared file across roots is last-writer-wins across unrelated quotas. + #> + param([Parameter(Mandatory)][string]$ConfigRoot) + return (Join-Path (ConvertTo-NormalRootPath $ConfigRoot) 'mefor-usage') +} + +function Resolve-CurrentConfigRoot { + <# + .SYNOPSIS + The config root THIS process is running against, and where that answer came from. + .DESCRIPTION + Returns @{ Path = ; Source = 'CLAUDE_CONFIG_DIR' | 'default (CLAUDE_CONFIG_DIR unset)' }. + + THE SOURCE IS RETURNED, NOT RE-DERIVED BY THE CALLER. Every caller prints it, and a reader who + cannot see WHY a path was chosen cannot tell a correct answer from a coincidence -- which is + the whole complaint that produced this change. + + An EMPTY-STRING pin counts as unset: [bool]$env:CLAUDE_CONFIG_DIR is False for "" (measured), + and falling through to the default is the right reading of a blank variable. + #> + param([Parameter(Mandatory)][string]$HomeDir) + if ($env:CLAUDE_CONFIG_DIR) { + return @{ Path = (ConvertTo-NormalRootPath $env:CLAUDE_CONFIG_DIR); Source = 'CLAUDE_CONFIG_DIR' } + } + return @{ Path = (ConvertTo-NormalRootPath (Join-Path $HomeDir '.claude')); Source = 'default (CLAUDE_CONFIG_DIR unset)' } +} + +function Test-IsOurStatusLine { + <# + .SYNOPSIS + Is this statusLine command ours? ANCHORED on the first line, never a substring. + .DESCRIPTION + THE SUBSTRING TEST STOPPED BEING SAFE WHEN THE PUBLISH PATH WENT INTO THE COMMAND. The wired + command now contains `\mefor-usage` inside its -StateDir argument, so the old + `command -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. The refusal + guard exists precisely to stop that. + + THREE-WAY, NOT TWO-WAY. A statusLine object present with a null, empty or whitespace command is + NONE, not FOREIGN: classifying it FOREIGN would make the installer refuse that root forever + while printing "already configured: " with nothing after the colon, and offer a remedy ("merge + the two commands by hand") naming a command that does not exist. Callers ask + Test-IsOurStatusLine only after establishing the command is non-empty. + #> + param([string]$Command) + if ([string]::IsNullOrWhiteSpace($Command)) { return $false } + return ((($Command -split "`r?`n", 2)[0]).Trim()) -ceq "# $script:UsageStatusLineMarker" +} + +function Get-WiredStateDir { + <# + .SYNOPSIS + The publish path a root's wired command NAMES. $null means a LEGACY command, which is an answer. + .DESCRIPTION + READ BACK, NEVER RECOMPUTED, and that is the point. The installer's -Status used to + report "script exists: " against a path THAT INVOCATION had just resolved from git -- + so a root wired months ago from a checkout since deleted still reported True. Across five + roots wired at different times from different checkouts, one recomputed line cannot describe + any of them. + + A NO-MATCH IS NOT AN ERROR. It means the command carries no -StateDir: a command written before + publish paths were per-root, or one the out-of-repo propagate stopgap copied. That is a real, + reportable state (the collector then chooses at run time), and folding it into "wired" is how + it would go silent. + #> + param([string]$Command) + if ([string]::IsNullOrWhiteSpace($Command)) { return $null } + $m = [regex]::Match($Command, "\`$d = '((?:[^']|'')*)'") + if (-not $m.Success) { return $null } + return ($m.Groups[1].Value -replace "''", "'") +} + +function Get-WiredCollectorPath { + <# + .SYNOPSIS + The collector script a root's wired command NAMES. $null when the shape is unrecognised. + #> + param([string]$Command) + if ([string]::IsNullOrWhiteSpace($Command)) { return $null } + $m = [regex]::Match($Command, "\`$s = '((?:[^']|'')*)'") + if (-not $m.Success) { return $null } + return ($m.Groups[1].Value -replace "''", "'") +} diff --git a/scripts/coord/install-usage-statusline.ps1 b/scripts/coord/install-usage-statusline.ps1 index cb77a0e9..7e804614 100644 --- a/scripts/coord/install-usage-statusline.ps1 +++ b/scripts/coord/install-usage-statusline.ps1 @@ -2,33 +2,72 @@ # Copyright (C) 2026 MessageFoundry Organization and contributors <# .SYNOPSIS - Wire usage-collect.ps1 as the Claude Code statusLine, so the account's plan limits get published. + Wire usage-collect.ps1 as the Claude Code statusLine, so an account's plan limits get published. .DESCRIPTION - Run this ONCE, from a plain terminal. It writes `statusLine` into the USER-level - ~/.claude/settings.json, so every session on this machine publishes -- and reads -- the same - account-wide quota state. See usage-collect.ps1 for why the statusLine is the only source. + Run this ONCE per config root, from a plain terminal. It writes `statusLine` into that root's + settings.json. See usage-collect.ps1 for why a statusLine is the only source. + + WHICH ROOT, AND WHY THAT IS THE WHOLE POINT. Claude Code reads settings from the root named by + CLAUDE_CONFIG_DIR, falling back to ~/.claude. An earlier version of this script always wrote + ~/.claude/settings.json and reported "INSTALLED (user level -- every session on this machine)". On + a box whose launchers pin CLAUDE_CONFIG_DIR to ~/.claude-account-, that 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, with the wrong one louder and earlier. + + FIVE RULES DECIDE THE TARGET SET, FIRST MATCH WINS: + 1. -SettingsPath ... exactly those files; each file's PARENT is its config root. + 2. -ConfigDir ... \settings.json for each. + 3. -AllRoots every ~/.claude-account- under -HomeDir, plus this session's + pinned root if CLAUDE_CONFIG_DIR names one outside that set. + 4. CLAUDE_CONFIG_DIR set that one root. + 5. otherwise \.claude. + -AllRoots is OPT-IN and covers ACCOUNT roots only. It deliberately diverges from + scripts/worktree/install-gate.ps1, which defaults to the whole set: a security gate fails by + under-reach so it wires everything it can find, whereas this writes into several vendor-owned + directories belonging to DIFFERENT Anthropic accounts, and the pin is the single-session correct + target. -AllRoots also excludes ~/.claude, which this repo's coordination tooling treats as shared + state; wire it deliberately with -ConfigDir "$HOME\.claude" if a bare `claude` run needs numbers. + + EACH ROOT IS WIRED TO PUBLISH UNDER ITSELF (\mefor-usage), because a config root holds one + credential set and therefore one Anthropic account, and separate accounts have separate 5-hour and + 7-day pools. One shared file across roots is last-writer-wins across unrelated quotas. The rule + lives once, in config-roots.ps1, and the reader derives its path from the same function -- so the + two halves cannot drift into the disagreement described above. IT TAKES EFFECT IN NEWLY STARTED SESSIONS. Existing sessions keep the config they booted with, the same as the coordination hooks. And it only ever runs in an INTERACTIVE session: the statusLine is part of the TUI's render tree and never executes under `claude -p` or the SDK, so a headless coordinator can read what this publishes but can never publish it itself. - WHY IT POINTS AT AN ABSOLUTE PATH rather than resolving the repo per invocation: the statusLine runs - on every assistant message behind a 300ms debounce, and a `git rev-parse` per fire is latency on the - render path for a value that never changes. The trade is that moving or deleting the checkout breaks - it -- so the wired command TESTS FOR THE SCRIPT and degrades to a quiet marker instead of erroring - into the status bar on every message. + WHY THE WIRED COMMAND POINTS AT AN ABSOLUTE PATH rather than resolving the repo per invocation: the + statusLine runs on every assistant message behind a 300ms debounce, and a `git rev-parse` per fire + is latency on the render path for a value that never changes. The trade is that moving or deleting + the checkout breaks it -- so the wired command TESTS FOR THE SCRIPT and degrades to a quiet marker + instead of erroring into the status bar on every message. refreshInterval is set because statusLine updates are EVENT-DRIVEN -- a new assistant message, /compact, a permission-mode change -- and go silent when a session is idle. Anthropic's own docs name "a coordinator waits on background subagents" as the case where that leaves you blind, which is exactly this repo's situation. + EXIT CODES, so a coordinator can branch without parsing prose: + 0 every targeted root ended in the desired state + 3 PARTIAL -- at least one root reached it and at least one did not + 1 NOTHING WRITTEN -- roots were targeted and none reached the desired state + 2 COULD NOT START -- unusable arguments, a named root that does not exist, no roots resolved, + or the collector missing. Nothing was examined. + .EXAMPLE pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 + pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -ConfigDir "$HOME\.claude-account-5" + pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -AllRoots pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -Status pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -Uninstall + # ONE -ConfigDir PER VALUE. `pwsh -File` hands each argument over as a single string, so + # `-ConfigDir A,B` binds as the one value "A,B" (measured). Nothing splits it, deliberately: a + # Windows path may legally contain a comma. #> [CmdletBinding(SupportsShouldProcess)] param( @@ -36,103 +75,468 @@ param( [switch]$Status, # Milliseconds. Minimum honoured by Claude Code is 1000. [int]$RefreshInterval = 10000, - [string]$SettingsPath = (Join-Path $env:USERPROFILE ".claude\settings.json"), + # LOWEST-LEVEL OVERRIDE, AND IT HAS NO DEFAULT ON PURPOSE. A default here is what made the + # CLAUDE_CONFIG_DIR pin unreachable: a pin can only be consulted when nothing has already answered + # the question, and a computed default answers it before the pin is ever read. Deleting the default + # IS the fix; reading the pin is only what that makes possible. + [string[]]$SettingsPath, + # Config dir(s) to wire. Same [string[]] shape as scripts/worktree/install-gate.ps1. + [string[]]$ConfigDir, + # Wire every account config root under -HomeDir. Opt-in; see the .DESCRIPTION for why. + [switch]$AllRoots, # Which collector to wire. Defaults to the PRIMARY checkout's copy, deliberately: a worktree is - # disposable and a user-level statusLine pointing into one dies with it. Overridable so tests can - # drive the real installer against a fixture instead of asserting a copy of its rules. - [string]$CollectorPath + # disposable and a statusLine pointing into one dies with it. One absolute path is wired into every + # root. Overridable so tests can drive the real installer against a fixture. + [string]$CollectorPath, + # THE HOME DIRECTORY IS A PARAMETER, NOT AN ENVIRONMENT READ, AND THAT IS A TEST-SAFETY RULE. + # Measured: with USERPROFILE overridden to C:/fake/home a child pwsh still reported + # [Environment]::GetFolderPath('UserProfile') = C:\Users\. A callee that resolves home itself + # cannot be redirected by a test, so one dropped environment variable would stand between the + # -AllRoots test and enumerating -- and wiring -- the real account roots on this box. + [string]$HomeDir = $(if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') }) ) +# CAPTURED AT SCRIPT SCOPE, IMMEDIATELY, AND NEVER RE-TESTED INSIDE A FUNCTION. Measured on pwsh 7.6.5 +# with `-File probe.ps1 -SettingsPath X`: at script scope ContainsKey('SettingsPath') is True; inside a +# function its own $PSBoundParameters is EMPTY, so the same test returns False for every caller. A +# resolver that tested it there would make rules 1 and 2 unreachable and silently fall through to the +# pin -- meaning `-SettingsPath ` from the test suite would be ignored and this script would +# write into the caller's live pinned root instead. +$script:GaveSettingsPath = $PSBoundParameters.ContainsKey('SettingsPath') +$script:GaveConfigDir = $PSBoundParameters.ContainsKey('ConfigDir') + $ErrorActionPreference = "Stop" -$MARKER = "mefor-usage" - -# The primary checkout, not this worktree: a worktree is disposable and the statusLine outlives it. -$common = (& git rev-parse --path-format=absolute --git-common-dir 2>$null) -if ($LASTEXITCODE -ne 0 -or -not $common) { throw "Not inside a git repository -- run this from the MessageFoundry checkout." } -$primary = Split-Path ($common.Trim()) -Parent -$script = if ($CollectorPath) { $CollectorPath } else { Join-Path $primary "scripts/coord/usage-collect.ps1" } - -function Get-Settings { - if (-not (Test-Path -LiteralPath $SettingsPath)) { return [ordered]@{} } - $raw = Get-Content -LiteralPath $SettingsPath -Raw - if (-not $raw.Trim()) { return [ordered]@{} } + +. (Join-Path $PSScriptRoot 'config-roots.ps1') +$MARKER = $script:UsageStatusLineMarker + +# One stamp for the whole run, so every root's backup reads as one run's set. The old fixed +# ".bak-usage" name is retired: run this twice over five roots and every pre-install backup on the box +# holds post-install content, recovering nothing anywhere. +$BackupStamp = [DateTime]::Now.ToString('yyyyMMdd-HHmmss') + +function Stop-Cannot([string]$Reason) { + Write-Host "" + Write-Host "CANNOT START: $Reason" -ForegroundColor Red + Write-Host "" + exit 2 +} + +function Assert-RootExists([string]$Path, [string]$What) { + if (Test-Path -LiteralPath $Path -PathType Container) { return } + $msg = "$What does not exist: $Path" + if ($Path -like '*,*') { + $msg += "`n (that string contains a comma -- `pwsh -File` passes it as ONE value;" + + " repeat -ConfigDir per path, or use -AllRoots)" + } + Stop-Cannot $msg +} + +# Read PER PATH, never once for the whole run. The previous version closed over a single script-scope +# $SettingsPath, which becomes actively dangerous the moment that parameter is [string[]]. Measured +# with @(existing.json, missing.json): `Test-Path -LiteralPath` returns "True False", `-not` on that is +# False so the guard PASSES, and Get-Content then throws on the missing one. With two files that both +# exist it is worse -- Get-Content -Raw returns them CONCATENATED and ConvertFrom-Json ACCEPTS the +# concatenation as an array of two objects, so one root would be judged by a silent merge of two files. +function Read-Settings([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return [ordered]@{} } + $raw = Get-Content -LiteralPath $Path -Raw + if ([string]::IsNullOrWhiteSpace($raw)) { return [ordered]@{} } + # Fail loudly rather than overwrite: a settings file we cannot parse is one we must not rewrite, + # because a bad write silently disables EVERY setting in it, not just this one. return ($raw | ConvertFrom-Json -AsHashtable) } +function Write-SettingsFile([string]$Path, $Data) { + # TWO SERIALISATION GUARDS, AND THE SECOND IS NOT IMPLIED BY THE FIRST. Measured with a 24-level + # document: ConvertTo-Json -Depth 20 emits "Resulting JSON is truncated as serialization has + # exceeded the set depth of 20" AND THE TRUNCATED TEXT STILL PARSES BACK CLEANLY, with the deep node + # replaced by its type name as a string. So a parse-back check alone would let one -AllRoots run + # quietly truncate up to five live account roots and count every one as written. + $json = $Data | ConvertTo-Json -Depth 20 -WarningVariable depthWarning -WarningAction SilentlyContinue + if ($depthWarning) { throw "serialising this file would have TRUNCATED it (nesting deeper than 20); left untouched" } + try { $null = $json | ConvertFrom-Json } catch { throw "the generated settings JSON is invalid: $_" } + # NO -ErrorAction SilentlyContinue on the backup. In a loop, a silent backup failure followed by a + # successful destructive write is a per-root data loss the tally would count as a success. + # RETURNS WHETHER IT BACKED ONE UP, because the caller prints that line. A root with no + # settings.json has nothing to copy, and an -AllRoots run that printed a backup path for all five + # would send an operator hunting for backups that were never taken -- a small lie of exactly the + # kind this change exists to remove. + # A FREE NAME, NOT A FIXED ONE, AND IT RETURNS WHERE THE COPY LANDED. $BackupStamp has + # one-second resolution, so two runs inside the same second collide -- measured on a fixture: run + # 1 wrote the original 55 bytes, run 2 started immediately after and overwrote that same file with + # 771 bytes of run 1's POST-install content. The only pre-install copy was destroyed by the run + # that claimed to be making one, which is worse than not backing up at all: the operator is told a + # backup exists and it holds the wrong content. + $backedTo = $null + if (Test-Path -LiteralPath $Path) { + $dest = "$Path.bak-usage-$BackupStamp" + $n = 1 + while (Test-Path -LiteralPath $dest) { $dest = "$Path.bak-usage-$BackupStamp-$n"; $n++ } + Copy-Item -LiteralPath $Path -Destination $dest -Force + $backedTo = $dest + } + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 + return $backedTo +} + +# THREE-WAY, NOT TWO-WAY. A statusLine object present with a null or empty command is NONE, not +# FOREIGN: calling it FOREIGN would make this script refuse that root forever while printing a refusal +# line with nothing after the colon, and offer a remedy naming a command that does not exist. +function Get-Ownership($Settings) { + $sl = $Settings['statusLine'] + if (-not $sl) { return 'NONE' } + $cmd = [string]$sl['command'] + if ([string]::IsNullOrWhiteSpace($cmd)) { return 'NONE' } + if (Test-IsOurStatusLine $cmd) { return 'OURS' } + return 'FOREIGN' +} + +function New-WiredCommand([string]$Collector, [string]$StateDir) { + # The guard is inline so a missing script degrades to a marker rather than erroring into the status + # bar on every single message -- a statusLine that shouts an exception is worse than one that says + # nothing. $d is a VARIABLE at the call site, not an interpolation, which is what makes a state dir + # containing spaces safe. + return "# $MARKER`n" + + "`$s = '$($Collector -replace "'", "''")'; " + + "`$d = '$($StateDir -replace "'", "''")'; " + + "if (Test-Path -LiteralPath `$s) { & pwsh -NoProfile -File `$s -StateDir `$d } " + + "else { Write-Output '${MARKER}: collector missing' }" +} + +# --- resolve the target set, ONCE ------------------------------------------------------------------ + +if ($AllRoots -and ($script:GaveConfigDir -or $script:GaveSettingsPath)) { + Stop-Cannot "-AllRoots cannot be combined with -ConfigDir / -SettingsPath -- pick one" +} +# BOUND-AND-EMPTY IS A USAGE ERROR, NOT "NOT GIVEN". Measured: `-ConfigDir @()` gives ContainsKey True +# with Count 0 and [bool] False, so a truthiness test would silently promote the caller to the next +# rule and wire a root they never named. +if ($script:GaveSettingsPath -and @($SettingsPath).Count -eq 0) { Stop-Cannot "-SettingsPath was given with no value" } +if ($script:GaveConfigDir -and @($ConfigDir).Count -eq 0) { Stop-Cannot "-ConfigDir was given with no value" } + +$targets = @() +$targetFrom = "" +if ($script:GaveSettingsPath) { + $targetFrom = "-SettingsPath" + $targets = @($SettingsPath | ForEach-Object { + [pscustomobject]@{ Settings = $_; Root = (ConvertTo-NormalRootPath (Split-Path $_ -Parent)) } + }) +} +elseif ($script:GaveConfigDir) { + $targetFrom = "-ConfigDir" + foreach ($d in $ConfigDir) { Assert-RootExists $d "config dir" } + $targets = @($ConfigDir | ForEach-Object { + $r = ConvertTo-NormalRootPath $_ + [pscustomobject]@{ Settings = (Join-Path $r "settings.json"); Root = $r } + }) +} +elseif ($AllRoots) { + $targetFrom = "-AllRoots" + $roots = @(Get-LaunchableConfigRoots -HomeDir $HomeDir -AccountsOnly) + $addedOutside = $null + if ($env:CLAUDE_CONFIG_DIR) { + $pin = ConvertTo-NormalRootPath $env:CLAUDE_CONFIG_DIR + Assert-RootExists $pin "CLAUDE_CONFIG_DIR names a directory that" + if (-not ($roots | Where-Object { Test-SameRoot $_ $pin })) { $roots += $pin; $addedOutside = $pin } + } + if (@($roots).Count -eq 0) { Stop-Cannot "no account config root found under $HomeDir" } + $targets = @($roots | ForEach-Object { + $r = ConvertTo-NormalRootPath $_ + [pscustomobject]@{ Settings = (Join-Path $r "settings.json"); Root = $r } + }) +} +else { + $cur = Resolve-CurrentConfigRoot -HomeDir $HomeDir + $targetFrom = $cur.Source + Assert-RootExists $cur.Path $(if ($cur.Source -eq 'CLAUDE_CONFIG_DIR') { "CLAUDE_CONFIG_DIR names a directory that" } else { "config dir" }) + $targets = @([pscustomobject]@{ Settings = (Join-Path $cur.Path "settings.json"); Root = $cur.Path }) +} + +# --- Status ---------------------------------------------------------------------------------------- +# +# AUDITING IS NOT INSTALLING, so -Status runs before the git-repository guard and before the +# collector-exists guard. Refusing an audit precisely when its answer -- "this root points at a +# collector that is gone" -- is the thing you needed is the wrong trade. + if ($Status) { - $s = Get-Settings - $sl = $s['statusLine'] Write-Host "" - if (-not $sl) { Write-Host "statusLine: NOT CONFIGURED" -ForegroundColor Yellow } - else { - $isOurs = ([string]$sl['command']) -like "*$MARKER*" - Write-Host ("statusLine: CONFIGURED" + $(if ($isOurs) { " (ours)" } else { " (SOMEONE ELSE'S -- install would replace it)" })) -ForegroundColor $(if ($isOurs) { "Green" } else { "Yellow" }) - Write-Host " command : $($sl['command'])" - Write-Host " refreshInterval: $($sl['refreshInterval'])" + Write-Host "target from: $targetFrom" + $ok = 0; $bad = 0 + foreach ($t in $targets) { + Write-Host "" + Write-Host " $($t.Settings)" + $own = 'UNREADABLE'; $settings = $null + try { $settings = Read-Settings $t.Settings; $own = Get-Ownership $settings } catch { } + Write-Host " statusLine : $own" + $wantState = Get-UsageStateDir $t.Root + if ($own -eq 'OURS') { + $cmd = [string]$settings['statusLine']['command'] + # READ BACK OUT OF THE WIRED COMMAND, NEVER RECOMPUTED. The previous version reported + # "script exists" against a path THAT INVOCATION had just resolved from git, so a root + # wired from a checkout since deleted still reported True. Across five roots wired at + # different times from different checkouts, one recomputed line describes none of them. + $wired = Get-WiredStateDir $cmd + $coll = Get-WiredCollectorPath $cmd + if ($null -eq $wired) { + Write-Host " publishes to : UNKNOWN (legacy command, no -StateDir -- the collector chooses at run time)" + Write-Host " re-install this root to bake the path in: -ConfigDir `"$($t.Root)`"" + $bad++ + } + elseif (-not (Test-SameRoot (Split-Path $wired -Parent) $t.Root)) { + Write-Host " publishes to : $wired -- ELSEWHERE; this root reads $wantState" -ForegroundColor Yellow + $bad++ + } + else { + Write-Host " publishes to : $wired" + $ok++ + } + if ($coll) { Write-Host " collector : $coll exists: $(Test-Path -LiteralPath $coll)" } + else { Write-Host " collector : UNKNOWN (command shape not recognised)" } + } + elseif ($own -eq 'UNREADABLE') { + # A CORRUPT settings.json IS NOT A CLEAN ONE. It may carry a working statusLine that this + # audit cannot see, so reporting "carries none" would send an operator away from a stray + # publisher rather than towards it. + Write-Host " publishes to : UNKNOWN -- settings.json could not be parsed, so its wiring is unknown" -ForegroundColor Yellow + $bad++ + } + else { + Write-Host " publishes to : nothing -- this root carries no statusLine of ours" + $bad++ + } + # WITHOUT THESE TWO LINES, a name-shaped root nobody has ever logged into renders byte-for-byte + # like a root wired ten minutes ago that simply has not run a session yet. Two states, opposite + # fixes, one rendering. They do NOT gate the write -- a fresh root still needs wiring, because + # the first session it runs is exactly the one that would come up unpublishing. + $hasCfg = Test-Path -LiteralPath (Join-Path $t.Root ".claude.json") + $hasCred = Test-Path -LiteralPath (Join-Path $t.Root ".credentials.json") + $markers = " login markers : .claude.json $(if ($hasCfg) { 'yes' } else { 'no ' }) .credentials.json $(if ($hasCred) { 'yes' } else { 'no ' })" + if (-not $hasCfg -and -not $hasCred) { $markers += " -- no session has ever launched from this root" } + Write-Host $markers + # A RECEIPT, NOT A CONFIG READ, and now per root. Whether a settings file names the script says + # nothing about whether it has ever run -- that distinction is the one this repo keeps paying for. + $latest = Join-Path $wantState "latest.json" + Write-Host " has published : $(Test-Path -LiteralPath $latest) ($latest)" + } + + # THE INDEPENDENT AUDIT. Enumerated by a DIFFERENT rule from the one that chose the targets, so it + # can contradict them. Without it -Status could only ever confirm this script's own predicate -- + # a validator satisfied by construction. It is also what makes the name predicate's deliberate + # case-sensitivity loud instead of a silent under-reach. + $seen = @(Get-ClaudeConfigCandidates -HomeDir $HomeDir) + Write-Host "" + Write-Host " audit: $($seen.Count) ~/.claude* dir(s) under $HomeDir carry a settings.json, enumerated" + Write-Host " independently of the target set above" + if ($seen.Count -eq 0) { + Write-Host " NOTHING EXAMINED -- so this audit concluded nothing. That is not the same as 'no orphans'." -ForegroundColor Yellow } - Write-Host " script exists : $(Test-Path -LiteralPath $script) ($script)" - $latest = Join-Path $env:USERPROFILE ".claude\mefor-usage\latest.json" - # A RECEIPT, NOT A CONFIG READ. Whether the settings file names the script says nothing about - # whether it has ever run -- that distinction is the one this repo keeps paying for. - Write-Host " has published : $(Test-Path -LiteralPath $latest) ($latest)" -ForegroundColor $(if (Test-Path -LiteralPath $latest) { "Green" } else { "Yellow" }) + foreach ($d in $seen) { + if ($targets | Where-Object { Test-SameRoot $_.Root $d.FullName }) { continue } + $orphan = $false; $unreadable = $false + try { $orphan = (Get-Ownership (Read-Settings (Join-Path $d.FullName "settings.json"))) -eq 'OURS' } catch { $unreadable = $true } + if ($unreadable) { Write-Host " UNREADABLE: $($d.Name) -- settings.json could not be parsed, ownership unknown" -ForegroundColor Yellow } + elseif ($orphan) { Write-Host " ORPHAN: $($d.Name) carries a mefor-usage statusLine and is not in the target set" -ForegroundColor Yellow } + else { Write-Host " not judged: $($d.Name) -- carries no statusLine of ours" } + } + Write-Host "" + Write-Host " This reads the FILE. A file that carries the statusLine is not the same as a statusLine that FIRED." Write-Host "" - exit 0 + if ($bad -eq 0) { exit 0 } + if ($ok -eq 0) { exit 1 } + exit 3 } -$settings = Get-Settings +# --- Uninstall ------------------------------------------------------------------------------------- +# +# THE MIRROR-IMAGE LIE IS WORSE THAN THE INSTALL LIE, because the operator believes they turned +# something off. A single-root -Uninstall under a pin strips one root, prints REMOVED, exits 0 -- and +# leaves every other root still wired and still publishing. if ($Uninstall) { - if ($settings['statusLine'] -and ([string]$settings['statusLine']['command']) -like "*$MARKER*") { - $settings.Remove('statusLine') - if ($PSCmdlet.ShouldProcess($SettingsPath, "remove the mefor-usage statusLine")) { - Copy-Item -LiteralPath $SettingsPath -Destination "$SettingsPath.bak-usage" -Force -ErrorAction SilentlyContinue - ($settings | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $SettingsPath -Encoding UTF8 - Write-Host "statusLine REMOVED from $SettingsPath" -ForegroundColor Yellow + $removed = 0; $absent = 0; $foreign = 0; $failed = 0; $wouldRemove = 0 + Write-Host "" + foreach ($t in $targets) { + try { + $settings = Read-Settings $t.Settings + switch (Get-Ownership $settings) { + 'OURS' { + # THE COUNTER GOES INSIDE THE GUARD. An earlier version incremented $removed + # outside it, so `-Uninstall -AllRoots -WhatIf` printed "removed: 5" and exited 0 + # having removed nothing -- an operator dry-running before committing would read + # that as "the collector is off" while all five roots kept publishing. It is the + # same shape as the install claim this whole change deletes, and worse, because a + # person believes they turned something OFF. + if ($PSCmdlet.ShouldProcess($t.Settings, "remove the $MARKER statusLine")) { + $settings.Remove('statusLine') + $backedTo = Write-SettingsFile $t.Settings $settings + Write-Host (" REMOVED {0}{1}" -f $t.Settings, $(if ($backedTo) { " backup $backedTo" } else { "" })) + $removed++ + } + else { + Write-Host " WOULD REMOVE $($t.Settings)" + $wouldRemove++ + } + } + 'FOREIGN' { Write-Host " FOREIGN $($t.Settings) -- someone else's statusLine, left untouched"; $foreign++ } + default { Write-Host " NOT PRESENT $($t.Settings) -- no $MARKER statusLine here"; $absent++ } + } + } + catch { + Write-Host " FAILED $($t.Settings) -- $($_.Exception.Message)" -ForegroundColor Red + $failed++ } } - else { Write-Host "Nothing to remove: the statusLine is absent or is not ours." -ForegroundColor Yellow } - exit 0 + Write-Host "" + Write-Host ("Roots examined: {0} ({1})" -f $targets.Count, $targetFrom) + Write-Host (" removed: {0} not present: {1} foreign: {2} failed: {3} would remove: {4}" -f ` + $removed, $absent, $foreign, $failed, $wouldRemove) + Write-Host "" + if ($WhatIfPreference) { exit 0 } + if ($failed -eq 0) { exit 0 } + if ($removed + $absent + $foreign -eq 0) { exit 1 } + exit 3 } -if (-not (Test-Path -LiteralPath $script)) { - throw "Collector not found at $script. The primary checkout ($primary) does not carry it yet -- merge the branch that adds it, or advance the primary, before installing." -} +# --- Install --------------------------------------------------------------------------------------- -if ($settings['statusLine'] -and ([string]$settings['statusLine']['command']) -notlike "*$MARKER*") { - Write-Host "" - Write-Host "REFUSING: a statusLine is already configured and it is not ours." -ForegroundColor Red - Write-Host " command: $($settings['statusLine']['command'])" - Write-Host "" - Write-Host "Silently replacing someone's status bar is not this script's call. Remove it yourself, or" - Write-Host "merge the two commands by hand, then re-run." - exit 1 +# Gated, so -Status and -Uninstall work outside a checkout. The primary checkout, not this worktree: a +# worktree is disposable and the statusLine outlives it. +if (-not $CollectorPath) { + $common = (& git rev-parse --path-format=absolute --git-common-dir 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $common) { Stop-Cannot "not inside a git repository -- run this from the MessageFoundry checkout, or pass -CollectorPath" } + $CollectorPath = Join-Path (Split-Path ($common.Trim()) -Parent) "scripts/coord/usage-collect.ps1" +} +if (-not (Test-Path -LiteralPath $CollectorPath)) { + Stop-Cannot "collector not found at $CollectorPath. The primary checkout does not carry it yet -- merge the branch that adds it, or advance the primary, before installing." } -# The guard is inline so a missing script degrades to a marker rather than erroring into the status bar -# on every single message -- a statusLine that shouts an exception is worse than one that says nothing. -$cmd = "# $MARKER`n" + -"`$s = '$($script -replace "'", "''")'; if (Test-Path -LiteralPath `$s) { & pwsh -NoProfile -File `$s } else { Write-Output '${MARKER}: collector missing' }" +$wrote = 0; $rewired = 0; $unchanged = 0; $refusing = 0; $failed = 0; $would = 0 +Write-Host "" +foreach ($t in $targets) { + $stateDir = Get-UsageStateDir $t.Root + $cmd = New-WiredCommand $CollectorPath $stateDir + try { + $settings = Read-Settings $t.Settings + $own = Get-Ownership $settings + if ($own -eq 'FOREIGN') { + $first = (([string]$settings['statusLine']['command'] -split "`r?`n", 2)[0]).Trim() + Write-Host " REFUSING $($t.Settings) -- a statusLine that is not ours is already configured" -ForegroundColor Red + Write-Host " its first line: $first" + Write-Host " Silently replacing someone's status bar is not this script's call. Remove it" + Write-Host " yourself, or merge the two commands by hand, then re-run." + $refusing++ + continue + } + + $wasState = $null + $wasColl = $null + $isOurs = ($own -eq 'OURS') + if ($isOurs) { + $existing = [string]$settings['statusLine']['command'] + $wasState = Get-WiredStateDir $existing + $wasColl = Get-WiredCollectorPath $existing + if ($existing -ceq $cmd -and [int]$settings['statusLine']['refreshInterval'] -eq $RefreshInterval) { + Write-Host " UNCHANGED $($t.Settings)" + Write-Host " already carries exactly this command and refreshInterval; not rewritten, no backup taken" + $unchanged++ + continue + } + } + + if (-not $PSCmdlet.ShouldProcess($t.Settings, "install the $MARKER statusLine")) { + Write-Host " WOULD WRITE $($t.Settings)" + Write-Host " would wire it to publish to $stateDir" + $would++ + continue + } + + $settings['statusLine'] = [ordered]@{ + type = "command" + command = $cmd + refreshInterval = $RefreshInterval + } + $backedTo = Write-SettingsFile $t.Settings $settings -$settings['statusLine'] = [ordered]@{ - type = "command" - command = $cmd - refreshInterval = $RefreshInterval + if ($isOurs) { + # Printed separately from WROTE rather than folded into it: "we wired a root that had + # nothing" and "we corrected a root that was publishing to the wrong account" are different + # facts to an operator deciding whether a stray file needs cleaning up. + # + # AND THE REASON IS DERIVED, NOT ASSUMED. This branch is entered whenever the command is + # not byte-identical, which is a WEAKER fact than any of the sentences below. An earlier + # version printed "published somewhere else" unconditionally, and an operator's real run + # produced a block reading "published somewhere else / was: / now: " + # -- the publish path had not moved at all, the COLLECTOR had. A line that contradicts the + # two lines under it is the same defect this whole change exists to remove. + $why = if (-not $wasState) { + "carried no -StateDir, so the collector chose its own default at run time" + } + elseif (-not (Test-SameRoot $wasState $stateDir)) { + "published somewhere else" + } + elseif ($wasColl -and $wasColl -ne $CollectorPath) { + "named a different collector" + } + else { + "differed from this command in some other way (refreshInterval, or an unrecognised shape)" + } + Write-Host " REWIRED $($t.Settings)" + Write-Host " replaced a $MARKER statusLine that $why" + Write-Host " was: $(if ($wasState) { $wasState } else { '(no -StateDir -- the collector chose its own default)' })" + Write-Host " now: $stateDir" + if ($wasColl -and $wasColl -ne $CollectorPath) { + Write-Host " collector was: $wasColl" + Write-Host " collector now: $CollectorPath" + } + $rewired++ + } + else { + $latest = Join-Path $stateDir "latest.json" + # PRESENT TENSE IS NOT USED. "wired to publish to" is backed by "a settings key was + # written"; "publishes to" would assert something no check here supports. + Write-Host " WROTE $($t.Settings)" + Write-Host " wired to publish to $latest $(if (Test-Path -LiteralPath $latest) { '(a reading is already there)' } else { '(nothing has published there yet)' })" + $wrote++ + } + if ($backedTo) { Write-Host " backup $backedTo" } + else { Write-Host " no backup taken -- this root had no settings.json to preserve" } + } + catch { + Write-Host " FAILED $($t.Settings) -- $($_.Exception.Message)" -ForegroundColor Red + $failed++ + } } -if ($PSCmdlet.ShouldProcess($SettingsPath, "install the mefor-usage statusLine")) { - if (Test-Path -LiteralPath $SettingsPath) { Copy-Item -LiteralPath $SettingsPath -Destination "$SettingsPath.bak-usage" -Force } - $json = $settings | ConvertTo-Json -Depth 20 - # Never leave the file unparseable: a broken settings.json degrades every session on this machine. - try { $null = $json | ConvertFrom-Json } catch { throw "Refusing to write: generated settings JSON is invalid. $_" } - $json | Set-Content -LiteralPath $SettingsPath -Encoding UTF8 - Write-Host "" - Write-Host "statusLine INSTALLED (user level -- every session on this machine)" -ForegroundColor Green - Write-Host " collector : $script" - Write-Host " refreshInterval: $RefreshInterval ms" - Write-Host " publishes to : $(Join-Path $env:USERPROFILE '.claude\mefor-usage\latest.json')" - Write-Host " backup : $SettingsPath.bak-usage" - Write-Host "" - Write-Host " Takes effect in NEWLY STARTED sessions. Interactive only -- never under 'claude -p'." - Write-Host " Then read it with: pwsh -NoProfile -File scripts\coord\usage.ps1" - Write-Host "" +Write-Host "" +# THE POPULATION IS NAMED, not left as a bare count. "Roots examined: 5" alone reads as "all of them", +# which is the completeness claim that produced this whole change. +Write-Host ("Roots examined: {0} ({1})" -f $targets.Count, $targetFrom) +# NO "skipped" COLUMN. An earlier draft carried one for "the backup threw so the write was never +# attempted", but the catch below reports that as FAILED, so nothing could ever increment it. A tally +# column that is structurally always zero reads as "nothing was skipped" -- a claim about the run +# rather than a fact about the code, which is the shape of overclaim this whole change removes. +Write-Host (" wrote: {0} rewired: {1} unchanged: {2} refusing: {3} failed: {4} would write: {5}" -f ` + $wrote, $rewired, $unchanged, $refusing, $failed, $would) +if ($AllRoots -and $addedOutside) { + Write-Host " added: $addedOutside (this session's CLAUDE_CONFIG_DIR, outside $HomeDir)" } +Write-Host " collector : $CollectorPath (one copy, shared by every root above)" +Write-Host " refreshInterval: $RefreshInterval ms" +Write-Host "" +Write-Host " Takes effect in NEWLY STARTED sessions; existing ones keep the config they booted with." +Write-Host " A root listed above is a root whose settings FILE now carries the statusLine. That is not the" +Write-Host " same as a statusLine that FIRED -- confirm with a session started under that root." +Write-Host " Interactive only -- it never runs under 'claude -p' or the SDK." +Write-Host " Read a root's numbers from a session pinned to it:" +Write-Host " pwsh -NoProfile -File scripts\coord\usage.ps1" +Write-Host "" + +# A DRY RUN MUST NOT REPORT FAILURE. Under -WhatIf ShouldProcess returns false for every root, so a +# purely tally-driven rule would return 1 -- while the shipped script exits 0. $WhatIfPreference is +# True inside the script under -WhatIf (measured) and is the test. +if ($WhatIfPreference) { exit 0 } +$desired = $wrote + $rewired + $unchanged +if ($desired -eq $targets.Count) { exit 0 } +if ($desired -eq 0) { exit 1 } +exit 3 diff --git a/scripts/coord/usage-collect.ps1 b/scripts/coord/usage-collect.ps1 index 5c1223ba..283cc4e0 100644 --- a/scripts/coord/usage-collect.ps1 +++ b/scripts/coord/usage-collect.ps1 @@ -10,11 +10,28 @@ coordinator cannot subscribe to quota state; it has to be COLLECTED here and written somewhere shared. That single fact is why this script exists and why it is a statusLine rather than a hook. - ONE PUBLISHER, N READERS. The quota is ACCOUNT-WIDE: all sessions in every repo draw down the same - 5-hour and 7-day pools, so any ONE session's reading is the truth for all of them. Do not run a - collector per session expecting to add them up -- summing double-counts the same shared pool. The - output path is therefore user-level, not repo-level: the data is a property of the account, not of - a checkout, and a repo-scoped copy would be a second truth that goes stale. + ONE PUBLISHER, N READERS, PER ACCOUNT -- AND THE "PER ACCOUNT" IS THE HALF THAT WAS MISSING. This + is the single place that premise is stated; everything else links here. + + The quota is ACCOUNT-WIDE: all sessions in every repo draw down the same 5-hour and 7-day pools, so + any ONE session's reading is the truth for all sessions ON THAT ACCOUNT. Do not run a collector per + session expecting to add them up -- summing double-counts the same shared pool. The output path is + therefore not repo-level: the data is a property of the account, not of a checkout, and a + repo-scoped copy would be a second truth that goes stale. + + IT IS NOT MACHINE-WIDE EITHER, and an earlier version of this file wrote as if it were. A box can + run several Claude config roots at once, each pinned through CLAUDE_CONFIG_DIR, and A CONFIG ROOT + HOLDS ONE CREDENTIAL SET AND THEREFORE ONE ANTHROPIC ACCOUNT. Measured on the box this was written + for: five account roots, five different account emails, five separate pools. Publishing all of them + to one user-level file is last-writer-wins across unrelated quotas, and the damage compounds -- the + percentage flaps; the carry-forward below 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. That last one is the worst: the guard looks present and is disarmed. + + SO THE PUBLISH PATH IS PER CONFIG ROOT: \mefor-usage. The rule lives once, in + config-roots.ps1 (Get-UsageStateDir), and usage.ps1 derives its read path from the same function -- + so publisher and reader cannot drift apart. The filesystem is the partition key, which is why two + roots cannot collide however CLAUDE_CONFIG_DIR is spelled. WHAT IT CANNOT SEE, STATED HERE SO NOTHING DOWNSTREAM IMPLIES OTHERWISE. The statusLine payload carries `five_hour` and `seven_day` only. Absent: the MODEL-SCOPED weekly bucket (the "Weekly / @@ -42,17 +59,58 @@ #> [CmdletBinding()] param( - # Where to publish. User-level by default: the quota is account-wide, so this is not repo state. - [string]$StateDir = (Join-Path $env:USERPROFILE ".claude\mefor-usage"), + # Where to publish. NO DEFAULT HERE -- resolved in the body, because a param-block default cannot + # call a function this script dot-sources (param() must be the first statement). That constraint is + # a gift: computing it in the body is what lets collector, reader and installer share ONE + # derivation instead of three string literals that agree by luck. + # + # The wired statusLine ALWAYS passes this explicitly, so the body's resolution is only ever reached + # by a hand run or by a LEGACY wired command written before publish paths were per-root. + [string]$StateDir, # Raw payload capture, for verifying the schema against a real session. Off by default: the payload - # carries cwd and session ids, and this is a shared machine-level path. - [switch]$CaptureRaw + # carries cwd and session ids. + [switch]$CaptureRaw, + # A parameter, not an environment read, for the test-safety reason config-roots.ps1 states: + # [Environment]::GetFolderPath ignores a USERPROFILE override, so a callee that resolves home + # itself cannot be redirected by a test. + [string]$HomeDir = $(if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') }) ) # NO `$ErrorActionPreference = "Stop"`. This decorates a live session; a throw here is a worse outcome # than a missing reading, every time. $ErrorActionPreference = "SilentlyContinue" +# DOT-SOURCED GUARDED, WITH A LITERAL FALLBACK. This file decorates a live session and must survive a +# missing or broken sibling; a throw here is worse than any wrong path, every time. The fallback is a +# SECOND copy of a two-line rule, which is exactly the duplication SDS-3.5 warns about -- kept +# deliberately because "never throws" outranks "one definition" for a statusLine, and pinned by a test +# in tests/test_coord_usage.py so drift goes red rather than silent. +$HaveConfigRoots = $false +try { . (Join-Path $PSScriptRoot 'config-roots.ps1'); $HaveConfigRoots = $true } catch { } + +# Loaded UNCONDITIONALLY, not only when $StateDir needs resolving: the wired statusLine always passes +# -StateDir, so a load inside that branch would leave the stamp helpers undefined on the one path that +# actually runs in production. +function Get-RootLabel([string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + if ($HaveConfigRoots) { return (ConvertTo-NormalRootPath $Path) } + try { return ([System.IO.Path]::GetFullPath($Path)).TrimEnd('\', '/') } catch { return $Path } +} + +$ConfigRootEnv = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { $null } +if (-not $StateDir) { + $root = $null + if ($HaveConfigRoots) { $root = (Resolve-CurrentConfigRoot -HomeDir $HomeDir).Path } + else { $root = if ($ConfigRootEnv) { $ConfigRootEnv } else { Join-Path $HomeDir '.claude' } } + # AN UNVALIDATED PIN MUST NOT MANUFACTURE A CONFIG ROOT. New-Item -Force creates every missing + # parent (measured: it created a .claude-account-99 as a side effect), so a typo'd or stale + # CLAUDE_CONFIG_DIR would have a live session build a directory nothing can launch from -- the + # exact input the installer refuses to create. Publishing nothing is a state this script already + # handles quietly. + if ($root -and (Test-Path -LiteralPath $root -PathType Container)) { $StateDir = Join-Path $root 'mefor-usage' } +} +if (-not $StateDir) { Write-Output "mefor-usage: no config root to publish to"; exit 0 } + function Write-AtomicText([string]$Path, [string]$Text) { # Temp-then-rename. [IO.File]::Move with overwrite is MoveFileEx(MOVEFILE_REPLACE_EXISTING), which # never unlinks the destination name -- measured on this box at 0 absent-polls across 134,581, versus @@ -81,6 +139,13 @@ try { try { $p = $raw | ConvertFrom-Json -ErrorAction Stop } catch { } if (-not $p) { Write-Output "mefor-usage: unreadable statusline payload"; exit 0 } + # THE LEAF ONLY, AGAINST A PARENT THAT ALREADY EXISTS. -Force creates every missing ancestor, so + # without this guard an explicit -StateDir naming a root that is gone would have this script build + # the whole chain. Same rule as the resolution above; stated here because the wired command reaches + # this line without passing through it. + if (-not (Test-Path -LiteralPath (Split-Path $StateDir -Parent) -PathType Container)) { + Write-Output "mefor-usage: no config root to publish to"; exit 0 + } New-Item -ItemType Directory -Force -Path $StateDir | Out-Null if ($CaptureRaw) { Write-AtomicText (Join-Path $StateDir "raw-payload.json") $raw | Out-Null } @@ -128,18 +193,51 @@ try { $prevDoc = $null try { $prevDoc = Get-Content -LiteralPath $latestPath -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json -ErrorAction Stop } catch { } - if ($five) { $five.captured_at = $now } elseif ($prevDoc -and $prevDoc.five_hour) { $five = $prevDoc.five_hour } - if ($seven) { $seven.captured_at = $now } elseif ($prevDoc -and $prevDoc.seven_day) { $seven = $prevDoc.seven_day } + # THE ORIGIN TRAVELS WITH THE WINDOW, FOR THE SAME REASON captured_at DOES. + # + # A document-level stamp records who WROTE the file, not where the numbers in it came from, and the + # carry-forward is exactly the hop that separates the two. Root A publishes into root B's directory + # (a legacy or hand-copied command). B's next session fires before its first API response, carries + # A's percentages forward, and rebuilds published_by with B -- so a document-level check 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; it would never have been called FOREIGN. + # + # So a FRESHLY OBSERVED window is stamped here and a CARRIED one keeps whatever stamp it arrived + # with. usage.ps1 applies the cross-root refusal per window as well as per document. + $stampEnv = $(if ($ConfigRootEnv) { (Get-RootLabel $ConfigRootEnv) } else { "unset" }) + if ($five) { $five.captured_at = $now; $five.config_root_env = $stampEnv } + elseif ($prevDoc -and $prevDoc.five_hour) { $five = $prevDoc.five_hour } + if ($seven) { $seven.captured_at = $now; $seven.config_root_env = $stampEnv } + elseif ($prevDoc -and $prevDoc.seven_day) { $seven = $prevDoc.seven_day } # Nothing observed and nothing remembered: publish NOTHING rather than a document full of nulls. if (-not $five -and -not $seven) { Write-Output "mefor-usage: no rate_limits yet"; exit 0 } $doc = [ordered]@{ captured_at = $now + # WHERE THIS DOCUMENT CAME FROM, IN THREE FIELDS, BECAUSE ONE WOULD DETECT NOTHING. + # + # config_root is a LABEL derived from the write path, so it is always correct and can never be + # a gate: a stamp derived from where we wrote agrees with where we wrote by construction. A + # root mis-wired to publish into ANOTHER root's directory would land there stamped with that + # other root, and every check would pass. + # + # config_root_env is THE DETECTOR: the ambient CLAUDE_CONFIG_DIR, read live, never derived from + # the write path. usage.ps1 refuses a document whose config_root_env names a root other than + # the one the document sits under -- which is the only comparison that can catch a mis-wire. + # + # "unset" IS A VALUE, distinct from absent. Absent means an older collector wrote this; unset + # means this collector ran with no pin. usage.ps1 treats both as unverifiable provenance and + # SAYS SO on every reading, rather than either lying about coverage or refusing correct data. + # Whether CLAUDE_CONFIG_DIR even reaches a statusLine child process is UNMEASURED on this box; + # if it does not, that line prints forever, which is the correct loud outcome. published_by = [ordered]@{ - session_id = [string]$p.session_id - version = [string]$p.version - cwd = [string]$p.cwd + session_id = [string]$p.session_id + version = [string]$p.version + cwd = [string]$p.cwd + state_dir = $StateDir + config_root = (Get-RootLabel (Split-Path $StateDir -Parent)) + config_root_env = $(if ($ConfigRootEnv) { (Get-RootLabel $ConfigRootEnv) } else { "unset" }) } five_hour = $five seven_day = $seven diff --git a/scripts/coord/usage.ps1 b/scripts/coord/usage.ps1 index 8af1433e..1baf558b 100644 --- a/scripts/coord/usage.ps1 +++ b/scripts/coord/usage.ps1 @@ -12,7 +12,11 @@ A percentage on its own does not answer that. 80% with two hours left and nothing running is fine; 45% with 30 minutes left and six sessions compiling is not. - THREE HONESTY RULES, because a usage tool that is confidently wrong is worse than no usage tool -- + WHICH ACCOUNT'S NUMBERS. This root's. The publish path is per config root -- see usage-collect.ps1 + for why, stated once there -- so a bare invocation answers for the account THIS session booted + against, not for the box. Use -AllRoots to see every root side by side. + + FOUR HONESTY RULES, because a usage tool that is confidently wrong is worse than no usage tool -- it converts "I should check" into "I already know". 1. NO PERCENTAGE WITHOUT ITS AGE. Every number is printed with how long ago it was observed. @@ -26,44 +30,248 @@ `seven_day` window read here, so Opus work is fully covered. An earlier draft warned about an invisible Opus bucket that does not exist -- a false blind spot is its own failure, because a session told its headroom is unknowable stops trusting a reading that was accurate. + 4. REFUSE A READING FROM SOMEBODY ELSE'S ACCOUNT, AND SAY WHEN THAT CANNOT BE CHECKED. A document + stamped with a config root other than the one it sits under is reported UNKNOWN, never as this + session's headroom. A document carrying no stamp is read, and labelled UNVERIFIED -- absence + of provenance and wrong provenance are different facts, and only one of them is an error. EXIT CODES, so a coordinator can branch without parsing prose: 0 OK 10 WARN -- high, or projected to exhaust before reset with slack 11 CRITICAL -- projected to exhaust before reset, or already at the ceiling - 20 UNKNOWN -- no data, stale data, or not enough samples to say + 20 UNKNOWN -- no data, stale data, not enough samples, or a reading this root may not trust + Every diagnostic state added for the per-root publish path is UNKNOWN/20: they distinguish WHICH + FIX to apply, not how bad the situation is, and a coordinator branches on the four codes above. .EXAMPLE pwsh -NoProfile -File scripts\coord\usage.ps1 pwsh -NoProfile -File scripts\coord\usage.ps1 -Json + pwsh -NoProfile -File scripts\coord\usage.ps1 -AllRoots + # Peek at another root without leaving this session: + pwsh -NoProfile -File scripts\coord\usage.ps1 -StateDir "$HOME\.claude-account-1\mefor-usage" #> [CmdletBinding()] param( - [string]$StateDir = (Join-Path $env:USERPROFILE ".claude\mefor-usage"), + # NO DEFAULT. Resolved in the body from this session's own config root, because a param-block + # default cannot call a function the script dot-sources (param() must be the first statement). + # That constraint is a gift: computing it in the body is what lets reader, collector and installer + # share ONE derivation instead of restating a literal that agrees by luck. + [string]$StateDir, # Machine-readable, for the coordinator. [switch]$Json, # Older than this and a reading is reported but NOT projected from. [int]$MaxAgeMinutes = 20, # Rate is measured over at most this much recent history. - [int]$RateWindowMinutes = 90 + [int]$RateWindowMinutes = 90, + # One line per config root on this box. A SURVEY, NEVER A MERGE: these are different accounts with + # different 5h and 7d pools, so summing, averaging or taking a worst-of across them would rebuild + # the exact lie the per-root publish path removes. + [switch]$AllRoots, + # A parameter for the test-safety reason config-roots.ps1 states. + [string]$HomeDir = $(if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') }) ) +$script:GaveStateDir = $PSBoundParameters.ContainsKey('StateDir') + $ErrorActionPreference = "SilentlyContinue" +# THE READER NEEDS THIS GUARD MORE THAN THE COLLECTOR DOES, and SilentlyContinue is why. With the +# library missing, the dot-source failure is swallowed, Resolve-CurrentConfigRoot is not found +# (swallowed), $StateDir stays $null, Join-Path $null "latest.json" yields the EMPTY STRING, and this +# script would print "Nothing has published to ." and then diagnose `\settings.json` -- a confidently +# wrong answer, which is the one outcome the honesty rules above exist to prevent. The shipped param +# default could not fail this way; removing it introduces the hazard, so it is closed here. +$rootInfo = $null +$haveLib = $false +try { + . (Join-Path $PSScriptRoot 'config-roots.ps1') + $rootInfo = Resolve-CurrentConfigRoot -HomeDir $HomeDir + $haveLib = $true +} +catch { } +# THE FLOOR IS THE LIBRARY, NOT THE PATH, and testing the path alone would leave a hole. An explicit +# -StateDir resolves the path without the library, but every downstream check -- Test-IsOurStatusLine, +# Get-WiredStateDir, Test-SameRoot -- comes from it, and under SilentlyContinue a missing function is +# swallowed rather than raised. That would produce a full, confidently wrong diagnosis. Refuse on the +# library, whatever the path. +if (-not $haveLib) { + $reason = "cannot resolve this session's config root -- scripts\coord\config-roots.ps1 did not load" + if ($Json) { @{ state = "UNKNOWN"; reason = $reason; path = $null } | ConvertTo-Json -Compress | Write-Output } + else { Write-Host ""; Write-Host "UNKNOWN. $reason" -ForegroundColor Yellow; Write-Host "" } + exit 20 +} +if (-not $StateDir) { $StateDir = Get-UsageStateDir $rootInfo.Path } + $latestPath = Join-Path $StateDir "latest.json" $histPath = Join-Path $StateDir "history.jsonl" $doc = $null try { $doc = Get-Content -LiteralPath $latestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { } +# WHY THE READER DIAGNOSES AND THE INSTALLER CANNOT. The reader is the only half that runs INSIDE the +# pin, so it is the only one positioned to see which root a session really boots against. And because +# it already has to open this root's settings.json to answer at all, the wired command is in hand at +# no extra cost -- which is what lets it distinguish "not wired" from "wired to publish somewhere +# else", two states with completely different fixes that the old one-line message merged. +# +# EIGHT STATES. The old message named none of them: it said "not installed or has not run yet" and +# printed the bare installer command with no root -- so following the reader's own advice re-ran the +# exact invocation that produced the false INSTALLED claim in the first place. +function Get-StatusLineDiagnosis([string]$Root, [string]$ReadingFrom) { + $settingsPath = Join-Path $Root "settings.json" + # THE DIRECTORY ITSELF FIRST. A typo'd CLAUDE_CONFIG_DIR otherwise reads as "this root has no + # settings.json", and the remedy printed for that state is an installer invocation the installer + # REFUSES (it will not create a config root). The operator follows the advice, gets a refusal, and + # nothing anywhere has named the actual fault. + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { + return [ordered]@{ + settings_path = $settingsPath + state = "NO_SUCH_ROOT" + line = "NO SUCH DIRECTORY -- the config root itself does not exist" + remedy = @("Nothing can be wired here, and the installer refuses to create a config root.", + "Fix CLAUDE_CONFIG_DIR (or the launcher that sets it) to name a directory that exists.") + wired_state_dir = $null + wired_collector = $null + } + } + $o = [ordered]@{ + settings_path = $settingsPath + state = "NOT_WIRED_NO_SETTINGS" + line = "NOT WIRED -- this root has no settings.json" + remedy = @("No session booting from this root can publish. Wire it (owner, plain terminal):", + " pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -ConfigDir `"$Root`"") + wired_state_dir = $null + wired_collector = $null + } + if (-not (Test-Path -LiteralPath $settingsPath)) { return $o } + + $settings = $null + try { $settings = Get-Content -LiteralPath $settingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } + catch { + $o.state = "SETTINGS_UNREADABLE" + $o.line = "UNKNOWN -- settings.json is not valid JSON" + $o.remedy = @("An unparseable settings.json silently disables EVERY setting in it, not just this one.", + "Fix that first.") + return $o + } + + $cmd = [string]$settings.statusLine.command + if ([string]::IsNullOrWhiteSpace($cmd)) { + $o.state = "NOT_WIRED_NO_STATUSLINE" + $o.line = "NOT WIRED -- settings.json carries no statusLine" + return $o + } + if (-not (Test-IsOurStatusLine $cmd)) { + $o.state = "FOREIGN_STATUSLINE" + $o.line = "FOREIGN -- a statusLine that is not ours owns this root's status bar" + $o.remedy = @("The collector never runs here, and the installer REFUSES to replace someone else's", + "statusLine. Merge the two commands by hand, or remove theirs, then re-install.") + return $o + } + + $o.wired_state_dir = Get-WiredStateDir $cmd + $o.wired_collector = Get-WiredCollectorPath $cmd + $reinstall = @("Re-wire this root so the two agree:", + " pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -ConfigDir `"$Root`"") + + if ($null -eq $o.wired_state_dir) { + $o.state = "WIRED_LEGACY" + $o.line = "WIRED (ours) but the command carries no -StateDir -- written before publish paths were per-root. Where it publishes depends on the collector's own fallback at run time." + $o.remedy = $reinstall + return $o + } + if ($o.wired_collector -and -not (Test-Path -LiteralPath $o.wired_collector)) { + $o.state = "WIRED_COLLECTOR_MISSING" + $o.line = "WIRED (ours) but the collector it names is absent: $($o.wired_collector)" + $o.remedy = @("The status bar shows 'mefor-usage: collector missing' and nothing publishes.", + "Advance the primary checkout, or re-install to point at a collector that exists.") + return $o + } + if (-not (Test-SameRoot $o.wired_state_dir $ReadingFrom)) { + # THIS ARM IS THE POINT OF THE WHOLE DIAGNOSIS. Without it the reader tells the operator to + # restart and wait, forever, with nothing anywhere saying the two halves disagree -- which is + # the original defect relocated rather than fixed. + $o.state = "WIRED_ELSEWHERE" + $o.line = "WIRED (ours) but it publishes to $($o.wired_state_dir), and this session reads $ReadingFrom. WAITING WILL NOT FIX THIS." + $o.remedy = $reinstall + return $o + } + $o.state = "WIRED_HERE" + $o.line = "WIRED (ours), and it is wired to publish where this reader is looking" + $o.remedy = @("Settings are read at session START, so a session already running when it was wired still", + "has none. Start a NEW session pinned to this root and give it about ten seconds. Interactive", + "only -- it never runs under 'claude -p' or the SDK, so a headless coordinator can read this", + "and can never publish it.") + return $o +} + +$readRoot = Split-Path $StateDir -Parent +$rootSource = if ($script:GaveStateDir) { "-StateDir" } elseif ($rootInfo) { $rootInfo.Source } else { "unknown" } + +# DIAGNOSED ON EVERY RUN, NOT ONLY WHEN THERE IS NOTHING TO READ. An earlier version computed this +# inside the no-data branch, which made WIRED_ELSEWHERE -- the arm this diagnosis exists for -- +# unreachable in the case where it misleads most: a root now wired to publish into a SIBLING still has +# 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", which is also false. The session is publishing; it is +# publishing somewhere else, and nothing in the output said so. +$dx = Get-StatusLineDiagnosis $readRoot $StateDir + if (-not $doc) { - $msg = "NO USAGE DATA. Nothing has published to $latestPath." - $fix = "The statusLine collector is not installed or has not run yet. Install it (owner, plain terminal):`n pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1`nIt only runs in an INTERACTIVE session -- never under 'claude -p' or the SDK." - if ($Json) { @{ state = "UNKNOWN"; reason = "no data"; path = $latestPath } | ConvertTo-Json -Compress | Write-Output } - else { Write-Host ""; Write-Host $msg -ForegroundColor Yellow; Write-Host $fix; Write-Host "" } + if ($Json) { + [ordered]@{ + state = "UNKNOWN" + reason = "no data" + path = $latestPath + config_root = $readRoot + config_root_source = $rootSource + settings_path = $dx.settings_path + statusline_state = $dx.state + state_dir = $StateDir + wired_state_dir = $dx.wired_state_dir + wired_collector = $dx.wired_collector + } | ConvertTo-Json -Compress | Write-Output + } + else { + Write-Host "" + Write-Host "NO USAGE DATA. Nothing has published to $latestPath." -ForegroundColor Yellow + Write-Host " config root : $readRoot (from $rootSource)" + Write-Host " settings : $($dx.settings_path)" + Write-Host " statusLine : $($dx.line)" + Write-Host "" + foreach ($l in $dx.remedy) { Write-Host " $l" } + Write-Host "" + Write-Host " This reads the FILE. A file that carries the statusLine is not the same as a statusLine that FIRED." + Write-Host "" + } exit 20 } +# RULE 4, THE REFUSAL. Compared against the READ-FROM root -- the root the document actually sits +# under -- and NOT against the reader's own root. Those are identical on the default path, but +# comparing against the reader's root would break the two invocations that exist precisely to look +# elsewhere: the documented single-root peek (-StateDir ) and every row of the -AllRoots +# survey but one. It still catches the failure it exists for: a reader in A opens A's file, the stamp +# says B, B is not A, refuse. +# +# THE STAMP IT GATES ON IS config_root_env, NOT config_root. config_root is derived from the write +# path, so it agrees with the write path by construction and could never detect a mis-wire. +$stampEnv = [string]$doc.published_by.config_root_env +$provenance = "OK" +if ($stampEnv -and $stampEnv -ne "unset") { + if (-not (Test-SameRoot $stampEnv $readRoot)) { + $reason = "published from a DIFFERENT config root ($stampEnv); this document sits under $readRoot -- refusing to report another account's headroom as this session's" + if ($Json) { [ordered]@{ state = "UNKNOWN"; reason = $reason; path = $latestPath; config_root = $readRoot; provenance = "FOREIGN" } | ConvertTo-Json -Compress | Write-Output } + else { Write-Host ""; Write-Host "UNKNOWN. $reason" -ForegroundColor Yellow; Write-Host "" } + exit 20 + } +} +else { + # ABSENCE IS UNVERIFIABLE PROVENANCE, NOT WRONG PROVENANCE -- so the reading is used, and the fact + # that the guard could not run is stated on every line of output rather than assumed away. It is + # also what keeps every hand-written fixture and every pre-change document readable. + $provenance = "UNVERIFIED" +} + $nowUtc = (Get-Date).ToUniversalTime() function Get-AgeMinutes($v) { @@ -122,10 +330,33 @@ function Get-Rate([string]$Key, [string]$ResetKey, $CurrentResetEpoch) { } } -function Get-WindowReport($w, [string]$Label, [string]$Key, [string]$ResetKey) { +# ONE DEFINITION of the per-window cross-root test. The single-root reader and the -AllRoots survey +# both call it, because two statements of one rule is exactly how the survey came to print a +# percentage that the reader four lines above had just refused. +function Test-WindowFromRoot($w, [string]$ReadRoot) { + if (-not $w) { return $true } + $e = [string]$w.config_root_env + if (-not $e -or $e -eq "unset" -or -not $ReadRoot) { return $true } + return [bool](Test-SameRoot $e $ReadRoot) +} + +function Get-WindowReport($w, [string]$Label, [string]$Key, [string]$ResetKey, [string]$ReadRoot) { if (-not $w) { return [ordered]@{ label = $Label; state = "UNKNOWN"; reason = "never published"; used_percentage = $null } } + # RULE 4, PER WINDOW. A window carries the config root that OBSERVED it, and the carry-forward keeps + # that stamp rather than restamping -- so a percentage that came from another account is caught here + # even when the document around it was written by this root. The document-level check cannot see + # that hop: it compares the writer to the directory, and both are correct. + $wEnv = [string]$w.config_root_env + if (-not (Test-WindowFromRoot $w $ReadRoot)) { + return [ordered]@{ + label = $Label + state = "UNKNOWN" + reason = "this window was observed under a DIFFERENT config root ($wEnv) and carried into a document under $ReadRoot -- refusing to report another account's headroom" + used_percentage = $null + } + } $age = Get-AgeMinutes $w.captured_at $pct = [double]$w.used_percentage $resetEpoch = $w.resets_at_epoch @@ -157,7 +388,11 @@ function Get-WindowReport($w, [string]$Label, [string]$Key, [string]$ResetKey) { $o.state = "UNKNOWN" $o.reason = if ($null -eq $age) { "reading is undateable" } elseif ($age -lt -2) { "reading is dated $([math]::Abs($age)) min in the FUTURE -- clock skew or a bad timestamp; refusing to trust it" } - else { "reading is $age min old (max $MaxAgeMinutes) -- no live session is publishing" } + # NO CAUSAL CLAIM. This used to read "-- no live session is publishing", which nothing here + # checks and which is often false: a session may be publishing perfectly well, into another + # root. The mis-wire warning printed above the numbers names that case; this line states the + # age and stops. + else { "reading is $age min old (max $MaxAgeMinutes)" } return $o } @@ -188,17 +423,31 @@ function Get-WindowReport($w, [string]$Label, [string]$Key, [string]$ResetKey) { return $o } -$five = Get-WindowReport $doc.five_hour "session (5h)" "five_hour" "five_reset" -$seven = Get-WindowReport $doc.seven_day "weekly (7d)" "seven_day" "seven_reset" +$five = Get-WindowReport $doc.five_hour "session (5h)" "five_hour" "five_reset" $readRoot +$seven = Get-WindowReport $doc.seven_day "weekly (7d)" "seven_day" "seven_reset" $readRoot $rank = @{ "OK" = 0; "WARN" = 10; "CRITICAL" = 11; "UNKNOWN" = 20 } $states = @($five.state, $seven.state) # CRITICAL outranks UNKNOWN: a known emergency in one window is not softened by the other being unknown. +# ONE LIST, READ TWICE. The warning printed below and the verdict computed here must name the same +# states, or the prose and the exit code describe different situations -- the two-instruments- +# disagreeing defect this whole change exists to remove, reproduced inside one script. +$dxUntrusted = @("WIRED_ELSEWHERE", "WIRED_LEGACY", "WIRED_COLLECTOR_MISSING", "FOREIGN_STATUSLINE", + "NOT_WIRED_NO_SETTINGS", "NOT_WIRED_NO_STATUSLINE") + $overall = if ($states -contains "CRITICAL") { "CRITICAL" } elseif ($states -contains "WARN") { "WARN" } elseif ($states -contains "UNKNOWN") { "UNKNOWN" } else { "OK" } +# A READING THIS ROOT MAY NOT TRUST IS UNKNOWN, which is what the exit-code contract promises. The +# diagnosis used to reach the prose only, so a root the script itself called mis-wired still exited 0 +# and reported state=OK beside statusline_state=WIRED_ELSEWHERE in one document. An explicit -StateDir +# is exempt: there the operator named the directory, and the root above it need not be wired at all. +if (-not $script:GaveStateDir -and $overall -ne "CRITICAL" -and $dx.state -in $dxUntrusted) { + $overall = "UNKNOWN" +} + # RULE 3, and the guidance is an ACTION. This repo has already learned that "don't do X" is the wrong # primitive when automation has X armed -- see docs/WORKTREES.md. "Commit and hand off" is something a # session can DO; "be careful" is not. @@ -211,6 +460,63 @@ $advice = switch ($overall) { $blindSpot = "NOT MEASURED: the model-scoped weekly bucket (Fable) and the plan tier are absent from the statusLine payload. Opus and Sonnet are NOT gaps -- they have no separate bucket and count against the 7d all-models window above, so Opus work is fully covered here." +# ONE LINE PER CONFIG ROOT, AND NOTHING COMPUTED ACROSS THEM. Each row is validated against ITS OWN +# root, which is why the refusal above compares to the read-from root rather than the reader's -- with +# the other comparison every row but this session's would render as a refusal. +function Get-RootSummary([string]$Root) { + $sd = Get-UsageStateDir $Root + $lp = Join-Path $sd "latest.json" + $s = [ordered]@{ root = $Root; state_dir = $sd; published = $false; note = ""; five = $null; seven = $null; age_min = $null; stale = $false } + $d = $null + try { $d = Get-Content -LiteralPath $lp -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { } + if (-not $d) { + $dx = Get-StatusLineDiagnosis $Root $sd + $s.note = "never published [statusLine: $($dx.state)]" + return $s + } + $env_ = [string]$d.published_by.config_root_env + if ($env_ -and $env_ -ne "unset" -and -not (Test-SameRoot $env_ $Root)) { + $s.note = "REFUSED -- stamped with a different config root ($env_)" + return $s + } + $s.published = $true + # THE SURVEY IS THE ONLY PLACE ACCOUNTS ARE COMPARED, so it is where the cross-root refusal + # matters most -- and it was the one place it did not run. Checking only the DOCUMENT stamp passes + # a carry-forward: the document was written by this root and sits under this root, while the + # numbers inside came from another. Measured: the single-root reader refused both windows and the + # survey four lines later printed them as this root's own. + $fiveOurs = Test-WindowFromRoot $d.five_hour $Root + $sevenOurs = Test-WindowFromRoot $d.seven_day $Root + $s.five = if ($d.five_hour -and $fiveOurs) { [double]$d.five_hour.used_percentage } else { $null } + $s.seven = if ($d.seven_day -and $sevenOurs) { [double]$d.seven_day.used_percentage } else { $null } + # AGED BY ITS WINDOWS, NOT BY THE DOCUMENT. The collector rewrites the document stamp on EVERY + # fire, including a pure carry-forward, so it dates the last fire and not the observation -- a + # six-hour-old reading rendered as "[seen 0 min ago]". The OLDER of the two windows is the honest + # single number for a row printing both. The @() wrapper is load-bearing: without it a single + # surviving age arrives as a scalar and .Count is unreliable. + $fa = if ($d.five_hour) { Get-AgeMinutes $d.five_hour.captured_at } else { $null } + $sa = if ($d.seven_day) { Get-AgeMinutes $d.seven_day.captured_at } else { $null } + $ages = @(@($fa, $sa) | Where-Object { $null -ne $_ }) + # The document stamp is the fallback only when no window carries one, which keeps pre-change + # documents and hand-written fixtures readable. + $s.age_min = if ($ages.Count) { ($ages | Measure-Object -Maximum).Maximum } else { Get-AgeMinutes $d.captured_at } + $s.stale = ($null -eq $s.age_min) -or ($s.age_min -gt $MaxAgeMinutes) + if (-not $fiveOurs -or -not $sevenOurs) { + $s.note = "REFUSED -- a window here was observed under a DIFFERENT config root" + } + elseif (-not $env_ -or $env_ -eq "unset") { $s.note = "provenance UNVERIFIED" } + return $s +} + +$survey = $null +if ($AllRoots) { + $survey = @() + foreach ($r in @(Get-LaunchableConfigRoots -HomeDir $HomeDir)) { $survey += Get-RootSummary $r } + if ($rootInfo -and -not ($survey | Where-Object { Test-SameRoot $_.root $readRoot })) { + $survey += Get-RootSummary $readRoot + } +} + if ($Json) { [ordered]@{ state = $overall @@ -219,6 +525,13 @@ if ($Json) { seven_day = $seven advice = $advice not_measured = $blindSpot + provenance = $provenance + statusline_state = $dx.state + wired_state_dir = $dx.wired_state_dir + config_root = $readRoot + config_root_source = $rootSource + state_dir = $StateDir + roots = $survey published_by = $doc.published_by captured_at = $doc.captured_at } | ConvertTo-Json -Depth 6 | Write-Output @@ -242,12 +555,90 @@ function Show-Window($o) { Write-Host "" Write-Host "Claude account usage -- $overall" -ForegroundColor $(switch ($overall) { "CRITICAL" { "Red" } "WARN" { "Yellow" } "UNKNOWN" { "DarkGray" } default { "Green" } }) +# WHOSE NUMBERS THESE ARE, ON EVERY RUN. Five roots on this box are five different accounts with five +# separate pools, so a percentage with no root beside it is an unattributed number -- the same shape of +# omission as a percentage with no age. +Write-Host " config root: $readRoot (from $rootSource)" -ForegroundColor DarkGray +# A MIS-WIRE IS PRINTED ABOVE THE NUMBERS, not below them and not only when there are none. If this root +# publishes somewhere else, the percentages under this heading are a leftover, and saying so after the +# reader has already read them is too late to stop the wrong decision. +if ($dx.state -in $dxUntrusted) { + Write-Host "" + Write-Host " WARNING -- the numbers below may be a leftover:" -ForegroundColor Yellow + Write-Host " $($dx.line)" -ForegroundColor Yellow + foreach ($l in $dx.remedy) { Write-Host " $l" -ForegroundColor DarkGray } +} +# CORRECTLY WIRED AND STILL NOT FRESH IS ITS OWN FAILURE, and it is the one actually seen on this box: +# every root wired, every collector present, and latest.json frozen for 47 minutes across nine live +# sessions. The mis-wire arm above cannot catch it -- a healthy root reports WIRED_HERE -- so without +# this it falls through to a bare "reading is N min old", which is the line a reader skims past. The +# earlier "-- no live session is publishing" at least signalled that something was wrong; deleting it +# as an unchecked causal claim was right, but it left this case with no signal at all. +# +# EVERY CLAUSE IS BACKED. "wired, and the collector it names exists" is what WIRED_HERE already tested +# (ownership, an extracted -StateDir matching this directory, and Test-Path on the collector). "nothing +# fresh" is the window states. The two CAUSES are offered as alternatives, never asserted, because +# nothing here can tell them apart. +# -and, NOT -or. With -or this fired on a document published 0.15 seconds earlier: a fresh five_hour +# beside a seven_day that has simply never been published leaves one window UNKNOWN, and the warning +# then claimed "nothing fresh has published here" over a reading taken moments ago. The claim is that +# NOTHING is fresh, so the test has to be that nothing is. +elseif ($dx.state -eq "WIRED_HERE" -and $five.state -eq "UNKNOWN" -and $seven.state -eq "UNKNOWN") { + # THE COLLECTOR CLAUSE IS CONDITIONAL, because WIRED_HERE does not always mean one was checked: a + # wired command carrying a -StateDir but no recognisable collector assignment yields a null + # collector path, which skips the Test-Path guard entirely. Asserting it exists would be asserting + # a check that never ran. + $collClause = if ($dx.wired_collector) { " and the collector it names exists" } else { "" } + Write-Host "" + Write-Host " WARNING -- this root is wired correctly$collClause," -ForegroundColor Yellow + Write-Host " yet nothing fresh has published here. Two causes, and this cannot tell them apart:" -ForegroundColor Yellow + Write-Host " - no session has STARTED here since it was wired (settings are read at session start), or" -ForegroundColor DarkGray + Write-Host " - the statusLine is configured but never runs. It is part of the terminal UI's render" -ForegroundColor DarkGray + Write-Host " tree, so check whether your client shows a status bar at all; a line beginning" -ForegroundColor DarkGray + Write-Host " 'mefor-usage' there means it IS firing and the fault is elsewhere." -ForegroundColor DarkGray +} Write-Host "" Show-Window $five Show-Window $seven Write-Host "" Write-Host " $advice" +if ($provenance -eq "UNVERIFIED") { + Write-Host "" + Write-Host " provenance: UNVERIFIED -- the publisher recorded no CLAUDE_CONFIG_DIR, so the cross-root guard could not run" -ForegroundColor DarkGray +} Write-Host "" Write-Host " $blindSpot" -ForegroundColor DarkGray +if ($survey) { + Write-Host "" + Write-Host " Config roots under $HomeDir matching the launcher name shape, plus this session's." + Write-Host " A survey -- nothing is summed across accounts:" + foreach ($s in $survey) { + $mark = if (Test-SameRoot $s.root $readRoot) { " <- this session" } else { "" } + if ($s.published) { + $f = if ($null -ne $s.five) { "5h {0,3:0}%" -f $s.five } else { "5h -" } + $v = if ($null -ne $s.seven) { "7d {0,3:0}%" -f $s.seven } else { "7d -" } + # Two branches rather than a computed -ForegroundColor: passing $null to that parameter + # throws ("Cannot convert null to type System.ConsoleColor"), measured. + if ($s.stale) { + Write-Host (" {0,-46} {1} {2} [STALE -- oldest window seen {3} min ago, max {4}]{5}" -f $s.root, $f, $v, $s.age_min, $MaxAgeMinutes, $mark) -ForegroundColor DarkGray + } + else { + Write-Host (" {0,-46} {1} {2} [seen {3} min ago]{4}" -f $s.root, $f, $v, $s.age_min, $mark) + } + if ($s.note) { Write-Host (" {0,-46} {1}" -f "", $s.note) -ForegroundColor DarkGray } + } + else { + Write-Host (" {0,-46} {1}{2}" -f $s.root, $s.note, $mark) -ForegroundColor DarkGray + } + } + # ENUMERATED BY A DIFFERENT RULE, so the survey cannot confirm its own predicate. The rows above + # come from an anchored, case-sensitive name shape; this pass finds every ~/.claude* directory + # carrying a settings.json and names any the survey did not cover. Without it, a root spelled + # `.Claude-Account-7` and burning quota would be absent from a list the operator reads as complete. + foreach ($c in @(Get-ClaudeConfigCandidates -HomeDir $HomeDir)) { + if ($survey | Where-Object { Test-SameRoot $_.root $c.FullName }) { continue } + Write-Host (" {0,-46} not surveyed -- carries a settings.json but is not a launcher-shaped root name" -f $c.Name) -ForegroundColor Yellow + } +} Write-Host "" exit $rank[$overall] diff --git a/tests/test_coord_usage.py b/tests/test_coord_usage.py index 6fc55267..e41b47aa 100644 --- a/tests/test_coord_usage.py +++ b/tests/test_coord_usage.py @@ -50,7 +50,16 @@ def collect(state: Path, payload: dict[str, Any] | str) -> str: - """Drive the collector exactly as Claude Code drives a statusLine: JSON on stdin, line on stdout.""" + """Drive the collector exactly as Claude Code drives a statusLine: JSON on stdin, line on stdout. + + THE PIN IS POPPED, and that is not incidental. The collector stamps each freshly observed window + with the config root that saw it, so a helper that inherited this process's own + ``CLAUDE_CONFIG_DIR`` would stamp every fixture with the real account root — and any test that then + read the fixture back through ``usage.ps1 -StateDir `` would trip the cross-root refusal and + fail for a reason that has nothing to do with what it is testing. An unpinned publisher stamps + ``unset``, which is what a neutral fixture should be. Tests that care about a specific root pass + one explicitly. + """ raw = payload if isinstance(payload, str) else json.dumps(payload) proc = subprocess.run( ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(COLLECT), "-StateDir", str(state)], @@ -59,6 +68,7 @@ def collect(state: Path, payload: dict[str, Any] | str) -> str: text=True, timeout=TIMEOUT, check=False, + env=_env(None), ) # A statusLine that exits non-zero degrades the session it decorates; it must never do that. assert proc.returncode == 0, f"collector exited {proc.returncode}: {proc.stderr}" @@ -391,8 +401,15 @@ def test_opus_is_not_claimed_as_a_blind_spot(tmp_path: Path) -> None: publish(tmp_path, five=10.0, seven=10.0) _, d = read(tmp_path) text = d["not_measured"].lower() - assert "opus" not in text or "not gaps" in text or "fully covered" in text, ( - f"Opus must not be presented as unmeasured: {d['not_measured']}" + # TWO INDEPENDENT ASSERTIONS, NOT ONE DISJUNCTION. As a single `or` this could not fail for the + # defect it names: re-inserting the exact false sentence ("heavy Opus use can exhaust a bucket + # nothing here can see") while leaving the "NOT gaps" text in place kept the whole expression true, + # and the suite stayed green while every session was told its Opus headroom was unknowable. + assert "not gaps" in text and "fully covered" in text, ( + f"Opus must be named as covered, not merely omitted: {d['not_measured']}" + ) + assert "can exhaust" not in text and "nothing here can see" not in text, ( + f"the false blind spot is back: {d['not_measured']}" ) @@ -431,7 +448,17 @@ def test_the_installer_refuses_to_replace_someone_elses_statusline(tmp_path: Pat def test_the_installed_command_actually_runs_the_collector(tmp_path: Path) -> None: """A wired command that resolves to nothing is this repo's most-repeated defect — the announce hook sat merged-and-never-installed for hours, and its own missing-script notice could not fire because it - lived inside the shim that was never wired. So assert the wired string EXECUTES and publishes.""" + lived inside the shim that was never wired. So assert the wired string EXECUTES and publishes. + + IT NOW RUNS THE COMMAND VERBATIM, which is what this docstring always claimed. The earlier version + rewrote it — ``cmd.replace("-File $s", "-File $s -StateDir ''")`` — because the wired command + carried no publish path, so the test had to inject one to keep the collector off the real + user-level file. Since the installer now bakes a per-root path in, that injection produces + ``-StateDir`` twice and pwsh refuses to bind it (measured: exit 1, "specified more than once"). + Deleting the rewrite is what lets the test exercise the actual production string, and the fixture + is safe by construction: ``-SettingsPath`` puts the root at ``tmp_path``, so the wired path is + ``tmp_path/mefor-usage``. + """ settings = tmp_path / "settings.json" settings.write_text("{}", encoding="utf-8") subprocess.run( @@ -454,24 +481,1543 @@ def test_the_installed_command_actually_runs_the_collector(tmp_path: Path) -> No cmd = json.loads(settings.read_text(encoding="utf-8"))["statusLine"]["command"] assert "mefor-usage" in cmd - state = tmp_path / "state" + state = tmp_path / "mefor-usage" + # The publish path is baked into the command, so assert it points where this root reads — a + # recomputed expectation would agree with a wrong wiring. + assert f"$d = '{state}'" in cmd, f"the wired command names no per-root publish path: {cmd!r}" + payload = json.dumps({"session_id": "wired", "rate_limits": {"five_hour": window(55.0, 3600)}}) - # Run the wired command itself, with the collector's state redirected so the test cannot write to - # the real user-level publish path. proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", cmd], + input=payload, + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + assert proc.returncode == 0, proc.stderr + assert "55" in proc.stdout, f"the wired command produced no reading: {proc.stdout!r}" + assert (state / "latest.json").exists(), "the wired command ran but published nothing" + + +# ------------------------------------------------------------- config roots and the publish partition +# +# WHY THIS SECTION EXISTS. The installer used to write ``~/.claude/settings.json`` unconditionally and +# report "INSTALLED (user level -- every session on this machine)". On a box whose launchers pin +# ``CLAUDE_CONFIG_DIR`` to ``~/.claude-account-``, Claude Code reads the PINNED root, so the +# statusLine never fired, nothing ever published, and ``usage.ps1`` correctly said the collector was +# not installed. An install success followed by a reader saying it was never installed. +# +# The second half is the publish path. A config root holds one credential set and therefore one +# Anthropic account; measured on the box this was written for, five account roots carry five different +# account emails and five separate 5h/7d pools. Publishing them all to one user-level file is +# last-writer-wins across unrelated quotas, and it disarms the staleness guard -- some other account +# keeps the file warm, so a reading always looks fresh. So the publish path is per config root, and +# these tests pin that the writer and the reader derive it from the same rule. + +CONFIG_ROOTS = ROOT / "scripts" / "coord" / "config-roots.ps1" + + +def _env(pin: Path | str | None) -> dict[str, str]: + """A child environment with the pin set EXPLICITLY, or explicitly absent. + + ``os.environ.copy()`` alone is not enough and the gap is silent: this suite runs inside a Claude + Code session, which on this box is itself pinned, so a child inherits ``CLAUDE_CONFIG_DIR`` and the + "no pin" arm would quietly test the "pinned" one and pass. It is popped, never merely overwritten. + """ + env = os.environ.copy() + env.pop("CLAUDE_CONFIG_DIR", None) + if pin is not None: + env["CLAUDE_CONFIG_DIR"] = str(pin) + return env + + +def install( + *args: str, + pin: Path | str | None = None, + home: Path | None = None, + collector: Path | None = COLLECT, +) -> subprocess.CompletedProcess[str]: + cmd = ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(INSTALL)] + if home is not None: + cmd += ["-HomeDir", str(home)] + if collector is not None: + cmd += ["-CollectorPath", str(collector)] + cmd += list(args) + return subprocess.run( + cmd, capture_output=True, text=True, timeout=TIMEOUT, check=False, env=_env(pin) + ) + + +def reader( + *args: str, + pin: Path | str | None = None, + home: Path | None = None, + script: Path | None = None, +) -> tuple[int, dict[str, Any], str]: + cmd = ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(script or READ)] + if home is not None: + cmd += ["-HomeDir", str(home)] + cmd += list(args) + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=TIMEOUT, check=False, env=_env(pin) + ) + out = proc.stdout.strip() + parsed: dict[str, Any] = {} + if "-Json" in args and out: + parsed = json.loads(out) + return proc.returncode, parsed, proc.stdout + + +def wired(settings: Path) -> str: + cmd: str = json.loads(settings.read_text(encoding="utf-8"))["statusLine"]["command"] + return cmd + + +@pytest.fixture +def fake_home(tmp_path: Path) -> Path: + """A home directory shaped like the real box, INCLUDING the three kinds of look-alike. + + ``.claude-account-2.lock`` is a real directory on this machine carrying a settings.json and no + ``.claude.json`` — a loose ``.claude-account-*`` glob adopts it, and ``install-gate.ps1`` wired it + on every run for weeks (BACKLOG #1024). ``.claude-desktop-`` carry a ``.claude.json`` and + nothing launches from them, which is why "has a .claude.json" is also the wrong predicate. + """ + h = tmp_path / "home" + for name in ( + ".claude", + ".claude-account-1", + ".claude-account-2", + ".claude-account-2.lock", + ".claude-desktop-1", + ".claude-tools", + ): + (h / name).mkdir(parents=True) + (h / ".claude-account-2.lock" / "settings.json").write_text("{}", encoding="utf-8") + return h + + +# --- requirement 1: honour the pin ------------------------------------------------------------- + + +def test_a_pinned_config_dir_is_where_the_statusline_lands(fake_home: Path) -> None: + """THE LIVE SYMPTOM, REPRODUCED. Reverting the installer to a ``-SettingsPath`` default of + ``\\.claude\\settings.json`` makes this fail on the second assertion.""" + pin = fake_home / ".claude-account-1" + proc = install(pin=pin, home=fake_home) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (pin / "settings.json").exists(), "the pinned root was not written" + assert not (fake_home / ".claude" / "settings.json").exists(), ( + "the default root was written even though CLAUDE_CONFIG_DIR named another" + ) + + +def test_a_pinned_root_does_not_steal_an_explicit_settings_path( + fake_home: Path, tmp_path: Path +) -> None: + """Rule 1 outranks rule 4, and the script-scope explicitness capture actually works. + + THE TWO PRE-EXISTING INSTALLER TESTS CANNOT CATCH THIS. They check only their own fixture root, so + they pass whether or not the pin is honoured. If ``$PSBoundParameters`` were tested inside the + resolver function instead of at script scope it would read False for every caller (measured: a + function's own ``$PSBoundParameters`` is EMPTY), rules 1 and 2 would be unreachable, and this + install would land in the caller's live pinned root instead of the fixture. + """ + fixture = tmp_path / "fixture" + fixture.mkdir() + settings = fixture / "settings.json" + settings.write_text("{}", encoding="utf-8") + pin = fake_home / ".claude-account-2" + + proc = install("-SettingsPath", str(settings), pin=pin, home=fake_home) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "statusLine" in json.loads(settings.read_text(encoding="utf-8")) + assert not (pin / "settings.json").exists(), "the pin overrode an explicit -SettingsPath" + + +def test_no_pin_still_installs_into_the_default_config_dir(fake_home: Path) -> None: + """The negative arm: with no pin the shipped behaviour is restored exactly, so rule 5 is a + regression fence rather than a new claim.""" + proc = install(pin=None, home=fake_home) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (fake_home / ".claude" / "settings.json").exists() + + +def test_the_no_pin_arm_really_runs_without_a_pin_and_inside_the_fixture_home() -> None: + """A TEST ABOUT THE TESTS, and the one standing between this suite and the real account roots. + + Two ways the fixtures could be lying. First, ``CLAUDE_CONFIG_DIR`` is set in this very process on + the box these scripts were written for, so a child that merely inherits it would make every "no + pin" test exercise the pinned path. Second, ``-HomeDir`` has to be a real seam: measured, with + ``USERPROFILE`` overridden to ``C:/fake/home`` a child pwsh still reports + ``[Environment]::GetFolderPath('UserProfile') = C:\\Users\\Scott``, so a script that resolved home + itself could not be redirected — and ``-AllRoots`` would enumerate and WIRE the owner's live + account roots. + """ + probe = subprocess.run( [ "pwsh", "-NoProfile", "-NonInteractive", "-Command", - cmd.replace("-File $s", f"-File $s -StateDir '{state}'"), + "if ($env:CLAUDE_CONFIG_DIR) { 'PINNED:' + $env:CLAUDE_CONFIG_DIR } else { 'NOPIN' }", ], - input=payload, + capture_output=True, + text=True, + timeout=TIMEOUT, + check=True, + env=_env(None), + ) + assert probe.stdout.strip() == "NOPIN", ( + "the child inherited a pin, so every 'no pin' test in this file is testing the pinned path" + ) + # And the installer must accept -HomeDir at all, or the redirection above is decorative. + assert "-HomeDir" in INSTALL.read_text(encoding="utf-8") + + +def test_a_pin_naming_a_directory_that_does_not_exist_is_refused_not_created( + fake_home: Path, +) -> None: + """A typo'd CLAUDE_CONFIG_DIR must not manufacture a config root nothing can launch from. This + repo has already paid once for an installer writing into such a directory (BACKLOG #1024), and + ``New-Item -Force`` creates every missing parent, so the wrong reflex here is one character.""" + ghost = fake_home / ".claude-account-99" + proc = install(pin=ghost, home=fake_home) + assert proc.returncode == 2, proc.stdout + assert "CANNOT START" in proc.stdout + assert not ghost.exists(), "a nonexistent pin was created rather than refused" + + +# --- requirement 2: multi-root ------------------------------------------------------------------ + + +def test_all_roots_wires_the_account_roots_and_skips_the_look_alikes_and_the_default( + fake_home: Path, +) -> None: + """Requirement 2, the anchored predicate, and the deliberate exclusion of ``~/.claude``. + + NOT COVERED HERE, and stated rather than implied: the ``-Force`` on the directory glob. Its + absence is invisible on Windows (a dot-prefixed directory carries no hidden attribute) and + collapses the set to nothing on Linux. This module is skipif'd to ``os.name == "nt"``, so it can + never observe that failure; only a CI ubuntu leg could, and this file has none. + """ + proc = install("-AllRoots", pin=None, home=fake_home) + assert proc.returncode == 0, proc.stdout + proc.stderr + + assert (fake_home / ".claude-account-1" / "settings.json").exists() + assert (fake_home / ".claude-account-2" / "settings.json").exists() + # The three that must be left alone, each for a different reason. + assert not (fake_home / ".claude" / "settings.json").exists(), ( + "-AllRoots wired the default root" + ) + assert not (fake_home / ".claude-desktop-1" / "settings.json").exists(), ( + "a .claude-desktop dir was wired -- the 'has a .claude.json' filter would do this" + ) + lock = json.loads((fake_home / ".claude-account-2.lock" / "settings.json").read_text("utf-8")) + assert lock == {}, "the .lock look-alike was wired -- the anchors are not holding" + + +def test_all_roots_on_a_home_with_no_account_roots_touches_nothing_and_says_so( + tmp_path: Path, +) -> None: + """The removed empty-set fallback. Seeding ``~/.claude`` when the glob finds nothing would make + this guard dead code AND manufacture a target that by definition does not exist.""" + empty = tmp_path / "empty-home" + empty.mkdir() + proc = install("-AllRoots", pin=None, home=empty) + assert proc.returncode == 2, proc.stdout + assert "no account config root found" in proc.stdout + assert not (empty / ".claude").exists() + + +def test_each_root_is_wired_to_its_own_publish_path_and_they_do_not_bleed( + fake_home: Path, +) -> None: + """THE PARTITION ITSELF. Two roots, two publish paths, and neither names the other's. + + Reverting ``Get-UsageStateDir`` to a single user-level literal makes both wired commands name one + path, and the second assertion fails. + """ + assert install("-AllRoots", pin=None, home=fake_home).returncode == 0 + a = wired(fake_home / ".claude-account-1" / "settings.json") + b = wired(fake_home / ".claude-account-2" / "settings.json") + pa = str(fake_home / ".claude-account-1" / "mefor-usage") + pb = str(fake_home / ".claude-account-2" / "mefor-usage") + assert f"$d = '{pa}'" in a + assert f"$d = '{pb}'" in b + assert pb not in a and pa not in b, "one root's wired command names another root's publish path" + + +def test_a_root_with_a_foreign_statusline_is_refused_without_abandoning_the_others( + fake_home: Path, +) -> None: + """The refusal survives multi-root: PER ROOT, and it does not abort the run. + + A foreign statusLine in one root must not cost the other roots their wiring — an installer that + stops at the first refusal would leave a partially wired box and a tally that says so only if you + read it closely, which is why the exit code for that state is 3 and not 0 or 1. + """ + (fake_home / ".claude-account-1" / "settings.json").write_text( + json.dumps({"statusLine": {"type": "command", "command": "my-own-thing"}}), encoding="utf-8" + ) + proc = install("-AllRoots", pin=None, home=fake_home) + assert proc.returncode == 3, f"partial outcome must be exit 3: {proc.stdout}" + assert "REFUSING" in proc.stdout + assert (fake_home / ".claude-account-2" / "settings.json").exists(), ( + "one refusal abandoned the remaining roots" + ) + kept = json.loads((fake_home / ".claude-account-1" / "settings.json").read_text("utf-8")) + assert kept["statusLine"]["command"] == "my-own-thing" + + +def test_a_foreign_statusline_that_merely_mentions_the_marker_is_still_refused( + tmp_path: Path, +) -> None: + """The ownership test had to stop being a substring match when the publish path went INTO the + command. The wired command now contains ``\\mefor-usage`` inside its ``-StateDir`` argument, so + ``command -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.""" + settings = tmp_path / "settings.json" + theirs = "echo ~/.claude/mefor-usage/latest.json" + settings.write_text( + json.dumps({"statusLine": {"type": "command", "command": theirs}}), encoding="utf-8" + ) + proc = install("-SettingsPath", str(settings), pin=None) + assert proc.returncode == 1 + assert "REFUSING" in proc.stdout + assert json.loads(settings.read_text(encoding="utf-8"))["statusLine"]["command"] == theirs + + +def test_a_statusline_with_an_empty_command_is_treated_as_absent_not_foreign( + tmp_path: Path, +) -> None: + """A state that would otherwise have no exit. Classified FOREIGN, this root could never be wired: + the refusal would print with nothing after the colon (which reads as truncated output, not as a + finding) and offer a remedy — "merge the two commands by hand" — naming a command that does not + exist.""" + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"statusLine": {"type": "command", "command": ""}}), "utf-8") + proc = install("-SettingsPath", str(settings), pin=None) + assert proc.returncode == 0, proc.stdout + assert "REFUSING" not in proc.stdout + assert "mefor-usage" in wired(settings) + + +# --- requirement 3: the message must not claim more than it did --------------------------------- + + +def test_the_success_message_names_the_file_it_wrote(fake_home: Path) -> None: + """Requirement 3, positive half. A truthful message would have surfaced this defect on the first + run instead of costing a debugging round.""" + pin = fake_home / ".claude-account-1" + proc = install(pin=pin, home=fake_home) + assert str(pin / "settings.json") in proc.stdout, ( + f"the success output does not name the file it wrote: {proc.stdout}" + ) + + +def test_a_single_root_install_does_not_claim_every_session_on_this_machine( + fake_home: Path, +) -> None: + """Requirement 3, negative half, and the exact sentence that was false. + + A completeness claim is a liability (CLAUDE.md section 11 / SDS-3.6). The phrase is deleted rather + than conditioned, because naming the files is shorter AND true. + """ + out = install(pin=fake_home / ".claude-account-1", home=fake_home).stdout + assert "every session on this machine" not in out + # And no line that PRINTS can carry it either — the .DESCRIPTION quotes the old claim on purpose, + # as the explanation of the defect, so a plain "not in the file" check would forbid the history. + printing = [ + ln + for ln in INSTALL.read_text(encoding="utf-8").splitlines() + if ("Write-Host" in ln or "Write-Output" in ln) and "every session on this machine" in ln + ] + assert not printing, f"the claim is still printed: {printing}" + + +def test_no_line_claims_a_publish_that_has_not_happened(fake_home: Path) -> None: + """The present-tense overclaim. Writing a settings key is not publishing, and nothing on this box + had ever published when the defect was found — so a line reading "publishes to" would assert + something no check in the script supports.""" + out = install(pin=fake_home / ".claude-account-1", home=fake_home).stdout + assert "wired to publish to" in out + assert "nothing has published there yet" in out + assert "publishes to " not in out + + +def test_all_roots_names_each_root_it_wrote_and_the_tally_agrees(fake_home: Path) -> None: + """Requirement 3 under multi-root, where a bare count is most tempting and least useful. + + The lines and the tally are the same counter, so they cannot disagree — the "summary says N while + the lines say M" defect is structurally excluded rather than asserted away. + """ + out = install("-AllRoots", pin=None, home=fake_home).stdout + named = [ln for ln in out.splitlines() if ln.strip().startswith("WROTE")] + assert len(named) == 2, out + assert str(fake_home / ".claude-account-1" / "settings.json") in out + assert str(fake_home / ".claude-account-2" / "settings.json") in out + assert "wrote: 2" in out + assert "Roots examined: 2" in out + + +def test_whatif_enumerates_every_target_and_exits_zero(fake_home: Path) -> None: + """A dry run must be safe to run and must not report failure. Under ``-WhatIf`` ShouldProcess + returns false for every root, so a purely tally-driven exit rule would return 1 — a dry run + reporting total failure, which is the summary-contradicts-the-lines defect inverted.""" + proc = install("-AllRoots", "-WhatIf", pin=None, home=fake_home) + assert proc.returncode == 0, proc.stdout + assert proc.stdout.count("WOULD WRITE") == 2 + assert "would write: 2" in proc.stdout + assert not (fake_home / ".claude-account-1" / "settings.json").exists() + + +def test_a_settings_file_too_deep_to_serialise_is_failed_not_silently_truncated( + tmp_path: Path, +) -> None: + """The guard a parse-back check cannot provide, and the draft of this design assumed it could. + + Measured with a 24-level document: ``ConvertTo-Json -Depth 20`` emits a truncation warning AND THE + TRUNCATED TEXT STILL PARSES BACK CLEANLY, with the deep node replaced by its type name as a + string. So one ``-AllRoots`` run would quietly truncate up to five live account roots and count + every one as written. + """ + settings = tmp_path / "settings.json" + deep: dict[str, Any] = {"leaf": 1} + for _ in range(24): + deep = {"a": deep} + original = json.dumps(deep) + settings.write_text(original, encoding="utf-8") + + proc = install("-SettingsPath", str(settings), pin=None) + assert proc.returncode == 1, proc.stdout + assert "TRUNCATED" in proc.stdout + assert settings.read_text(encoding="utf-8") == original, "the file was rewritten anyway" + + +# --- -Status and -Uninstall --------------------------------------------------------------------- + + +def test_status_under_a_pin_does_not_report_the_default_root_as_configured( + fake_home: Path, +) -> None: + """The disagreement, from the other side. ``-Status`` used to read the default root's settings + while every session read a pinned root — so it reported CONFIGURED about a file no session on the + box loads. This is the test whose failure message a person would recognise as their own problem.""" + (fake_home / ".claude" / "settings.json").write_text( + json.dumps({"statusLine": {"type": "command", "command": "# mefor-usage\n$s = 'x'"}}), + encoding="utf-8", + ) + pin = fake_home / ".claude-account-1" + proc = install("-Status", pin=pin, home=fake_home, collector=None) + assert str(pin / "settings.json") in proc.stdout + assert proc.returncode == 1, "an unwired pinned root must not report success" + + +def test_status_reads_the_publish_path_out_of_the_wired_command_not_a_recomputed_one( + fake_home: Path, tmp_path: Path +) -> None: + """The SDS-3.8 defect: the shipped ``-Status`` reported "script exists" against a path THAT + INVOCATION had just resolved from git, so a root wired from a checkout since deleted still + reported True. Across roots wired at different times from different checkouts, one recomputed line + describes none of them.""" + pin = fake_home / ".claude-account-1" + elsewhere = tmp_path / "some-other-root" / "mefor-usage" + (pin / "settings.json").write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": f"# mefor-usage\n$s = 'c.ps1'; $d = '{elsewhere}'; x", + } + } + ), + encoding="utf-8", + ) + proc = install("-Status", pin=pin, home=fake_home, collector=None) + assert str(elsewhere) in proc.stdout, "-Status recomputed the path instead of reading it back" + assert "ELSEWHERE" in proc.stdout + assert proc.returncode == 1 + + +def test_status_reports_a_legacy_command_as_its_own_state(fake_home: Path) -> None: + """A command written before publish paths were per-root — including every one the out-of-repo + propagate stopgap copied — names no ``-StateDir`` at all. Folding that into WIRED is how it goes + silent; where it publishes then depends on the collector's run-time fallback.""" + pin = fake_home / ".claude-account-1" + (pin / "settings.json").write_text( + json.dumps( + {"statusLine": {"type": "command", "command": "# mefor-usage\n$s = 'c.ps1'; x"}} + ), + encoding="utf-8", + ) + proc = install("-Status", pin=pin, home=fake_home, collector=None) + assert "legacy command, no -StateDir" in proc.stdout + + +def test_uninstall_names_the_roots_it_actually_removed_from(fake_home: Path) -> None: + """The mirror-image lie, and worse than the install one because the operator believes they turned + something off. A single-root ``-Uninstall`` under a pin used to strip one root, print REMOVED, exit + 0 — and leave every other root wired and still publishing.""" + assert install("-AllRoots", pin=None, home=fake_home).returncode == 0 + proc = install("-AllRoots", "-Uninstall", pin=None, home=fake_home, collector=None) + assert proc.returncode == 0, proc.stdout + assert str(fake_home / ".claude-account-1" / "settings.json") in proc.stdout + assert str(fake_home / ".claude-account-2" / "settings.json") in proc.stdout + assert "removed: 2" in proc.stdout + for n in (".claude-account-1", ".claude-account-2"): + assert "statusLine" not in json.loads( + (fake_home / n / "settings.json").read_text(encoding="utf-8") + ) + + +# --- the reader half ---------------------------------------------------------------------------- + + +def test_the_reader_defaults_to_its_own_config_roots_state_dir(fake_home: Path) -> None: + """Publisher and reader now derive the path from ONE function. They used to agree only because two + separate string literals happened to match, which is agreement by luck, not by construction.""" + pin = fake_home / ".claude-account-1" + # WIRE IT FIRST. A root holding a fresh document but carrying no statusLine is a state that cannot + # occur -- something published there, so something was wired -- and since the wiring diagnosis now + # reaches the verdict, that contradiction would make this test assert OK over a root the script + # itself calls unwired. + assert install("-ConfigDir", str(pin), pin=None, home=fake_home).returncode == 0 + state = pin / "mefor-usage" + state.mkdir(exist_ok=True) + now = datetime.now(UTC).isoformat() + (state / "latest.json").write_text( + json.dumps( + { + "captured_at": now, + "five_hour": { + "used_percentage": 12.0, + "resets_at_epoch": int(time.time()) + 3600, + "captured_at": now, + }, + "seven_day": { + "used_percentage": 30.0, + "resets_at_epoch": int(time.time()) + 280000, + "captured_at": now, + }, + } + ), + encoding="utf-8", + ) + code, doc, _ = reader("-Json", pin=pin, home=fake_home) + # Both windows are fresh, so the verdict is a real OK rather than the UNKNOWN that a partly + # published document would give — which would let this pass while reading the wrong directory. + assert code == OK, doc + assert doc["config_root"].lower() == str(pin).lower() + assert doc["state_dir"].lower() == str(state).lower() + assert doc["five_hour"]["used_percentage"] == pytest.approx(12.0) + + +def test_the_no_data_error_diagnoses_which_state_this_root_is_in(fake_home: Path) -> None: + """Requirement 4. Five states with five different fixes must not share one message. + + The old message said "not installed or has not run yet" and printed the bare installer command + with NO root — so following the reader's own advice re-ran the exact invocation that produced the + false INSTALLED claim. + """ + pin = fake_home / ".claude-account-1" + + # (a) no settings.json at all + code, doc, out = reader("-Json", pin=pin, home=fake_home) + assert code == UNKNOWN + assert doc["statusline_state"] == "NOT_WIRED_NO_SETTINGS" + assert doc["config_root"].lower() == str(pin).lower() + + # (b) settings.json with no statusLine + (pin / "settings.json").write_text("{}", encoding="utf-8") + _, doc, _ = reader("-Json", pin=pin, home=fake_home) + assert doc["statusline_state"] == "NOT_WIRED_NO_STATUSLINE" + + # (c) somebody else's statusLine + (pin / "settings.json").write_text( + json.dumps({"statusLine": {"type": "command", "command": "theirs"}}), encoding="utf-8" + ) + _, doc, out = reader("-Json", pin=pin, home=fake_home) + assert doc["statusline_state"] == "FOREIGN_STATUSLINE" + + # (d) unreadable. The installer REFUSES this root (exit 1) rather than rewriting a file it could + # not parse, which is why the fixture is reset before arm (e): a bad write here would silently + # disable every setting in the file, not just this one. + (pin / "settings.json").write_text("{ not json", encoding="utf-8") + _, doc, _ = reader("-Json", pin=pin, home=fake_home) + assert doc["statusline_state"] == "SETTINGS_UNREADABLE" + assert install("-ConfigDir", str(pin), pin=None, home=fake_home).returncode == 1 + + # (e) ours, and pointing where this reader looks -- so the fix really is "start a new session" + (pin / "settings.json").write_text("{}", encoding="utf-8") + assert install("-ConfigDir", str(pin), pin=None, home=fake_home).returncode == 0 + _, doc, out = reader("-Json", pin=pin, home=fake_home) + assert doc["statusline_state"] == "WIRED_HERE" + _, _, human = reader(pin=pin, home=fake_home) + assert "Start a NEW session" in human + # Every remedy must name the root, or it repeats the original defect. + assert str(pin) in human + + +def test_the_no_data_error_says_so_when_a_root_publishes_somewhere_else( + fake_home: Path, tmp_path: Path +) -> None: + """THE ARM THE WHOLE DIAGNOSIS EXISTS FOR. Without it the reader tells the operator to restart and + wait, forever, with nothing anywhere saying the two halves disagree — the original defect + relocated rather than fixed.""" + pin = fake_home / ".claude-account-1" + other = tmp_path / "other-root" / "mefor-usage" + (pin / "settings.json").write_text( + json.dumps( + {"statusLine": {"type": "command", "command": f"# mefor-usage\n$s='c'; $d = '{other}'"}} + ), + encoding="utf-8", + ) + code, doc, _ = reader("-Json", pin=pin, home=fake_home) + assert code == UNKNOWN + assert doc["statusline_state"] == "WIRED_ELSEWHERE" + assert doc["wired_state_dir"].lower() == str(other).lower() + # THE VERDICT, NOT ONLY THE PROSE. The diagnosis used to reach the printed warning and stop there, + # so a root the script itself called mis-wired still exited 0 and reported state=OK beside + # statusline_state=WIRED_ELSEWHERE in one document -- two instruments disagreeing inside one run. + assert doc["state"] == "UNKNOWN", doc + hcode, _, human = reader(pin=pin, home=fake_home) + assert hcode == UNKNOWN, f"mis-wired root exited {hcode}" + assert "WAITING WILL NOT FIX THIS" in human + assert "-- OK" not in human, f"the heading claimed OK over a leftover: {human}" + + +def test_a_document_stamped_with_another_config_root_is_refused(tmp_path: Path) -> None: + """THE TRIPWIRE. A layout mistake becomes loud instead of plausible. + + It gates on ``config_root_env`` — the ambient pin, recorded live — and NOT on ``config_root``, + which is derived from the write path and therefore agrees with it by construction. A stamp derived + from where we wrote can detect nothing. + """ + state = tmp_path / "acct-a" / "mefor-usage" + state.mkdir(parents=True) + (state / "latest.json").write_text( + json.dumps( + { + "captured_at": datetime.now(UTC).isoformat(), + "published_by": {"config_root_env": str(tmp_path / "acct-b")}, + "five_hour": { + "used_percentage": 99.0, + "resets_at_epoch": int(time.time()) + 3600, + "captured_at": datetime.now(UTC).isoformat(), + }, + } + ), + encoding="utf-8", + ) + code, doc, _ = reader("-StateDir", str(state), "-Json", pin=None) + assert code == UNKNOWN + assert doc["provenance"] == "FOREIGN" + assert "another account's headroom" in doc["reason"] + + +def test_a_document_with_no_stamp_is_read_and_says_the_guard_could_not_run( + tmp_path: Path, +) -> None: + """Absence is UNVERIFIABLE provenance, not WRONG provenance — two different facts, and only one is + an error. Refusing on absence would also break every hand-written fixture in this file and every + document written before the stamp existed, for no gain.""" + collect(tmp_path, {"session_id": "x", "rate_limits": {"five_hour": window(40.0, 3600)}}) + doc = latest(tmp_path) + del doc["published_by"]["config_root_env"] + (tmp_path / "latest.json").write_text(json.dumps(doc), encoding="utf-8") + + code, parsed, _ = reader("-StateDir", str(tmp_path), "-Json", pin=None) + assert parsed["provenance"] == "UNVERIFIED" + assert parsed["five_hour"]["used_percentage"] == pytest.approx(40.0), ( + "an unstamped reading must still be read" + ) + _, _, human = reader("-StateDir", str(tmp_path), pin=None) + assert "provenance: UNVERIFIED" in human + + +def test_an_explicit_state_dir_is_not_refused_for_being_another_root(tmp_path: Path) -> None: + """The refusal must fire on ERROR, not on INTENT. + + It compares the stamp against the READ-FROM root — the root the document sits under — and not + against the reader's own. Comparing against the reader's root would break the documented + cross-root peek (``usage.ps1 -StateDir \\mefor-usage``, the only way to look at + another account) and would make every row of the ``-AllRoots`` survey but one render as a refusal. + """ + other = tmp_path / "acct-b" + state = other / "mefor-usage" + state.mkdir(parents=True) + (state / "latest.json").write_text( + json.dumps( + { + "captured_at": datetime.now(UTC).isoformat(), + "published_by": {"config_root_env": str(other)}, + "five_hour": { + "used_percentage": 33.0, + "resets_at_epoch": int(time.time()) + 3600, + "captured_at": datetime.now(UTC).isoformat(), + }, + } + ), + encoding="utf-8", + ) + # Read it from a session pinned somewhere else entirely. + _, doc, _ = reader("-StateDir", str(state), "-Json", pin=tmp_path / "acct-a") + assert doc["provenance"] == "OK", doc + assert doc["five_hour"]["used_percentage"] == pytest.approx(33.0) + + +def test_the_survey_lists_every_root_and_computes_nothing_across_them(fake_home: Path) -> None: + """A SURVEY, NEVER A MERGE. These are different accounts with different pools; summing, averaging + or taking a worst-of across them would rebuild the exact lie the partitioning removes.""" + for name, pct in ((".claude-account-1", 10.0), (".claude-account-2", 90.0)): + st = fake_home / name / "mefor-usage" + st.mkdir() + (st / "latest.json").write_text( + json.dumps( + { + "captured_at": datetime.now(UTC).isoformat(), + "published_by": {"config_root_env": str(fake_home / name)}, + "five_hour": { + "used_percentage": pct, + "resets_at_epoch": int(time.time()) + 3600, + "captured_at": datetime.now(UTC).isoformat(), + }, + } + ), + encoding="utf-8", + ) + pin = fake_home / ".claude-account-1" + code, doc, _ = reader("-AllRoots", "-Json", pin=pin, home=fake_home) + rows = {r["root"].lower(): r for r in doc["roots"]} + assert str(fake_home / ".claude-account-1").lower() in rows + assert str(fake_home / ".claude-account-2").lower() in rows + assert str(fake_home / ".claude").lower() in rows, ( + "the survey must list every root, not only ours" + ) + assert rows[str(fake_home / ".claude-account-2").lower()]["five"] == pytest.approx(90.0) + # The exit code is THIS session's verdict, not a roll-up across accounts. + assert doc["five_hour"]["used_percentage"] == pytest.approx(10.0) + assert doc["exit_code"] == code + + +# --- the collector half, and the shared rule ---------------------------------------------------- + + +def test_the_collector_publishes_under_its_pinned_root_when_given_no_state_dir( + fake_home: Path, +) -> None: + """The other end of the shared derivation. A LEGACY wired command passes no ``-StateDir``, so this + is the path such a root actually takes at run time.""" + pin = fake_home / ".claude-account-2" + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(COLLECT), + "-HomeDir", + str(fake_home), + ], + input=json.dumps({"session_id": "p", "rate_limits": {"five_hour": window(21.0, 3600)}}), capture_output=True, text=True, timeout=TIMEOUT, check=False, + env=_env(pin), ) assert proc.returncode == 0, proc.stderr - assert "55" in proc.stdout, f"the wired command produced no reading: {proc.stdout!r}" - assert (state / "latest.json").exists(), "the wired command ran but published nothing" + assert (pin / "mefor-usage" / "latest.json").exists() + assert not (fake_home / ".claude" / "mefor-usage").exists() + + +def test_the_collector_never_manufactures_a_config_root(fake_home: Path) -> None: + """The installer refuses to create a root; the collector must not disagree about the same input. + + ``New-Item -ItemType Directory -Force`` creates every missing ANCESTOR (measured), so without an + explicit guard a typo'd or stale ``CLAUDE_CONFIG_DIR`` would have a live session build a directory + nothing can launch from — on every statusLine fire. + """ + ghost = fake_home / ".claude-account-99" + payload = json.dumps({"session_id": "g", "rate_limits": {"five_hour": window(5.0, 3600)}}) + for args, pin in ( + ([], ghost), # resolved from the pin + (["-StateDir", str(ghost / "mefor-usage")], None), # named explicitly + ): + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(COLLECT), + "-HomeDir", + str(fake_home), + *args, + ], + input=payload, + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + env=_env(pin), + ) + assert proc.returncode == 0, proc.stderr + assert "no config root to publish to" in proc.stdout + assert not ghost.exists(), f"the collector created {ghost} (args={args})" + + +def test_the_collector_still_publishes_when_the_shared_library_is_missing( + fake_home: Path, tmp_path: Path +) -> None: + """NEVER THROWS, NEVER BLOCKS outranks one-definition for a statusLine, so the collector keeps a + literal fallback copy of the two-line state-dir rule. This pins that the fallback both EXISTS and + AGREES with the shared function — drift goes red rather than silent.""" + isolated = tmp_path / "isolated" + isolated.mkdir() + shutil.copy(COLLECT, isolated / "usage-collect.ps1") + pin = fake_home / ".claude-account-1" + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(isolated / "usage-collect.ps1"), + "-HomeDir", + str(fake_home), + ], + input=json.dumps({"session_id": "iso", "rate_limits": {"five_hour": window(7.0, 3600)}}), + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + env=_env(pin), + ) + assert proc.returncode == 0, proc.stderr + assert (pin / "mefor-usage" / "latest.json").exists(), ( + "the fallback state-dir rule disagrees with Get-UsageStateDir" + ) + + +def test_the_reader_refuses_to_diagnose_when_the_shared_library_is_missing( + tmp_path: Path, +) -> None: + """THE LOUD FLOOR, and the reader needs it more than the collector does. + + ``usage.ps1`` runs under ``SilentlyContinue``, which converts a dot-source failure into a WRONG + ANSWER rather than an error: the missing functions are swallowed, ``$StateDir`` stays null, + ``Join-Path $null "latest.json"`` yields the empty string, and the script would print "Nothing has + published to ." and then diagnose ``\\settings.json``. Testing the resolved PATH is not enough — + an explicit ``-StateDir`` resolves it without the library while every downstream check still comes + from there. + """ + isolated = tmp_path / "isolated" + isolated.mkdir() + shutil.copy(READ, isolated / "usage.ps1") + code, _, out = reader("-StateDir", str(tmp_path), pin=None, script=isolated / "usage.ps1") + assert code == UNKNOWN + assert "config-roots.ps1 did not load" in out + + +def test_config_roots_ps1_keeps_the_contract_three_scripts_depend_on(tmp_path: Path) -> None: + """FOUR CONSTRAINTS ON A FILE THAT IS DOT-SOURCED INTO A STATUSLINE, made executable. + + Dot-sourcing runs in the CALLER's scope, so anything at top level here happens to them. A comment + cannot enforce that, and the collector is bound by NEVER THROWS, NEVER BLOCKS. + """ + text = CONFIG_ROOTS.read_text(encoding="utf-8") + # BOTH comment forms, and the block form is the one that matters: this file's own header explains + # the four constraints, so a line-only stripper would find every forbidden token inside the prose + # that forbids it and fail on the documentation rather than on the code. + code_lines: list[str] = [] + in_block = False + for ln in text.splitlines(): + s = ln.strip() + if in_block: + if "#>" in s: + in_block = False + continue + if s.startswith("<#"): + in_block = "#>" not in s + continue + if s and not s.startswith("#"): + code_lines.append(ln) + body = "\n".join(code_lines) + assert "$ErrorActionPreference" not in body, ( + "assigning a preference variable would override the caller's and let a statusLine throw" + ) + # A top-level param() would consume the caller's own arguments. Any param( here must be indented + # inside a function. + assert not any(ln.startswith("param(") for ln in code_lines), "top-level param() block" + assert "$env:USERPROFILE" not in body, ( + "resolving home inside the library defeats the -HomeDir seam every test depends on" + ) + # Loading it must produce no output and no side effects. + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + f". '{CONFIG_ROOTS}'; Write-Output 'LOADED'", + ], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "LOADED", f"loading it emitted output: {proc.stdout!r}" + + +def test_the_root_list_survives_being_wrapped_in_an_array_at_one_element( + tmp_path: Path, +) -> None: + """A one-element result must arrive as ONE STRING, not as a nested array. + + This is not hypothetical: an earlier draft returned ``,@(...)`` — correct for the HashSet in + ``install-gate.ps1``, wrong for an array — and ``@(Get-LaunchableConfigRoots ...)`` then produced a + single element that WAS the array. Its string form is every path joined by a space, so + ``-AllRoots`` resolved one bogus target named `` C:\\...\\\\settings.json`` and + reported "Roots examined: 1". Silent, and it survived a smoke test because ``Split-Path`` happens + to accept arrays. + """ + home = tmp_path / "h" + (home / ".claude-account-3").mkdir(parents=True) + script = ( + f". '{CONFIG_ROOTS}'; " + f"$r = @(Get-LaunchableConfigRoots -HomeDir '{home}'); " + "Write-Output $r.Count; Write-Output $r[0].GetType().Name" + ) + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=True, + ) + assert proc.stdout.split() == ["1", "String"], proc.stdout + + +def test_install_then_the_wired_command_then_the_reader_all_agree(fake_home: Path) -> None: + """THE ANCHOR TEST: the one path that exercises the whole partition end to end. + + Every other test in this section checks one hop. This one installs into a pinned root, runs the + string the installer actually wired exactly as Claude Code would, and then reads it back the way a + session in that root would — with nothing recomputed by the test. A hand-written stamp would let + this pass whether or not the collector produces one. + """ + pin = fake_home / ".claude-account-1" + assert install(pin=pin, home=fake_home).returncode == 0 + + cmd = wired(pin / "settings.json") + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", cmd], + # BOTH windows, deliberately: with only five_hour the seven_day window is legitimately + # "never published", the overall verdict is UNKNOWN, and this test would be asserting the + # partition works by reading a code that means "I could not tell". + input=json.dumps( + { + "session_id": "e2e", + "rate_limits": {"five_hour": window(64.0, 3600), "seven_day": window(31.0, 280000)}, + } + ), + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + env=_env(pin), + ) + assert proc.returncode == 0, proc.stderr + assert "64" in proc.stdout + + code, doc, _ = reader("-Json", pin=pin, home=fake_home) + assert code == OK, doc + assert doc["five_hour"]["used_percentage"] == pytest.approx(64.0) + assert doc["provenance"] == "OK", ( + "the collector did not stamp the pin, so the cross-root guard cannot fire" + ) + assert doc["config_root"].lower() == str(pin).lower() + # And the second root sees nothing, which is the whole point of partitioning. + code2, doc2, _ = reader("-Json", pin=fake_home / ".claude-account-2", home=fake_home) + assert code2 == UNKNOWN + assert doc2["reason"] == "no data" + + +# --- the predicate now exists in four places, so pin them against each other ------------------- + +COORD_INSTALL = ROOT / "scripts" / "coord" / "install-coordination.ps1" +GATE_INSTALL = ROOT / "scripts" / "worktree" / "install-gate.ps1" + +#: Names that actually occur, or plausibly could, under a home directory on a box running several +#: Claude logins. Each entry is (name, is_a_launchable_account_root). +_ROOT_NAMES = [ + (".claude-account-1", True), + (".claude-account-42", True), + (".claude-account-2.lock", False), # a real directory on this box; carries a settings.json + (".claude-account-", False), + (".claude-account-2b", False), + (".claude-desktop-1", False), # carries a .claude.json; nothing launches from it + (".claude-hooks", False), + (".claude-tools", False), + (".claudex", False), +] + + +def test_the_shared_predicate_matches_the_one_install_gate_and_its_python_twin_use() -> None: + """FOUR COPIES OF ONE RULE, AND THIS IS WHAT KEEPS THEM HONEST. + + ``config-roots.ps1`` was added so ``install-usage-statusline.ps1`` would not write a fourth. It + could not simply absorb the other three: ``install-gate.ps1`` pairs its copy with a deliberately + WIDER independent audit population that must not be selected by the same 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 instead of one definition, the rule is one BEHAVIOUR, asserted here across every copy. A future + edit to any of them turns this red instead of going silent — which is the outcome SDS-3.5 is + actually after. + """ + script = "; ".join( + [ + f". '{CONFIG_ROOTS}'", + "$g = [regex]'\\A\\.claude-account-\\d+\\z'", # install-gate.ps1:114, quoted + "foreach ($n in @(" + ",".join(f"'{n}'" for n, _ in _ROOT_NAMES) + ")) { " + "$mine = $script:ClaudeAccountRootName.IsMatch($n); " + "$gate = $g.IsMatch($n); " + "$coord = $n -match '^\\.claude$|^\\.claude-account-\\d+$'; " + 'Write-Output "$n $mine $gate $coord" }', + ] + ) + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=True, + ) + seen: dict[str, tuple[bool, bool, bool]] = {} + for line in proc.stdout.strip().splitlines(): + dir_name, a, b, c = line.split() + seen[dir_name] = (a == "True", b == "True", c == "True") + + for name, expected in _ROOT_NAMES: + mine, gate, coord = seen[name] + assert mine == expected, ( + f"config-roots.ps1 classifies {name} as {mine}, expected {expected}" + ) + assert gate == expected, f"install-gate.ps1's copy disagrees on {name}" + assert coord == expected, f"install-coordination.ps1's copy disagrees on {name}" + + # The literal is quoted above rather than parsed out of install-gate.ps1, so assert it is still + # the literal that file carries — otherwise this test pins a string nobody uses. + assert "[regex]'\\A\\.claude-account-\\d+\\z'" in GATE_INSTALL.read_text(encoding="utf-8") + assert "'^\\.claude$|^\\.claude-account-\\d+$'" in COORD_INSTALL.read_text(encoding="utf-8") + + +def test_the_one_place_the_copies_disagree_is_named_rather_than_discovered() -> None: + """CASE. ``install-coordination.ps1`` uses ``-match``, which is case-INSENSITIVE by default; + ``[regex]::IsMatch`` is case-SENSITIVE. A ``.Claude-Account-2`` is creatable on Windows and would + be accepted by one copy and rejected by the other. + + No such directory exists on the box this was measured on, so nothing differs today. It is asserted + here anyway, because a difference that is written down is a decision and a difference that is only + latent is a trap — and the ``-Status`` audit in ``install-usage-statusline.ps1`` reports any + ``~/.claude*`` directory carrying a settings.json that the predicate rejected, which is what makes + this under-reach loud rather than silent. + """ + script = ( + f". '{CONFIG_ROOTS}'; " + "$n = '.Claude-Account-2'; " + "Write-Output $script:ClaudeAccountRootName.IsMatch($n); " + "Write-Output ($n -match '^\\.claude$|^\\.claude-account-\\d+$')" + ) + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=True, + ) + strict, loose = proc.stdout.split() + assert strict == "False", "the shared predicate stopped being case-sensitive" + assert loose == "True", "install-coordination.ps1's -match stopped being case-insensitive" + + +# --- three defects the adversarial review found in the first pass ------------------------------- + + +def test_uninstall_whatif_does_not_claim_it_removed_anything(fake_home: Path) -> None: + """A DRY RUN THAT REPORTS SUCCESS IS THE SAME LIE, POINTING THE OTHER WAY. + + ``$removed++`` sat 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 all five roots keep publishing — and a coordinator branching + on the documented exit codes gets 0, which the header defines as every root reaching the desired + state. It is worse than the install claim this change deletes, because a person believes they + turned something OFF. + """ + assert install("-AllRoots", pin=None, home=fake_home).returncode == 0 + proc = install("-AllRoots", "-Uninstall", "-WhatIf", pin=None, home=fake_home, collector=None) + assert proc.returncode == 0, proc.stdout + assert "removed: 0" in proc.stdout, f"a dry run claimed removals: {proc.stdout}" + assert "would remove: 2" in proc.stdout + # And nothing was actually removed. + for n in (".claude-account-1", ".claude-account-2"): + assert "statusLine" in json.loads( + (fake_home / n / "settings.json").read_text(encoding="utf-8") + ) + + +def test_a_window_carried_from_another_root_is_refused_even_when_the_document_is_ours( + fake_home: Path, +) -> None: + """THE ONE-HOP LAUNDERING PATH, and the reason provenance travels with the WINDOW. + + A document-level stamp records who WROTE the file, not where the numbers in it came from, and the + carry-forward is exactly the hop that separates those. Root A publishes into root B's directory (a + legacy or hand-copied command). B's next session fires before its first API response, carries A's + percentages forward, and rebuilds ``published_by`` with B — so a document-level check compares B + against B, passes, and reports A's headroom as B's. The window kept its older ``captured_at`` and + would eventually have been called stale; it would never have been called FOREIGN. + + Driven through the real collector, twice, because a hand-written fixture would assert that the + fixture agrees with itself rather than that the carry-forward stamps what it should. + """ + a = fake_home / ".claude-account-1" + b = fake_home / ".claude-account-2" + state = b / "mefor-usage" # A publishes into B's directory + + def run(pin: Path, payload: dict[str, Any]) -> None: + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(COLLECT), + "-StateDir", + str(state), + "-HomeDir", + str(fake_home), + ], + input=json.dumps(payload), + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + env=_env(pin), + ) + assert proc.returncode == 0, proc.stderr + + run(a, {"session_id": "A", "rate_limits": {"five_hour": window(91.0, 3600)}}) + run(b, {"session_id": "B"}) # B has no rate_limits yet, so A's window is carried forward + + doc = latest(state) + assert doc["published_by"]["config_root_env"].lower() == str(b).lower(), ( + "the document should now be stamped by B -- that is what makes this laundering" + ) + assert doc["five_hour"]["config_root_env"].lower() == str(a).lower(), ( + "the carried window must keep A's stamp, exactly as it keeps A's captured_at" + ) + + code, parsed, _ = reader("-StateDir", str(state), "-Json", pin=b, home=fake_home) + assert code == UNKNOWN + assert parsed["five_hour"]["used_percentage"] is None, "A's 91% was reported as B's headroom" + assert "DIFFERENT config root" in parsed["five_hour"]["reason"] + + +def test_a_root_wired_elsewhere_is_flagged_even_when_it_still_has_an_old_reading( + fake_home: Path, tmp_path: Path +) -> None: + """WIRED_ELSEWHERE was unreachable in the one case where it misleads most. + + The diagnosis ran only inside the no-data branch. So a root re-wired to publish into a sibling, + but still holding its own older ``latest.json``, served that stale percentage as current for + twenty minutes and then said "no live session is publishing" — which is also false. The session is + publishing; it is publishing somewhere else, and nothing in the output said so. + """ + pin = fake_home / ".claude-account-1" + elsewhere = tmp_path / "sibling" / "mefor-usage" + (pin / "settings.json").write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": f"# mefor-usage\n$s='c'; $d = '{elsewhere}'", + } + } + ), + encoding="utf-8", + ) + # This root DOES have a reading of its own, so the no-data branch never runs. + state = pin / "mefor-usage" + state.mkdir() + now = datetime.now(UTC).isoformat() + (state / "latest.json").write_text( + json.dumps( + { + "captured_at": now, + "five_hour": { + "used_percentage": 20.0, + "resets_at_epoch": int(time.time()) + 3600, + "captured_at": now, + }, + "seven_day": { + "used_percentage": 20.0, + "resets_at_epoch": int(time.time()) + 280000, + "captured_at": now, + }, + } + ), + encoding="utf-8", + ) + + code, doc, human = reader(pin=pin, home=fake_home) + assert "WARNING" in human, f"a mis-wired root reported clean: {human}" + assert "WAITING WILL NOT FIX THIS" in human + jcode, parsed, _ = reader("-Json", pin=pin, home=fake_home) + assert parsed["statusline_state"] == "WIRED_ELSEWHERE" + assert parsed["wired_state_dir"].lower() == str(elsewhere).lower() + # THE VERDICT, NOT ONLY THE PROSE -- AND THIS IS THE TEST THAT CAN PIN IT. Both windows here are + # FRESH, so without the fix the verdict is a clean OK and exit 0 while the same document reports + # statusline_state=WIRED_ELSEWHERE and the same render prints a WARNING. Two instruments + # disagreeing inside one run, which is the defect this whole branch exists to remove. + # + # The assertion was first written into the no-data test next door, where it could not fail: that + # one exits 20 because there is nothing to read, whatever the diagnosis does. Caught by reverting + # the fix and watching the test stay green. + assert parsed["state"] == "UNKNOWN", parsed + assert jcode == UNKNOWN, f"mis-wired root with a fresh reading exited {jcode}" + assert code == UNKNOWN, f"mis-wired root with a fresh reading exited {code}" + assert "-- OK" not in human, f"the heading claimed OK over a leftover: {human}" + + +def test_a_root_that_has_no_settings_file_gets_no_backup_line(tmp_path: Path) -> None: + """A backup path printed for a file that was never copied sends an operator hunting for backups + that do not exist. Write-SettingsFile reports whether it took one; the caller prints accordingly.""" + fresh = tmp_path / "fresh" + fresh.mkdir() + out = install("-ConfigDir", str(fresh), pin=None).stdout + assert "no backup taken" in out + assert ".bak-usage-" not in out + assert not list(fresh.glob("*.bak-usage-*")) + + # And a root that DID have one says so, with a file that really exists. + out2 = install("-ConfigDir", str(fresh), "-RefreshInterval", "9000", pin=None).stdout + assert ".bak-usage-" in out2 + assert list(fresh.glob("*.bak-usage-*")) + + +def test_status_does_not_report_an_unparseable_root_as_carrying_nothing(fake_home: Path) -> None: + """A corrupt settings.json is not a clean one. It may carry a working statusLine this audit cannot + see, so "carries none" would steer an operator away from a stray publisher rather than towards it.""" + pin = fake_home / ".claude-account-1" + (pin / "settings.json").write_text("{ not json", encoding="utf-8") + out = install("-Status", pin=pin, home=fake_home, collector=None).stdout + assert "could not be parsed" in out + # The PER-ROOT line, not the audit's wording: the audit legitimately says "carries no statusLine of + # ours" about .claude-account-2.lock in the same output, so matching that phrase alone would pass + # or fail for the wrong directory. + assert "publishes to : nothing" not in out + + +def test_a_pin_naming_a_missing_directory_is_diagnosed_as_missing_not_unwired( + fake_home: Path, +) -> None: + """Two states with different fixes. Reported as "no settings.json", the remedy printed is an + installer invocation the installer REFUSES, so the operator follows the advice, gets a refusal, and + nothing has named the actual fault.""" + ghost = fake_home / ".claude-account-77" + _, doc, human = reader("-Json", pin=ghost, home=fake_home) + assert doc["statusline_state"] == "NO_SUCH_ROOT" + _, _, human = reader(pin=ghost, home=fake_home) + assert "NO SUCH DIRECTORY" in human + assert "Fix CLAUDE_CONFIG_DIR" in human + + +def test_the_survey_names_a_settings_bearing_root_its_own_predicate_rejected( + fake_home: Path, +) -> None: + """The survey enumerates by an anchored, case-sensitive name shape. Without a second pass selected + by a DIFFERENT rule, it can only ever confirm its own predicate — and a root the predicate rejects + while it burns quota would be absent from a list the operator reads as complete.""" + # .claude-account-2.lock carries a settings.json and is rejected by the name predicate. + now = datetime.now(UTC).isoformat() + state = fake_home / ".claude-account-1" / "mefor-usage" + state.mkdir() + (state / "latest.json").write_text( + json.dumps( + { + "captured_at": now, + "five_hour": { + "used_percentage": 5.0, + "resets_at_epoch": int(time.time()) + 3600, + "captured_at": now, + }, + } + ), + encoding="utf-8", + ) + _, _, human = reader("-AllRoots", pin=fake_home / ".claude-account-1", home=fake_home) + assert "not surveyed" in human, f"the audit pass did not run: {human}" + assert ".claude-account-2.lock" in human + # And the heading must not claim completeness it cannot deliver. + assert "Every config root on this box" not in human + + +def test_a_correctly_wired_root_that_stopped_publishing_still_warns(fake_home: Path) -> None: + """TONIGHT'S ACTUAL FAILURE, and the arm the mis-wire warning cannot reach. + + Every root on the box this was written for is wired correctly, names a collector that exists, and + has published NOTHING for the better part of an hour across nine live sessions. A healthy root + reports WIRED_HERE, which the mis-wire warning deliberately excludes — so without a second arm this + case falls through to a bare "reading is N min old", the line a reader skims past. + + The earlier message said "-- no live session is publishing", which at least signalled that + something was wrong. Deleting it was right (nothing checked it, and it is false when a session is + publishing into another root) but it left this case with no signal at all. Caught by the Steward + seat, whose whole concern is that a quiet plausible stale reading is more dangerous than a loud + failure. + """ + pin = fake_home / ".claude-account-1" + assert install("-ConfigDir", str(pin), pin=None, home=fake_home).returncode == 0 + + state = pin / "mefor-usage" + state.mkdir(exist_ok=True) + old = (datetime.now(UTC) - timedelta(minutes=48)).isoformat() + (state / "latest.json").write_text( + json.dumps( + { + "captured_at": old, + "five_hour": { + "used_percentage": 64.0, + "resets_at_epoch": int(time.time()) + 780, + "captured_at": old, + }, + "seven_day": { + "used_percentage": 31.0, + "resets_at_epoch": int(time.time()) + 280000, + "captured_at": old, + }, + } + ), + encoding="utf-8", + ) + + code, doc, human = reader(pin=pin, home=fake_home) + assert code == UNKNOWN + assert "WARNING" in human, f"a wired-but-silent root produced no warning:\n{human}" + assert "wired correctly" in human + # The two causes are offered as alternatives, never asserted -- nothing here can tell them apart. + assert "cannot tell them apart" in human + # And the properties the staleness guard already provided must survive alongside it. + _, parsed, _ = reader("-Json", pin=pin, home=fake_home) + assert parsed["statusline_state"] == "WIRED_HERE" + assert parsed["five_hour"]["used_percentage"] == pytest.approx(64.0), ( + "the number must still show" + ) + assert parsed["five_hour"]["reading_age_min"] is not None, "its age must still show" + assert parsed["five_hour"]["projected_at_reset"] is None, ( + "a stale reading must not be projected" + ) + + +def test_two_installs_in_the_same_second_do_not_destroy_the_only_backup(tmp_path: Path) -> None: + """A BACKUP THAT HOLDS POST-INSTALL CONTENT IS WORSE THAN NO BACKUP, because the operator is told + one exists. The stamp has one-second resolution, so two runs inside the same second collided: + measured on a fixture, run 2 overwrote run 1's pre-install copy with run 1's post-install content, + and the only original was destroyed by the run that claimed to be preserving it.""" + root = tmp_path / "root" + root.mkdir() + settings = root / "settings.json" + original = json.dumps({"mine": "keep me"}) + settings.write_text(original, encoding="utf-8") + + assert install("-ConfigDir", str(root), pin=None).returncode == 0 + assert install("-ConfigDir", str(root), "-RefreshInterval", "9000", pin=None).returncode == 0 + + backups = sorted(root.glob("settings.json.bak-usage-*")) + assert len(backups) == 2, f"the second run reused the first run's backup name: {backups}" + # Exactly one of them must still hold the untouched original. + contents = [b.read_text(encoding="utf-8") for b in backups] + assert original in contents, f"the pre-install content was destroyed: {contents}" + + +def test_the_survey_refuses_a_window_carried_from_another_root(fake_home: Path) -> None: + """THE SURVEY IS THE ONLY PLACE ACCOUNTS ARE COMPARED, so it is where this refusal matters most -- + and it was the one place it did not run. + + Checking only the DOCUMENT stamp passes a carry-forward: the document was written by this root and + sits under this root, while the numbers inside came from another. Measured before the fix: the + single-root reader refused both windows with "refusing to report another account's headroom", and + the survey in the SAME run printed those exact percentages as the other root's own, unlabelled. An + operator scanning the survey for the account with the most headroom reads one account's number as + another's. + """ + a = fake_home / ".claude-account-1" + b = fake_home / ".claude-account-2" + state = b / "mefor-usage" + + def fire(pin: Path, payload: dict[str, Any]) -> None: + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(COLLECT), + "-StateDir", + str(state), + "-HomeDir", + str(fake_home), + ], + input=json.dumps(payload), + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + env=_env(pin), + ) + assert proc.returncode == 0, proc.stderr + + # A publishes into B's directory, then B's own un-warmed session carries it forward. + fire( + a, + { + "session_id": "A", + "rate_limits": {"five_hour": window(88.0, 3600), "seven_day": window(77.0, 280000)}, + }, + ) + fire(b, {"session_id": "B"}) + # And A gets a healthy publish of its own, so the survey renders at all. + (a / "mefor-usage").mkdir(exist_ok=True) + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(COLLECT), + "-StateDir", + str(a / "mefor-usage"), + "-HomeDir", + str(fake_home), + ], + input=json.dumps( + { + "session_id": "A2", + "rate_limits": {"five_hour": window(12.0, 3600), "seven_day": window(9.0, 280000)}, + } + ), + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + env=_env(a), + ) + assert proc.returncode == 0, proc.stderr + + _, doc, _ = reader("-AllRoots", "-Json", pin=a, home=fake_home) + rows = {r["root"].lower(): r for r in doc["roots"]} + row_b = rows[str(b).lower()] + assert row_b["five"] is None, f"A's 88% was printed as B's own: {row_b}" + assert row_b["seven"] is None, row_b + assert "DIFFERENT config root" in row_b["note"], row_b + # A's own row is untouched -- the refusal fires on error, not on the survey as a whole. + assert rows[str(a).lower()]["five"] == pytest.approx(12.0) + + +def test_the_survey_ages_a_row_by_its_windows_not_by_the_document(fake_home: Path) -> None: + """The collector rewrites the document stamp on EVERY fire, including a pure carry-forward, so it + dates the last FIRE and not the OBSERVATION. Aged by the document, a six-hour-old reading renders + as "[seen 0 min ago]" -- a stale number wearing a fresh timestamp, which is the exact shape rule 1 + exists to forbid.""" + pin = fake_home / ".claude-account-1" + state = pin / "mefor-usage" + state.mkdir() + old = (datetime.now(UTC) - timedelta(hours=6)).isoformat() + now = datetime.now(UTC).isoformat() + # A document written just now, whose windows were observed six hours ago -- what a carry-forward + # produces. + (state / "latest.json").write_text( + json.dumps( + { + "captured_at": now, + "published_by": {"config_root_env": str(pin)}, + "five_hour": { + "used_percentage": 55.0, + "resets_at_epoch": int(time.time()) + 3600, + "captured_at": old, + "config_root_env": str(pin), + }, + "seven_day": { + "used_percentage": 33.0, + "resets_at_epoch": int(time.time()) + 280000, + "captured_at": old, + "config_root_env": str(pin), + }, + } + ), + encoding="utf-8", + ) + _, doc, human = reader("-AllRoots", "-Json", pin=pin, home=fake_home) + row = next(r for r in doc["roots"] if r["root"].lower() == str(pin).lower()) + assert row["age_min"] > 300, f"the row was aged by the document, not its windows: {row}" + assert row["stale"] is True, row + _, _, human = reader("-AllRoots", pin=pin, home=fake_home) + assert "STALE" in human, human + + +def test_rewired_says_which_thing_changed_not_always_the_publish_path(tmp_path: Path) -> None: + """A LINE THAT CONTRADICTS THE TWO LINES UNDER IT. + + REWIRED is entered whenever the command is not byte-identical, which is a WEAKER fact than any + single reason for it. The message asserted "published somewhere else" unconditionally, and the + owner's real run produced: + + replaced a mefor-usage statusLine that published somewhere else + was: /mefor-usage + now: /mefor-usage + + The publish path had not moved at all. The COLLECTOR had. Same defect class as the claim this + whole branch removes: a sentence stating more than the check behind it established. + """ + root = tmp_path / "root" + root.mkdir() + other_collector = tmp_path / "elsewhere" / "usage-collect.ps1" + other_collector.parent.mkdir() + other_collector.write_text("# stand-in", encoding="utf-8") + + # Wired to the RIGHT publish path but a DIFFERENT collector. + # collector= KEYWORD, not -CollectorPath in args: the helper always adds that flag, so passing it + # again binds the parameter twice and pwsh refuses it ("specified more than once"). + assert install("-ConfigDir", str(root), pin=None, collector=other_collector).returncode == 0 + first = wired(root / "settings.json") + assert str(tmp_path / "elsewhere") in first + + out = install("-ConfigDir", str(root), pin=None).stdout + assert "REWIRED" in out, out + assert "published somewhere else" not in out, ( + f"the publish path did not move, but the line says it did:\n{out}" + ) + assert "named a different collector" in out, out + assert "collector was:" in out and "collector now:" in out, out + + # And the genuine case must still say what it always said. + root2 = tmp_path / "root2" + root2.mkdir() + (root2 / "settings.json").write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": f"# mefor-usage\n$s = '{COLLECT}'; $d = '{tmp_path / 'far-away'}'; x", + } + } + ), + encoding="utf-8", + ) + out2 = install("-ConfigDir", str(root2), pin=None).stdout + assert "published somewhere else" in out2, out2