diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 5aaa2f29..c435d9e7 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -364,6 +364,58 @@ pwsh -NoProfile -File scripts\hooks\collision_gate.ps1 -PathOverride docs\BACKLO Empty output means no live session holds it. Documented in-script as a test affordance; surfaced here because a session that needed the answer found it by reading the source. +## Account usage — knowing before a session is cut off + +**What it fixes.** Sessions were hitting the plan limit mid-task and losing work. The account's real +quota state exists — Settings > Usage shows it — but it is not visible from inside a session, so nobody +knew how much headroom was left until it ran out. + +**The one place the numbers arrive.** Claude Code hands `rate_limits` to a **statusLine command's stdin +and nowhere else**. Not `SessionStart`, not `UserPromptSubmit`, not `Stop` — the payloads were enumerated +in the shipped binary and it appears in exactly one of them. So quota state cannot be subscribed to; it +has to be *collected* by a statusLine and published somewhere shared. That single fact determines the +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. + +**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 +never publish it itself. `refreshInterval` is set because statusLine updates are event-driven and go +silent when a session is idle — Anthropic's docs name *"a coordinator waits on background subagents"* as +exactly the case where that leaves you blind. + +**Two of the four Settings > Usage numbers are not available at all.** The payload carries `five_hour` +and `seven_day` only. The **per-model weekly buckets** (the Fable/Opus/Sonnet bars) and the **plan tier** +are absent, and the request to expose them was closed as not-planned. `usage.ps1` prints that on every +run rather than burying it: if Opus is being burned hard across many sessions, the bucket most likely to +stop you is the one nothing here can see. Two green bars and an invisible third is worse than no tool. + +```powershell +pwsh -NoProfile -File scripts\coord\usage.ps1 # human +pwsh -NoProfile -File scripts\coord\usage.ps1 -Json # coordinator +``` + +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.** + +> **`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 +> "5-hour block" is a client-side reconstruction and its statusline percentage is context-window. +> `claude-usage-tracker` is the same mistake in cruder form — real token parsing compared against a +> hardcoded limit table. Anything reading plan state from either is confidently wrong at exactly the +> moment it matters. Tokens and plan-limit consumption are different quantities. + ## Announcing yourself (UserPromptSubmit hook) **What it fixes.** Everything above is **pull**-based: a new session discovers its peers and the peers diff --git a/scripts/coord/install-usage-statusline.ps1 b/scripts/coord/install-usage-statusline.ps1 new file mode 100644 index 00000000..145ee0dc --- /dev/null +++ b/scripts/coord/install-usage-statusline.ps1 @@ -0,0 +1,136 @@ +<# +.SYNOPSIS + Wire usage-collect.ps1 as the Claude Code statusLine, so the 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. + + 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. + + 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. + +.EXAMPLE + pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 + pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -Status + pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 -Uninstall +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [switch]$Uninstall, + [switch]$Status, + # Milliseconds. Minimum honoured by Claude Code is 1000. + [int]$RefreshInterval = 10000, + [string]$SettingsPath = (Join-Path $env:USERPROFILE ".claude\settings.json"), + # 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 +) + +$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]@{} } + return ($raw | ConvertFrom-Json -AsHashtable) +} + +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 " 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" }) + Write-Host "" + exit 0 +} + +$settings = Get-Settings + +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 + } + } + else { Write-Host "Nothing to remove: the statusLine is absent or is not ours." -ForegroundColor Yellow } + exit 0 +} + +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." +} + +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 +} + +# 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' }" + +$settings['statusLine'] = [ordered]@{ + type = "command" + command = $cmd + refreshInterval = $RefreshInterval +} + +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 "" +} diff --git a/scripts/coord/usage-collect.ps1 b/scripts/coord/usage-collect.ps1 new file mode 100644 index 00000000..cbf1b765 --- /dev/null +++ b/scripts/coord/usage-collect.ps1 @@ -0,0 +1,199 @@ +<# +.SYNOPSIS + statusLine command: publish the account's live plan-limit state where every session can read it. + +.DESCRIPTION + THE ONLY PLACE THE REAL NUMBERS ARRIVE. Claude Code hands `rate_limits` to a statusLine command's + stdin and NOWHERE ELSE -- not SessionStart, not UserPromptSubmit, not Stop, not any other hook. So a + 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. + + WHAT IT CANNOT SEE, STATED HERE SO NOTHING DOWNSTREAM IMPLIES OTHERWISE. The statusLine payload + carries `five_hour` and `seven_day` only. The PER-MODEL weekly buckets (the "Weekly / Fable" or + Opus/Sonnet bars in Settings > Usage) and the plan tier are NOT in it, and the request to expose + them was closed as not-planned. If Opus is being burned hard, the bucket most likely to cut a + session off is one this file cannot report. Readers must render that as UNKNOWN, never as 0 or as + "fine" -- an absent bucket is not an empty one. + + NEVER THROWS, NEVER BLOCKS. A statusLine that errors or hangs degrades the session it is decorating, + so every path is wrapped and the worst case is a bare line of text with no publish. Writes are + temp-then-rename because Claude Code CANCELS an in-flight statusLine when a new one is triggered + (300ms debounce) -- a truncating write would publish half a JSON document to every reader on the box. + +.EXAMPLE + Wire it (owner, from a plain terminal): + pwsh -NoProfile -File scripts\coord\install-usage-statusline.ps1 + Read it (anyone): + pwsh -NoProfile -File scripts\coord\usage.ps1 +#> +[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"), + # 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 +) + +# NO `$ErrorActionPreference = "Stop"`. This decorates a live session; a throw here is a worse outcome +# than a missing reading, every time. +$ErrorActionPreference = "SilentlyContinue" + +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 + # 2,559 for Move-Item -Force. Readers therefore never observe a missing or half-written file. + $tmp = "$Path.$PID.tmp" + [System.IO.File]::WriteAllBytes($tmp, [System.Text.Encoding]::UTF8.GetBytes($Text)) + foreach ($attempt in 1..3) { + # Untyped catch: PowerShell wraps a .NET method's exception in MethodInvocationException, so a + # typed catch here silently never matches and orphans the temp file. + try { [System.IO.File]::Move($tmp, $Path, $true); return $true } catch { Start-Sleep -Milliseconds (15 * $attempt) } + } + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + return $false +} + +function Format-Reset([object]$EpochSeconds) { + if ($null -eq $EpochSeconds) { return $null } + try { return [System.DateTimeOffset]::FromUnixTimeSeconds([long]$EpochSeconds).ToLocalTime().ToString("o") } catch { return $null } +} + +$line = "mefor-usage" +try { + if (-not [Console]::IsInputRedirected) { Write-Output $line; exit 0 } + $raw = [Console]::In.ReadToEnd() + $p = $null + try { $p = $raw | ConvertFrom-Json -ErrorAction Stop } catch { } + if (-not $p) { Write-Output "mefor-usage: unreadable statusline payload"; exit 0 } + + New-Item -ItemType Directory -Force -Path $StateDir | Out-Null + if ($CaptureRaw) { Write-AtomicText (Join-Path $StateDir "raw-payload.json") $raw | Out-Null } + + $rl = $p.rate_limits + $now = (Get-Date).ToUniversalTime().ToString("o") + + # ABSENT IS A VALUE. The docs are explicit that rate_limits "appears only for Claude.ai subscribers + # (Pro/Max) after the first API response in the session" and that each window "may be independently + # absent". So a missing window is recorded as null with a reason, never defaulted to 0 -- 0% used and + # "not reported yet" are opposite facts and must not share a representation. + function Get-Window($w) { + if ($null -eq $w) { return $null } + $pct = $w.used_percentage + if ($null -eq $pct) { return $null } + return [ordered]@{ + used_percentage = [double]$pct + resets_at_epoch = $w.resets_at + resets_at = Format-Reset $w.resets_at + } + } + + $five = Get-Window $rl.five_hour + $seven = Get-Window $rl.seven_day + + # Snapshotted BEFORE the carry-forward below, because HISTORY MUST RECORD ONLY WHAT WAS FRESHLY + # OBSERVED. A carried-forward percentage written against a new timestamp tells the burn-rate + # calculation that consumption had stopped -- the one lie that matters in a tool built to warn + # about consumption. + $freshFive = if ($five) { $five.used_percentage } else { $null } + $freshSeven = if ($seven) { $seven.used_percentage } else { $null } + + # DO NOT CLOBBER A GOOD READING WITH AN EMPTY ONE. + # + # EVERY session runs this statusLine and they all publish to ONE shared file, so every session is a + # publisher -- there is no privileged "collector". A session that has not yet had its first API + # response carries no rate_limits at all, and a naive write blanks the account's only good reading + # for all of them. Caught in test: a full 5h/7d reading was overwritten seconds later by a session + # reporting neither. + # + # Per-window, not all-or-nothing, because the docs are explicit that the two windows are absent + # INDEPENDENTLY. An absent window carries the previous value forward together with ITS OWN + # captured_at, so staleness is tracked per window and a reader can never mistake a carried-over + # number for a freshly observed one. + $latestPath = Join-Path $StateDir "latest.json" + $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 } + + # 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 + published_by = [ordered]@{ + session_id = [string]$p.session_id + version = [string]$p.version + cwd = [string]$p.cwd + } + five_hour = $five + seven_day = $seven + # Named explicitly rather than omitted, so a reader cannot mistake "this build never collected it" + # for "the account has none". + unavailable = @( + "per_model_weekly (Fable/Opus/Sonnet) -- not present in the statusLine payload", + "plan_tier -- not present in the statusLine payload" + ) + source = "claude-code statusLine rate_limits" + } + + $json = ($doc | ConvertTo-Json -Depth 6) + $ok = Write-AtomicText (Join-Path $StateDir "latest.json") $json + + # History drives burn rate. Append ONLY when a percentage actually moves: the statusline can fire many + # times a minute, and a row per fire would be a large file describing a flat line. + if ($ok -and ($null -ne $freshFive -or $null -ne $freshSeven)) { + $histPath = Join-Path $StateDir "history.jsonl" + $prev = $null + try { $prev = (Get-Content -LiteralPath $histPath -Tail 1 -ErrorAction SilentlyContinue | ConvertFrom-Json) } catch { } + # Append only when a FRESHLY OBSERVED percentage actually moves. The statusline can fire many + # times a minute; a row per fire would be a large file describing a flat line, and rate over a + # dense flat series is noise. + $changed = $true + if ($prev) { + $samePct = (($null -eq $freshFive) -or ($prev.five_hour -eq $freshFive)) -and + (($null -eq $freshSeven) -or ($prev.seven_day -eq $freshSeven)) + if ($samePct) { $changed = $false } + } + if ($changed) { + $row = [ordered]@{ + at = $now + five_hour = $freshFive + seven_day = $freshSeven + five_reset = if ($rl.five_hour) { $rl.five_hour.resets_at } else { $null } + seven_reset = if ($rl.seven_day) { $rl.seven_day.resets_at } else { $null } + } | ConvertTo-Json -Compress + Add-Content -LiteralPath $histPath -Value $row -Encoding UTF8 + } + } + + # The human-facing line. Keep it short; this is a status bar, not a report. + if ($five -or $seven) { + $parts = @() + if ($five) { $parts += ("5h {0:0}%" -f $five.used_percentage) } + if ($seven) { $parts += ("7d {0:0}%" -f $seven.used_percentage) } + $line = ($parts -join " ") + if ($five -and $five.resets_at_epoch) { + $mins = [int](([System.DateTimeOffset]::FromUnixTimeSeconds([long]$five.resets_at_epoch) - [System.DateTimeOffset]::UtcNow).TotalMinutes) + if ($mins -ge 0) { $line += (" (resets {0}h{1:00}m)" -f [int]($mins / 60), ($mins % 60)) } + } + } + else { + # Distinguish "no quota data yet" from "quota fine". The first API response of a session has not + # landed yet, or this is not a subscription account. + $line = "mefor-usage: no rate_limits yet" + } +} +catch { + $line = "mefor-usage: collector error" +} + +Write-Output $line +exit 0 diff --git a/scripts/coord/usage.ps1 b/scripts/coord/usage.ps1 new file mode 100644 index 00000000..a99b6add --- /dev/null +++ b/scripts/coord/usage.ps1 @@ -0,0 +1,248 @@ +<# +.SYNOPSIS + What the account's plan limits are doing, and whether a session is about to be cut off. + +.DESCRIPTION + Reads what `usage-collect.ps1` publishes from the Claude Code statusLine (see that file for why the + statusLine is the only source). Adds the two things a raw reading cannot give you: a BURN RATE, and + the only question that actually matters operationally -- WILL THIS WINDOW RUN OUT BEFORE IT RESETS? + + 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 -- + 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. + Windows are aged INDEPENDENTLY, because they are published independently. + 2. REFUSE TO PROJECT ON STALE OR THIN DATA. Below two fresh samples in the current window, or past + -MaxAgeMinutes, the answer is UNKNOWN. Not an extrapolation, not a last-known value dressed up + as current. + 3. NAME WHAT IS NOT MEASURED. The per-model weekly buckets (Fable/Opus/Sonnet) are not in the + statusLine payload at all. If Opus is being burned hard, the bucket most likely to stop you is + one nothing here can see. That is printed every run, not buried. + + 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 + +.EXAMPLE + pwsh -NoProfile -File scripts\coord\usage.ps1 + pwsh -NoProfile -File scripts\coord\usage.ps1 -Json +#> +[CmdletBinding()] +param( + [string]$StateDir = (Join-Path $env:USERPROFILE ".claude\mefor-usage"), + # 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 +) + +$ErrorActionPreference = "SilentlyContinue" + +$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 { } + +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 "" } + exit 20 +} + +$nowUtc = (Get-Date).ToUniversalTime() + +function Get-AgeMinutes($v) { + if (-not $v) { return $null } + # DO NOT STRINGIFY THIS VALUE. ConvertFrom-Json has ALREADY coerced the ISO-8601 field into a + # [datetime] with Kind=Utc (verified, not assumed). Rendering it back to a string drops the 'Z', + # and re-parsing a Z-less string assumes LOCAL -- which added this machine's UTC-5 offset and + # reported a reading taken 90 seconds earlier as 299 minutes in the FUTURE. + # + # The sign is what made it dangerous rather than merely wrong: a negative age passes an + # `age -gt max` staleness test unconditionally, so the guard against a dead publisher would have + # been disarmed on every non-UTC machine while still looking present. Same ConvertFrom-Json date + # coercion that silently downgraded the stamp in claim.ps1; it is worth expecting now. + $t = $null + if ($v -is [datetime]) { + $t = switch ($v.Kind) { + ([System.DateTimeKind]::Utc) { $v } + ([System.DateTimeKind]::Local) { $v.ToUniversalTime() } + default { [datetime]::SpecifyKind($v, [System.DateTimeKind]::Utc) } # we only ever write UTC + } + } + elseif ($v -is [datetimeoffset]) { $t = $v.UtcDateTime } + else { try { $t = [System.DateTimeOffset]::Parse([string]$v).UtcDateTime } catch { return $null } } + return [math]::Round(($nowUtc - $t).TotalMinutes, 1) +} + +# --- burn rate ------------------------------------------------------------------------------------ +# +# ONLY WITHIN ONE WINDOW EPOCH. When a window resets, the percentage falls off a cliff (90 -> 0). A rate +# computed across that boundary is a large NEGATIVE number, which would read as "consumption has stopped" +# at the exact moment a fresh window starts being spent. Rows are therefore grouped by their reset epoch +# and only rows sharing the CURRENT epoch are used. +function Get-Rate([string]$Key, [string]$ResetKey, $CurrentResetEpoch) { + if (-not (Test-Path -LiteralPath $histPath)) { return $null } + $rows = @() + try { + foreach ($line in (Get-Content -LiteralPath $histPath -Tail 400 -ErrorAction Stop)) { + try { $r = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue } + if ($null -eq $r.$Key) { continue } # carried-forward or absent: not an observation + if ($CurrentResetEpoch -and ($r.$ResetKey -ne $CurrentResetEpoch)) { continue } # a previous window + $age = Get-AgeMinutes $r.at + if ($null -eq $age -or $age -gt $RateWindowMinutes) { continue } + $rows += [pscustomobject]@{ AgeMin = $age; Pct = [double]$r.$Key } + } + } catch { return $null } + if ($rows.Count -lt 2) { return $null } # one point is a reading, not a rate + $oldest = $rows | Sort-Object AgeMin -Descending | Select-Object -First 1 + $newest = $rows | Sort-Object AgeMin | Select-Object -First 1 + $spanH = ($oldest.AgeMin - $newest.AgeMin) / 60.0 + if ($spanH -le 0) { return $null } + $delta = $newest.Pct - $oldest.Pct + return [pscustomobject]@{ + PctPerHour = [math]::Round($delta / $spanH, 2) + Samples = $rows.Count + SpanMin = [math]::Round($oldest.AgeMin - $newest.AgeMin, 1) + } +} + +function Get-WindowReport($w, [string]$Label, [string]$Key, [string]$ResetKey) { + if (-not $w) { + return [ordered]@{ label = $Label; state = "UNKNOWN"; reason = "never published"; used_percentage = $null } + } + $age = Get-AgeMinutes $w.captured_at + $pct = [double]$w.used_percentage + $resetEpoch = $w.resets_at_epoch + $minsToReset = $null + if ($resetEpoch) { + try { $minsToReset = [math]::Round(([System.DateTimeOffset]::FromUnixTimeSeconds([long]$resetEpoch) - [System.DateTimeOffset]::UtcNow).TotalMinutes, 0) } catch { } + } + + $o = [ordered]@{ + label = $Label + used_percentage = $pct + reading_age_min = $age + resets_at = $w.resets_at + minutes_to_reset = $minsToReset + rate_pct_per_hr = $null + projected_at_reset = $null + minutes_to_empty = $null + state = "OK" + reason = "" + } + + # RULE 2: stale readings are reported, never projected from. + # + # The lower bound is not defensive tidying. An age is a subtraction of two clocks, and any bug or skew + # that makes it negative would pass an `age -gt max` test unconditionally -- the staleness guard would + # then be permanently disarmed while looking present. Bound it BOTH ways so a nonsensical age is + # reported as nonsense rather than silently accepted as fresh. + if ($null -eq $age -or $age -gt $MaxAgeMinutes -or $age -lt -2) { + $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" } + return $o + } + + $rate = Get-Rate -Key $Key -ResetKey $ResetKey -CurrentResetEpoch $resetEpoch + if ($rate) { + $o.rate_pct_per_hr = $rate.PctPerHour + if ($null -ne $minsToReset -and $minsToReset -gt 0) { + $o.projected_at_reset = [math]::Round([math]::Min(100.0, $pct + $rate.PctPerHour * ($minsToReset / 60.0)), 1) + } + if ($rate.PctPerHour -gt 0) { + $o.minutes_to_empty = [math]::Round((100.0 - $pct) / $rate.PctPerHour * 60.0, 0) + } + } + else { + $o.reason = "not enough samples in this window for a rate" + } + + # Bands. The question is not "is the number big" but "does it run out before it resets". + if ($pct -ge 98) { $o.state = "CRITICAL"; $o.reason = "at the ceiling" } + elseif ($null -ne $o.minutes_to_empty -and $null -ne $minsToReset -and $o.minutes_to_empty -lt $minsToReset) { + $o.state = "CRITICAL" + $o.reason = "projected to hit 100% in ~$($o.minutes_to_empty) min, $minsToReset min before this window resets" + } + elseif ($pct -ge 85) { $o.state = "WARN"; $o.reason = "high, but not projected to run out before reset" } + elseif ($null -ne $o.projected_at_reset -and $o.projected_at_reset -ge 95) { + $o.state = "WARN"; $o.reason = "projected to reach $($o.projected_at_reset)% by reset" + } + 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" + +$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. +$overall = if ($states -contains "CRITICAL") { "CRITICAL" } +elseif ($states -contains "WARN") { "WARN" } +elseif ($states -contains "UNKNOWN") { "UNKNOWN" } +else { "OK" } + +# 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. +$advice = switch ($overall) { + "CRITICAL" { "COMMIT NOW and write your handoff. Assume you may be cut off mid-task. Do not start anything you cannot finish or hand over in the time above." } + "WARN" { "Commit at your next logical stop and keep your handoff current, so a cutoff costs nothing." } + "UNKNOWN" { "Treat headroom as UNKNOWN, not as fine. Commit at logical stops anyway; that is the behaviour that makes a cutoff survivable regardless of the number." } + default { "Normal working. Commit at logical stops as usual." } +} + +$blindSpot = "NOT MEASURED: per-model weekly (Fable/Opus/Sonnet) and plan tier are absent from the statusLine payload. Heavy Opus use across many sessions can exhaust a bucket nothing here can see." + +if ($Json) { + [ordered]@{ + state = $overall + exit_code = $rank[$overall] + five_hour = $five + seven_day = $seven + advice = $advice + not_measured = $blindSpot + published_by = $doc.published_by + captured_at = $doc.captured_at + } | ConvertTo-Json -Depth 6 | Write-Output + exit $rank[$overall] +} + +function Show-Window($o) { + if ($o.state -eq "UNKNOWN" -and $null -eq $o.used_percentage) { + Write-Host (" {0,-14} UNKNOWN -- {1}" -f $o.label, $o.reason) -ForegroundColor DarkGray + return + } + $colour = switch ($o.state) { "CRITICAL" { "Red" } "WARN" { "Yellow" } "UNKNOWN" { "DarkGray" } default { "Green" } } + $reset = if ($null -ne $o.minutes_to_reset) { "resets in {0}h{1:00}m" -f [int]($o.minutes_to_reset / 60), ($o.minutes_to_reset % 60) } else { "reset unknown" } + Write-Host (" {0,-14} {1,5:0.0}% {2} [seen {3} min ago]" -f $o.label, $o.used_percentage, $reset, $o.reading_age_min) -ForegroundColor $colour + if ($null -ne $o.rate_pct_per_hr) { + $proj = if ($null -ne $o.projected_at_reset) { ", ~{0}% by reset" -f $o.projected_at_reset } else { "" } + Write-Host (" {0:+0.0;-0.0;0.0} %/hr{1}" -f $o.rate_pct_per_hr, $proj) -ForegroundColor DarkGray + } + if ($o.reason) { Write-Host (" {0}" -f $o.reason) -ForegroundColor DarkGray } +} + +Write-Host "" +Write-Host "Claude account usage -- $overall" -ForegroundColor $(switch ($overall) { "CRITICAL" { "Red" } "WARN" { "Yellow" } "UNKNOWN" { "DarkGray" } default { "Green" } }) +Write-Host "" +Show-Window $five +Show-Window $seven +Write-Host "" +Write-Host " $advice" +Write-Host "" +Write-Host " $blindSpot" -ForegroundColor DarkGray +Write-Host "" +exit $rank[$overall] diff --git a/tests/test_coord_usage.py b/tests/test_coord_usage.py new file mode 100644 index 00000000..f48e7c20 --- /dev/null +++ b/tests/test_coord_usage.py @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Tests for the plan-usage collector and reader (``scripts/coord/usage-*.ps1``). + +Claude Code hands the account's live quota state (``rate_limits``) to a **statusLine command's stdin and +nowhere else** — not to any hook. So quota state has to be collected there and published somewhere +shared. ``usage-collect.ps1`` is that statusLine; ``usage.ps1`` reads what it publishes and answers the +only operationally useful question: *will this window run out before it resets?* + +The properties worth pinning are all about **not being confidently wrong**, because a usage tool that +lies converts "I should check" into "I already know": + +* **An empty reading must never overwrite a good one.** Every session runs the statusLine and they all + publish to one shared file, so a session that has not yet had its first API response would otherwise + blank the account's only reading for all of them. +* **Windows are absent independently**, so staleness is tracked per window — a carried-forward number + must keep its own older timestamp and must never enter the burn-rate history. +* **Stale, undateable and future-dated readings are UNKNOWN**, never extrapolated. +* **Rate is never computed across a window reset**, where the percentage legitimately falls to zero. + +Driven as real subprocesses against fixtures, because these are PowerShell scripts and a Python +re-implementation of their rules would only assert that the re-implementation agrees with itself. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +COLLECT = ROOT / "scripts" / "coord" / "usage-collect.ps1" +READ = ROOT / "scripts" / "coord" / "usage.ps1" +INSTALL = ROOT / "scripts" / "coord" / "install-usage-statusline.ps1" +TIMEOUT = 60 + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="the usage scripts need pwsh on Windows", +) + +OK, WARN, CRITICAL, UNKNOWN = 0, 10, 11, 20 + + +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.""" + raw = payload if isinstance(payload, str) else json.dumps(payload) + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(COLLECT), "-StateDir", str(state)], + input=raw, + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + # 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}" + return proc.stdout.strip() + + +def read(state: Path, *extra: str) -> tuple[int, dict[str, Any]]: + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(READ), + "-StateDir", + str(state), + "-Json", + *extra, + ], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + out = proc.stdout.strip() + return proc.returncode, (json.loads(out) if out else {}) + + +def window(pct: float, resets_in_s: int) -> dict[str, Any]: + return {"used_percentage": pct, "resets_at": int(time.time()) + resets_in_s} + + +def latest(state: Path) -> dict[str, Any]: + parsed: dict[str, Any] = json.loads((state / "latest.json").read_text(encoding="utf-8")) + return parsed + + +def history(state: Path) -> list[dict[str, Any]]: + p = state / "history.jsonl" + if not p.exists(): + return [] + return [json.loads(x) for x in p.read_text(encoding="utf-8").splitlines() if x.strip()] + + +# ------------------------------------------------------------------------------ collector + + +def test_publishes_both_windows(tmp_path: Path) -> None: + line = collect( + tmp_path, + { + "session_id": "A", + "rate_limits": {"five_hour": window(80.4, 5940), "seven_day": window(36.2, 280000)}, + }, + ) + d = latest(tmp_path) + assert d["five_hour"]["used_percentage"] == pytest.approx(80.4) + assert d["seven_day"]["used_percentage"] == pytest.approx(36.2) + assert "80" in line and "36" in line, f"the human line must show both: {line!r}" + + +def test_a_session_with_no_rate_limits_does_not_clobber_a_good_reading(tmp_path: Path) -> None: + """THE ONE THAT MATTERS. Found by testing, not by reading. + + Every session runs this statusLine and they all publish to ONE shared file, so each session is a + publisher — there is no privileged collector. ``rate_limits`` is absent until a session's first API + response lands, so a naive write blanks the account's only good reading for all thirty sessions at + once, and the reader downstream cannot tell the difference between "0% used" and "nobody has looked". + """ + collect( + tmp_path, + { + "session_id": "A", + "rate_limits": {"five_hour": window(80.4, 5940), "seven_day": window(36.2, 280000)}, + }, + ) + collect(tmp_path, {"session_id": "B"}) # a session that has not made an API call yet + + d = latest(tmp_path) + assert d["five_hour"]["used_percentage"] == pytest.approx(80.4), ( + "a session with no data destroyed the reading" + ) + assert d["seven_day"]["used_percentage"] == pytest.approx(36.2) + + +def test_an_absent_window_is_carried_forward_with_its_own_older_timestamp(tmp_path: Path) -> None: + """The docs are explicit that the two windows are absent INDEPENDENTLY, so staleness is per window. + + A carried-forward value stamped with the current time would be a stale number wearing a fresh + timestamp — which is precisely the shape of claim that this repo keeps having to retract. + """ + collect( + tmp_path, + { + "session_id": "A", + "rate_limits": {"five_hour": window(80.0, 5940), "seven_day": window(36.0, 280000)}, + }, + ) + time.sleep(1.1) + collect(tmp_path, {"session_id": "C", "rate_limits": {"five_hour": window(88.0, 5940)}}) + + d = latest(tmp_path) + assert d["five_hour"]["used_percentage"] == pytest.approx(88.0), "the fresh window must update" + assert d["seven_day"]["used_percentage"] == pytest.approx(36.0), ( + "the absent window must be carried, not nulled" + ) + assert d["seven_day"]["captured_at"] < d["five_hour"]["captured_at"], ( + "the carried window must keep its ORIGINAL timestamp, or it reads as freshly observed" + ) + + +def test_history_never_records_a_carried_forward_value(tmp_path: Path) -> None: + """Burn rate is computed from this file. A carried-forward percentage against a new timestamp says + consumption stopped — the one lie that matters in a tool built to warn about consumption.""" + collect( + tmp_path, + { + "session_id": "A", + "rate_limits": {"five_hour": window(80.0, 5940), "seven_day": window(36.0, 280000)}, + }, + ) + collect(tmp_path, {"session_id": "C", "rate_limits": {"five_hour": window(88.0, 5940)}}) + + rows = history(tmp_path) + assert rows, "expected history rows" + assert rows[-1]["five_hour"] == pytest.approx(88.0) + assert rows[-1]["seven_day"] is None, ( + "a carried-forward window must be null in history, not repeated" + ) + + +def test_a_flat_reading_does_not_append_history(tmp_path: Path) -> None: + """The statusline fires many times a minute; a row per fire is a big file describing a flat line.""" + payload = {"session_id": "A", "rate_limits": {"five_hour": window(50.0, 5940)}} + collect(tmp_path, payload) + collect(tmp_path, payload) + collect(tmp_path, payload) + assert len(history(tmp_path)) == 1 + + +@pytest.mark.parametrize("bad", ["", "not json at all", "{truncated", "null"]) +def test_the_collector_never_fails_on_a_bad_payload(tmp_path: Path, bad: str) -> None: + """It decorates a live session. A throw or a non-zero exit here is worse than a missing reading.""" + line = collect(tmp_path, bad) + assert line, "it must still emit a status line" + assert not list(tmp_path.glob("*.tmp")), "a failed write must not orphan a temp file" + + +def test_no_data_publishes_nothing_rather_than_a_document_of_nulls(tmp_path: Path) -> None: + collect(tmp_path, {"session_id": "B"}) + assert not (tmp_path / "latest.json").exists(), ( + "with nothing observed and nothing remembered, publishing nulls would make 'unknown' look like 'zero'" + ) + + +# ------------------------------------------------------------------------------ reader + + +def publish( + state: Path, + *, + five: float | None, + seven: float | None, + five_in_s: int = 3600, + age_min: float = 0.0, + five_epoch: int | None = None, +) -> None: + """Write a latest.json directly, so reader behaviour can be tested independently of the collector.""" + state.mkdir(parents=True, exist_ok=True) + stamp = (datetime.now(UTC) - timedelta(minutes=age_min)).isoformat().replace("+00:00", "Z") + doc: dict[str, Any] = { + "captured_at": stamp, + "published_by": {"session_id": "T"}, + "source": "test", + } + doc["five_hour"] = ( + None + if five is None + else { + "used_percentage": five, + "resets_at_epoch": five_epoch or int(time.time()) + five_in_s, + "resets_at": "x", + "captured_at": stamp, + } + ) + doc["seven_day"] = ( + None + if seven is None + else { + "used_percentage": seven, + "resets_at_epoch": int(time.time()) + 280000, + "resets_at": "x", + "captured_at": stamp, + } + ) + (state / "latest.json").write_text(json.dumps(doc), encoding="utf-8") + + +def write_history(state: Path, points: list[tuple[float, float]], five_epoch: int) -> None: + """points = [(minutes_ago, five_hour_pct)] within one window epoch.""" + state.mkdir(parents=True, exist_ok=True) + lines = [] + for mins, pct in points: + at = (datetime.now(UTC) - timedelta(minutes=mins)).isoformat().replace("+00:00", "Z") + lines.append( + json.dumps( + { + "at": at, + "five_hour": pct, + "seven_day": None, + "five_reset": five_epoch, + "seven_reset": None, + } + ) + ) + (state / "history.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def test_no_data_at_all_is_unknown_and_says_how_to_fix_it(tmp_path: Path) -> None: + code, _ = read(tmp_path / "nothing") + assert code == UNKNOWN + + +def test_a_stale_reading_is_reported_but_never_projected_from(tmp_path: Path) -> None: + """A dead publisher is the expected steady state here: the statusLine does not run headless, so the + coordinator itself can never publish. Extrapolating from an hour-old number would be a confident + answer about a window that may already have reset.""" + publish(tmp_path, five=71.0, seven=30.0, age_min=75) + code, d = read(tmp_path) + assert code == UNKNOWN + assert d["five_hour"]["used_percentage"] == pytest.approx(71.0), "the number is still shown" + assert d["five_hour"]["reading_age_min"] >= 70, "with its age" + assert d["five_hour"]["projected_at_reset"] is None, "but nothing is projected from it" + + +def test_a_future_dated_reading_is_refused_rather_than_treated_as_fresh(tmp_path: Path) -> None: + """A negative age passes an ``age -gt max`` staleness test unconditionally, so the guard against a + dead publisher would be disarmed while still looking present. Measured during development: a + reading written 90 seconds earlier reported as 299 minutes in the future — exactly this machine's + UTC offset, from stringifying a value ``ConvertFrom-Json`` had already typed as a UTC datetime.""" + publish(tmp_path, five=71.0, seven=30.0, age_min=-120) + code, d = read(tmp_path) + assert code == UNKNOWN + assert "FUTURE" in d["five_hour"]["reason"].upper() + + +def test_a_never_published_window_is_unknown_not_zero(tmp_path: Path) -> None: + publish(tmp_path, five=50.0, seven=None) + _, d = read(tmp_path) + assert d["seven_day"]["state"] == "UNKNOWN" + assert d["seven_day"]["used_percentage"] is None, "absent must not render as 0% used" + + +def test_burning_faster_than_the_window_resets_is_critical(tmp_path: Path) -> None: + """The operational question is not 'is the number big' but 'does it run out before it resets'.""" + epoch = int(time.time()) + 3600 + publish(tmp_path, five=71.0, seven=30.0, five_epoch=epoch) + write_history(tmp_path, [(60, 40.0), (40, 52.0), (20, 64.0), (1, 71.0)], epoch) + + code, d = read(tmp_path) + assert code == CRITICAL + assert d["five_hour"]["rate_pct_per_hr"] > 20 + assert 0 < d["five_hour"]["minutes_to_empty"] < 60, "must run out before the window resets" + + +def test_a_high_but_stable_reading_is_not_critical(tmp_path: Path) -> None: + """85% with nothing being consumed is not an emergency, and calling it one is how a warning gets + ignored when it is real.""" + epoch = int(time.time()) + 3600 + publish(tmp_path, five=86.0, seven=30.0, five_epoch=epoch) + write_history(tmp_path, [(60, 86.0), (30, 86.0), (1, 86.0)], epoch) + + code, d = read(tmp_path) + assert code == WARN + assert d["five_hour"]["minutes_to_empty"] is None, "a flat rate cannot project exhaustion" + + +def test_rate_is_not_computed_across_a_window_reset(tmp_path: Path) -> None: + """THE EPOCH-BOUNDARY TRAP. When a window resets the percentage legitimately collapses (90 -> 5). + A rate spanning that boundary is large and NEGATIVE, which reads as 'consumption has stopped' at the + exact moment a fresh window has started being spent. Rows carry their reset epoch so the old window's + samples are excluded rather than averaged in. + """ + old_epoch = int(time.time()) - 600 # a window that already reset + new_epoch = int(time.time()) + 3600 + publish(tmp_path, five=5.0, seven=30.0, five_epoch=new_epoch) + state = tmp_path + rows = [ + { + "at": (datetime.now(UTC) - timedelta(minutes=m)).isoformat().replace("+00:00", "Z"), + "five_hour": p, + "seven_day": None, + "five_reset": e, + "seven_reset": None, + } + for m, p, e in [ + (50, 88.0, old_epoch), + (40, 93.0, old_epoch), + (10, 2.0, new_epoch), + (1, 5.0, new_epoch), + ] + ] + (state / "history.jsonl").write_text( + "\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8" + ) + + _, d = read(state) + rate = d["five_hour"]["rate_pct_per_hr"] + assert rate is not None and rate > 0, f"rate must come from the CURRENT window only, got {rate}" + + +def test_the_unmeasured_buckets_are_named_every_run(tmp_path: Path) -> None: + """Per-model weekly (Fable/Opus/Sonnet) is not in the statusLine payload at all. A tool that shows + two green bars while a third invisible bucket is what actually stops you is worse than no tool.""" + publish(tmp_path, five=10.0, seven=10.0) + _, d = read(tmp_path) + assert "per-model" in d["not_measured"].lower() + assert "opus" in d["not_measured"].lower() + + +# ------------------------------------------------------------------------------ installer + + +def test_the_installer_refuses_to_replace_someone_elses_statusline(tmp_path: Path) -> None: + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps({"statusLine": {"type": "command", "command": "my-own-thing"}}), encoding="utf-8" + ) + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(INSTALL), + "-SettingsPath", + str(settings), + "-CollectorPath", + str(COLLECT), + ], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + assert proc.returncode == 1 + assert "REFUSING" in proc.stdout + assert ( + json.loads(settings.read_text(encoding="utf-8"))["statusLine"]["command"] == "my-own-thing" + ) + + +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.""" + settings = tmp_path / "settings.json" + settings.write_text("{}", encoding="utf-8") + subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(INSTALL), + "-SettingsPath", + str(settings), + "-CollectorPath", + str(COLLECT), + ], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=True, + ) + cmd = json.loads(settings.read_text(encoding="utf-8"))["statusLine"]["command"] + assert "mefor-usage" in cmd + + state = tmp_path / "state" + 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.replace("-File $s", f"-File $s -StateDir '{state}'"), + ], + 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"