diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e44c1..d4b8642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,100 @@ # Changelog -## Unreleased +## v0.35.0 — 2026-08-27 — a directory is where a sample landed, not who it belongs to + +**A directory is where a sample landed, not who it belongs to.** The +same account reaches `~/.claude/statusline/` when an untagged statusline +fetched and `accounts//` when a deva-tagged container did, and +every aggregating reader — the forecast, the ledger strip, `report` — +read only the directory it was standing in. Measured on one machine: +301 days of history at the root, 28 in the tagged dir, one uuid. Nothing +was visibly wrong, because 28 clears the 14-day floor. The failure sits +one step ahead: every new `DEVA_AUTH_TAG` starts an empty directory for +an account with ten months of samples one level up, and renders "still +learning" for two weeks. The readers now take the union — root plus +every `accounts/*/` — and partition by `user.uuid`, which is the rule +the state-dir contract already stated and ccpace already followed. The +union is safe for this data because every quantity is envelope-based: +the same window seen from two containers takes a max, never a sum. +`week.cache` keys on every store's mtime:size so a sample landing +anywhere invalidates it. Cost: 1.2 s per hourly rebuild over 31 MiB. + +**`forecast.cache` says which samples it stands on.** `schema` versions +the model and cannot: statusline and ccpace agree on envelope burn, read +different stores, both pass the gate, and `days_history` — the number +that decides whether a forecast speaks at all — depended on which binary +rendered last. A `corpus` stamp (`uuid`, `files`, `samples`, +`dropped_no_uuid`, `oldest`) makes that visible in one `jq`. It is +informative, not a gate; ccpace v0.3.1 writes the same one. + +**A dropped row is counted.** 94 rows in that store carry no +`user.uuid` (the field is younger than the log) and every +uuid-partitioned reader discarded them in silence. They stay dropped — +thirteen carry an email that would identify them, and guessing identity +on a log that already interleaves accounts is how a 9000%/day burn rate +gets manufactured — but the count is in the stamp. Loss is acceptable; +silent loss is not. + +**`session.project`.** Each `usage` line's session block now carries +the basename of the project directory. The quota log knows percent, the +transcript knows tokens, and neither knew which repo the week went to; +this is the one dimension a breakdown cannot recover later. Basename +only, never the path. + +**The ledger is one typeface again.** The baseline — a cell that ran and +cost nothing — was `ˍ` (U+02CD MODIFIER LETTER LOW MACRON). Every bar +above it comes from Block Elements, and terminals resolve the two through +different faces: the zero line sat at a different height and a different +advance width than the bars beside it, and the seam showed on every row +that held both. It is `▁` now, the shortest bar of the same run, so the +whole ladder is one block. Burn therefore starts one rung up, at `▂`; +`▅` and everything above it keep their old thresholds, so a fully burned +window still reads the same height it always did. `LEDGER_BASE_GLYPH` +overrides it. + +**A shared cache needs a version, not just a timestamp.** `forecast.cache` +lives in `~/.claude/statusline/`, which is a published shared store, and +it is the one derived file more than one tool wants to write. A +co-writer that counted burn as the sum of raw positive deltas — the +accounting this script abandoned two releases ago, because a stale dip +gets refunded and then re-earned — published a profile with `149.11` +into Thursday and dropped `pct_per_window`, `scoped_*` and `cost` on the +way past. + +Everything downstream did the right thing and the result was still +wrong. The walk's corrupt-profile guard saw 149%/day, judged it +impossible and went silent. The exchange rate, the price and the scoped +forecast lost their inputs and said "still learning". The budget line +fell back to linear pace. Nothing lied; the account simply had a sound +profile ten minutes earlier and no way back to it, because the rebuild +gate asked only whether the file was fresh — and it was, having been +overwritten seconds ago. Silence is a defence against a bad model. It is +not a substitute for knowing whose model you are reading. + +So the cache carries `schema` now, the version of the MODEL rather than +of the file, and freshness is necessary instead of sufficient: a cache +whose schema is missing or lower is rebuilt on sight. The contract for +co-writers is written down (`docs/api/state-dir.md`) and it cuts both +ways — stamp the schema you actually implement, and either write the +full key set or merge into what is there. Rebuilding the fields you know +and dropping the rest is a truncating write. + +**`heading` was neither.** The budget line carried two futures with no +grammar to tell them apart: `even 6.2%/win` is a RATION — spend that per +window and the pool lands exactly on 100 — and the other number is a +PREDICTION of where your own pattern takes you. A direction is not a +destination, and the reader was left working out which of the two was +the forecast. It reads `lands ~91%` now, in the budget line and in +`report`'s week-in-progress alike. + +Row 2 states the landing rather than the ration. Of the three clauses +the long form carries it is the only one not already on screen: the +count is drawn on the strip beside it as `...▯(✕9)` and the ration is +surplus ÷ that count, but where the week ends up is nowhere else. It is +also the clause that answers what a calm week actually asks — not "how +do I ration this" but "am I going to strand it". With no projection yet +(a young week, a cold profile) there is no landing to state and the row +falls back to the ration. ## v0.34.0 — 2026-08-24 — five slots, and a window you are in is not one you have left diff --git a/DESIGN.md b/DESIGN.md index a454529..0864b41 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -82,7 +82,7 @@ own signal. ## The ledgers (row 2) ``` -5h ▃▄▮▯▯ 0.6x @04:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▮▯▯...▯(x14) 0.7x @Wed 09:00 +5h ▄▄▮▯▯ 0.6x @04:00 7d ▅▂▃ ▄▅▁▄▅ ▄▄▂▃▂ ▮▯▯...▯(x14) 0.7x @Wed 09:00 ``` One grammar, two scales. `5h` = this window as five hour cells; `7d` = @@ -109,8 +109,8 @@ window has run) and the reset its right edge is — axis labels, not restated badges. ``` -▁▂▃▄▅▆▇█ burned; height = points that cell cost (▁ ≤2 … ▅ ≤11 … █ >20) -ˍ ran, negligible — a bar of height zero, on the baseline +▂▃▄▅▆▇█ burned; height = points that cell cost (▂ ≤2 … ▅ ≤11 … █ >20) +▁ baseline: ran, negligible — the shortest bar of the same block ░ unknown — no sample; never drawn as idle ▮ now ▯ ahead — the hollow of ▮; on 5h, the hours left in the window diff --git a/README.md b/README.md index 0556a54..5ffd50e 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Every component earns its place: | User tier | Neutral white-weight (MAX bold, PRO normal, dim otherwise) — identity, never a status color. Truncated display name. | | Quota | Integer percentages. The 5h badge always carries its reset time while a window is live — `5h[42%@14:30]` reads "42% used, resets at 14:30" — because on a 5h horizon the reset is the number you plan the current sitting around. Wall-clock, not a countdown, on purpose: Claude Code only re-renders the statusline on activity, so a relative "@1h38m" silently decays into a lie during idle gaps, while "@14:30" stays true in a frozen frame. (The 7d badge is hybrid: day-relative `@5d` while the reset is >= 24h out — decays one day per day, mild and narrow — switching to the same wall-clock `@04:00` inside the last day, where an `@6h`/`@<1h` countdown decayed by the hour exactly when pressure keeps the suffix visible.) When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@14:32]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows when relief arrives: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now"; inside the last day it reads `7d[92%@04:00]` — resets at 04:00. Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. **Model-scoped weekly quota**: when the usage API carries a per-model weekly limit (`limits[]`, `kind=weekly_scoped`) for the model your session is running, it renders right after the model+context block — `fabl5[1m][12%] fb[67%]` on a Fable 5 session, `op[33%]` on Opus — because the quota is a property of the model you're running, not of the account-wide 5h/7d cluster. It's a weekly number (same reset as the 7d badge), scoped to one model. Other models' scoped quotas stay hidden: only the limit constraining *this* session is signal. Supersedes the legacy `seven_day_opus`/`seven_day_sonnet` fields, which the API now sends as null. | | Extra usage | Monthly spend, limit, prepaid balance. `--extra auto` shows when quota runs out. | -| Week row | **A row of its own, under the badges**: `5h ▅█▃▮▯ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ …▮▯▯...▯(✕19) 0.7✕ @Wed 09:00` — this sitting hour by hour on a fixed five-slot axis (the hollow run is the hours left), the week as its 5h windows (day-gapped, the far future folded to a counted `...▯(✕19)`), height = what each cell burned, `▮` = now. `×` marks where the pool runs dry on the week; each strip ends with its pace and reset. Reconstructed from your own usage log; `auto` shows it once there is history to show. See [Week row](#week-row). | +| Week row | **A row of its own, under the badges**: `5h ▅█▄▮▯ 0.9✕ @23:00 7d ▅▂▃ ▄▅▁▄▅ …▮▯▯...▯(✕19) 0.7✕ @Wed 09:00` — this sitting hour by hour on a fixed five-slot axis (the hollow run is the hours left), the week as its 5h windows (day-gapped, the far future folded to a counted `...▯(✕19)`), height = what each cell burned, `▮` = now. `×` marks where the pool runs dry on the week; each strip ends with its pace and reset. Reconstructed from your own usage log; `auto` shows it once there is history to show. See [Week row](#week-row). | | Deadman | **Invisible until a switch is armed.** Surfaces [deadman](https://github.com/thevibeworks/deadman) — a dead man's switch that hands the session off when you stop responding. `[☠ armed 42m]` (dim) counts down to the auto-handoff; `[☠ warned 3m]` (yellow) means the phone warning went out; `[☠ due]` means the handoff fires imminently. Sits on the left lane next to the path — it describes this session's lifecycle, not a quota. One `command -v` when the tool is absent, one fast file read when present; nothing armed renders nothing. `--deadman off` disables it. | | Cache health | **Quiet until it bites.** Claude Code never re-renders an idle session, and while you work the prompt cache is always freshly ~1 TTL from expiry — so a proactive "expiring soon" isn't honestly observable, and `auto` spends no width on it. It speaks only when a rewrite actually happens: `≡!419k` the instant you resume onto a dead cache (idle longer than the TTL) or a mid-session prefix collapse — a 419k-token re-cache at ~20x the read rate (and the same burn on your 5h/7d quota on subscriptions). **Bold red past 200k** — the premium-band miss. `≡~` while a large prefix rebuilds. `--cache always` additionally keeps the freeze-safe deadline `≡@15:20` (last request + TTL; a past time in a frozen frame reads "expired at 15:20"). TTL defaults to 1h (claude.ai subscriber sessions) or 5m (API-key auth); an observed usage breakdown overrides it. The `≡` glyph (U+2261) reads as stacked cache layers — one terminal column, quiet and distinct. | @@ -273,7 +273,7 @@ right edge shared with line 1: ```text proj (main*) +84/-14 8m $6.72 fabl5[1m][██░░42%] fb[66%] [MAX|@work] 5h[38%@23:00] 7d[39%] -3✕5h left · 20%/win 5h ▅█▃▮▯ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 +3✕5h left · lands ~52% 5h ▅█▄▮▯ 0.9✕ 7d ▅▂▃ ▄▅▁▄▅ ▄▄▂▃▂ ▅▁▃▂▂ ▁▁▁▄▅ ▁▁▁▅ ▆▆▁▃▮▯▯ 0.7✕ @Wed 09:00 ``` The 5h strip prints no reset: line 1's `5h[38%@23:00]` already carries @@ -290,8 +290,8 @@ edge and the full sentence sits flush-left beneath them: ```text proj (main*) fabl5[1m][██░░42%] fb[66%] [MAX|@work] 5h[38%@23:00] 7d[39%] - 5h ▅█▃▮▯ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 - budget ~3✕5h left · even 20%/win · heading ~52% + 5h ▅█▄▮▯ 0.9✕ @23:00 7d ▅▂▃ ▄▅▁▄▅ ▄▄▂▃▂ ▅▁▃▂▂ ▁▁▁▄▅ ▁▁▁▅ ▆▆▁▃▮▯▯ 0.7✕ @Wed 09:00 + budget ~3✕5h left · even 20%/win · lands ~52% ``` Each strip ends with its **pace** (used ÷ elapsed: `0.7✕` is on track, @@ -329,8 +329,8 @@ fire. | Cell | Meaning | |------|---------| -| `▁▂▃▄▅▆▇█` | a cell that ran; height is the points it burned (`▁` <= 2, `▅` <= 11, `█` > 20) — the same scale in both strips, so a full window and a full week read the same height | -| `ˍ` | ran, cost under a point — or ran idle inside the log's coverage; a bar of height zero, on the baseline | +| `▂▃▄▅▆▇█` | a cell that ran; height is the points it burned (`▂` <= 2, `▅` <= 11, `█` > 20) — the same scale in both strips, so a full window and a full week read the same height | +| `▁` | the baseline: ran and cost under a point, or ran idle inside the log's coverage. The shortest bar of the same Block Elements run as `▂▃▄`, so the zero line shares their font, width and height — it used to be `ˍ` (U+02CD), a modifier *letter*, which terminals resolve through the text face and which broke the strip's metrics mid-row. Override with `LEDGER_BASE_GLYPH` | | `░` | unknown: the log has no sample for that cell (never drawn as idle — a gap in the record is not a quiet session) | | `▮` | the cell you are in now | | `▯` | a cell still ahead of you — the hollow of `▮`, an empty slot waiting. On the 5h strip the run of them is the hours left in the window | @@ -363,7 +363,7 @@ long one: ```text proj (main*) +84/-14 8m $6.72 fabl5[1m][██░░42%] fb[91%] [MAX|@work] 5h[38%@23:00] 7d[55%] -+ fb 91% vs 7d 55% · go op 5h ▅█▃▮▯ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ … ▆▆ˍ▂▮▯▯ 0.6✕ @Wed 09:00 ++ fb 91% vs 7d 55% · go op 5h ▅█▄▮▯ 0.9✕ 7d ▅▂▃ ▄▅▁▄▅ … ▆▆▁▃▮▯▯ 0.6✕ @Wed 09:00 + fb weekly 91% against 7d 55% · the model caps first, not the account; op sits at 33%, so run it for the bulk ``` @@ -398,7 +398,7 @@ clauses about one window can never disagree. | `+ 5h ~40m left · 70% unused` | Said only when the *week* is stranding capacity — an unspent 5h window is otherwise headroom, not waste, since the 5h window is a rate limit and not a budget. | | `+ ~62% will expire · go heavier` | On pace to strand a large chunk of the subscription. Speaks only to an engaged, unsqueezed session. | | `+ work 5h[8%] free` | A sibling account in the same shared home is idle while this one is pinned. | -| `19✕5h left · 1.1%/win` | The calm budget: runway, what even looks like, where you land (long form). 19 counts the windows **after** the one you are in — the window you are inside is where you are, not what you have left, and it is already drawn as `▮` and priced by `5h[38%]`. The count holds steady inside a window and steps down by one at each rollover. It wears no sigil — `!` and `+` interrupt, the week's resting reading does not. | +| `19✕5h left · lands ~52%` | The calm budget: runway plus the destination. The long form carries all three clauses — `budget ~19✕5h left · even 1.1%/win · lands ~52%` — and names each for what it is: `even N%/win` is the **ration** (spend that per window and the pool lands exactly on 100), `lands ~N%` is the **prediction** (spend like you have been and you end up here). Row 2 keeps the landing, because the count is already drawn on the strip beside it and the ration is surplus ÷ count, but where the week ends up is nowhere else on screen; with no projection yet the row falls back to the ration. 19 counts the windows **after** the one you are in — the window you are inside is where you are, not what you have left, and it is already drawn as `▮` and priced by `5h[38%]`. The count holds steady inside a window and steps down by one at each rollover. It wears no sigil — `!` and `+` interrupt, the week's resting reading does not. | `--notice off` keeps row 3 quiet; `--advisor off` silences both. `--check` and `--week` print the long form, since a terminal command has a whole diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index bb870fc..a7113e6 100644 --- a/docs/api/state-dir.md +++ b/docs/api/state-dir.md @@ -7,7 +7,7 @@ read concurrently. - Contract version: **2** (bump on any breaking layout/field change; this file is the changelog) -- Synced with: statusline.sh v0.29.0 +- Synced with: statusline.sh v0.35.0 - Permissions: the script runs under `umask 077` — files are owner-only. Caches hold account PII (email, uuid, org names). @@ -37,8 +37,17 @@ a writer that cannot honor all of them must use its own dir instead: `profile.cache` likewise (raw profile; mtime is the fetch time, 24 h TTL). `*.lock` files are advisory between statusline processes; cross-tool safety comes from atomic rename. -- **Partition by uuid.** Aggregating readers (forecast, report) filter - on `user.uuid`, never trust directory placement alone. +- **Partition by uuid, read the union.** A directory is where a sample + LANDED, not who it belongs to: the same account reaches the top level + when an untagged statusline fetched and `accounts//` when a + tagged container did (measured: 301 days at the root, 28 in the tagged + dir, one uuid). Aggregating readers (forecast, ledger, report) read + every store under the root — top level plus every `accounts/*/` — and + filter on `user.uuid`; they never trust placement alone. The union is + safe because every derived quantity is envelope-based (the same window + seen twice takes a max, never a sum); it would not be for a sum of + deltas. Rows with no `user.uuid` are dropped and counted + (`corpus.dropped_no_uuid`), never attributed by guess. ## Layout @@ -102,16 +111,49 @@ at most daily, only from the usage fetch path. Output of the hourly usage.jsonl scan (EWMA half-life 14 days): ```json -{"computed_at": 1785000000, "days_history": 21, +{"schema": 2, "computed_at": 1785000000, "days_history": 21, "recent_24h": 14.20, "recent_48h": 22.10, "pct_per_window": 11.83, "weekday_profile": {"0": 5.1, "1": 27.3, "…": 0, "6": -1}, "scoped_name": "Fable", "scoped_recent_24h": 76.00, "scoped_profile": {"0": 5.7, "1": 23.5, "…": 0, "6": -1}, "cost": {"usd_24h": 12.40, "usd_7d": 84.10, - "usd_per_pct": 1.6820, "paired_pct": 50.0}} + "usd_per_pct": 1.6820, "paired_pct": 50.0}, + "corpus": {"uuid": "…", "files": 5, "samples": 26081, + "dropped_no_uuid": 94, "oldest": 1761542968}} ``` +`corpus` says WHICH samples the model was run over — `schema` cannot. +Two writers that agree on envelope burn and read different stores both +pass the schema gate, and `days_history` (which decides whether the +forecast speaks at all) then depends on which binary rendered last. +The stamp is informative, not a gate: a reader that wants to know why +two caches disagree reads it; a rebuild overwrites it. + +#### The co-writer contract + +`schema` is the version of the MODEL, not of the file: bump it when how +burn is counted or what a profile value means changes; adding a field +does not, because readers already tolerate a missing one via `// -1`. + +This is the one derived cache more than one tool wants to write, so the +rule is explicit and it cuts both ways: + +- **Writers** stamp the schema they actually implement, and either emit + the full key set or merge into what is already there. Rebuilding only + the fields you know and dropping the rest is a truncating write. +- **Readers** treat freshness as necessary, not sufficient. A cache whose + `schema` is missing or lower than the reader's own gets rebuilt on + sight, no matter how recently it was written. + +Freshness alone was the gate until a co-writer that summed raw positive +deltas published `149.11` into Thursday and dropped `pct_per_window`, +`scoped_*` and `cost` on the way past. The walk's corrupt-profile guard +caught the 149 and went silent — the right reflex, the wrong resting +state: the account had a sound profile ten minutes earlier and no way +back to it until the hour turned. Silence is a defence against a bad +model; it is not a substitute for knowing whose model you are reading. + `weekday_profile` keys are days-of-week `0`=Sun..`6`=Sat, values are percent-of-7d-quota burned per day; `-1` = never observed. `pct_per_window` is the learned cross-window exchange rate: 7d percentage @@ -226,6 +268,7 @@ render, for free. Both writers (`api` and `source:"stdin"`) carry it; | `session.effort` | string \| null | reasoning effort (`low`…`max`) | | `session.fast` | bool | fast mode | | `session.cli` | string \| null | Claude Code version, so a schema change is datable | +| `session.project` | string \| null | basename of `workspace.project_dir` (else `cwd`) — the one dimension a breakdown cannot recover later; never the full path | **Aggregating cost.** `cost_usd` is per session and cumulative, and consecutive samples routinely come from different sessions. Account @@ -291,7 +334,9 @@ infers them: ## Consumer rules 1. Read-only. Locks, TTLs, and rotation are the writer's job. -2. Key on `accounts//` when present; fall back to the top level. +2. Key on `accounts//` for the live caches (`usage.cache`, + `profile.cache`, `forecast.cache`); for history, read every + `usage.jsonl(.1)` under the root and partition by `user.uuid`. 3. Trust `fetched_at`/`timestamp`, not file mtimes (migrations preserve content, not mtime). 4. Tolerate absent files — every file appears lazily on first use. diff --git a/statusline.sh b/statusline.sh index 79390d5..0a30c81 100755 --- a/statusline.sh +++ b/statusline.sh @@ -93,6 +93,16 @@ CACHE_GLYPH="${CACHE_GLYPH:-≡}" # ✖ (U+2716) is Emoji=Yes (colour-font fallback); either would overhang row 2. MULT_GLYPH="${MULT_GLYPH:-✕}" +# The ledger strips' baseline: a cell that ran and cost nothing. ▁ (U+2581 +# LOWER ONE EIGHTH BLOCK) is the shortest bar in the same Block Elements run +# as ▂▃▄▅▆▇█, so the zero line and the bars share one font, one advance width +# and one baseline. The old baseline was ˍ (U+02CD MODIFIER LETTER LOW +# MACRON): a spacing modifier LETTER, resolved through the text font while +# the bars fall back to the box-drawing face — visibly a different height and +# width mid-strip. Burn therefore starts one rung up, at ▂; the ladder above +# ▅ is untouched, so a fully burned window still reads the same. +LEDGER_BASE_GLYPH="${LEDGER_BASE_GLYPH:-▁}" + # Unobserved-TTL default for the cache expiry deadline. Mirrors the CLI's own # rule (should1hCacheTTL, CC source): claude.ai subscribers in the REPL get # cache_control ttl:"1h" (confirmed on every breakpoint in live traces); @@ -1088,18 +1098,27 @@ fetch_prepaid_balance() { STDIN_LOG_MIN_SECS=60 session_telemetry_json() { [ -n "${cost_usd:-}" ] || [ -n "${ctx_total_in:-}" ] || return 0 + # The project is the one dimension a breakdown by session cannot recover + # later: the transcript knows tokens, the quota log knows percent, and + # neither knows which repo the week went to. Basename only — the log is + # owner-readable but a full path is a machine fingerprint the store has + # no use for. + local proj="${project_dir:-${cwd:-}}" + proj="${proj%/}"; proj="${proj##*/}" jq -nc --arg cost "${cost_usd:-}" --arg dur "${duration_ms:-}" \ --arg api "${api_duration_ms:-}" --arg la "${lines_added:-}" \ --arg ld "${lines_removed:-}" --arg cin "${ctx_total_in:-}" \ --arg csz "${ctx_size:-}" --arg eff "${effort_level:-}" \ - --arg fast "${fast_mode:-}" --arg cli "${cli_version:-}" ' + --arg fast "${fast_mode:-}" --arg cli "${cli_version:-}" \ + --arg proj "$proj" ' def num: tonumber? // null; {cost_usd: ($cost|num), dur_ms: ($dur|num), api_ms: ($api|num), lines_add: ($la|num), lines_del: ($ld|num), ctx_in: ($cin|num), ctx_size: ($csz|num), effort: (if $eff == "" then null else $eff end), fast: ($fast == "true"), - cli: (if $cli == "" then null else $cli end)}' 2>/dev/null + cli: (if $cli == "" then null else $cli end), + project: (if $proj == "" then null else $proj end)}' 2>/dev/null } log_stdin_snapshot() { @@ -2016,6 +2035,60 @@ get_seven_day_color() { USAGE_LOG_MAX_BYTES="${USAGE_LOG_MAX_BYTES:-33554432}" # 32 MiB cap +# Every store this account may have been written to. A directory is where a +# sample LANDED, not who it belongs to: the same uuid reaches the root when +# an untagged statusline fetched, accounts// when a deva-tagged +# container did — and a fresh tag starts an EMPTY directory for an account +# with ten months of history one level up, so it renders "still learning" +# for two weeks. Measured on one machine: 301 days at the root, 28 in the +# tagged dir, same uuid. Aggregating readers (forecast, ledger, report) +# therefore read the union — root + every accounts/*/ — and partition by +# user.uuid (state-dir contract v2, "never trust placement"); the +# per-directory scope was the thing that fragmented history to begin with. +# The union is safe for THIS data because every quantity is envelope-based: +# the same window seen from two containers takes a max, never a sum. +# Without a data root (function-level tests) it is just the account dir. +# Prints paths, `.1` backups first, one per line. +usage_corpus_files() { + local base f seen=" " + for base in "${CLAUDE_DATA_DIR:-}" "${CLAUDE_DATA_DIR:+$CLAUDE_DATA_DIR/accounts/}"*/ "$CLAUDE_ACCOUNT_DIR"; do + base="${base%/}" + [ -n "$base" ] && [ -d "$base" ] || continue + case "$seen" in *" $base "*) continue ;; esac + seen="$seen$base " + for f in "$base/usage.jsonl.1" "$base/usage.jsonl"; do + [ -f "$f" ] && printf '%s\n' "$f" + done + done + return 0 +} +cat_usage_corpus() { + local f + usage_corpus_files | while IFS= read -r f; do cat "$f" 2>/dev/null; done + return 0 +} +# mtime:size of every corpus file, one token — the cache key a derived file +# (week.cache) uses to notice a sample landing in ANY store. +usage_corpus_sig() { + local f sig="" + while IFS= read -r f; do + sig="$sig$(stat -c %Y:%s "$f" 2>/dev/null || stat -f %m:%z "$f" 2>/dev/null || echo 0)," + done <<<"$(usage_corpus_files)" + printf '%s' "${sig:-0}" +} + +# forecast.cache contract version. Bump when the MODEL changes (how burn is +# counted, what a profile value means) — not when a field is added, which +# readers already tolerate via `// -1`. +# 2 envelope burn + the full key set (pct_per_window, scoped_*, cost) +# - absent/lower: an unversioned or partial cache. Rebuild on sight. +# `~/.claude/statusline/` is a shared store (docs/api/state-dir.md) and this +# file is the one derived cache more than one tool wants to write. The +# version is what lets a reader tell "computed an hour ago by a writer that +# counts burn the way I do" from "recently overwritten by one that does +# not", without either tool having to know the other exists. +FORECAST_SCHEMA=2 + # Rotate usage.jsonl when it exceeds the cap. Single .1 backup; the profile # builder reads .1 + current, so rotation never costs learned history. rotate_usage_log() { @@ -2034,29 +2107,45 @@ rotate_usage_log() { # Rebuild forecast.cache from usage.jsonl (at most hourly; called from the # fetch path, which is itself TTL-gated). Output: one small JSON the renders # can read for free: -# { computed_at, days_history, recent_24h, recent_48h, -# weekday_profile: {"0":sun.. "6":sat, unknown days = -1} } -# Daily burn = sum of positive deltas of seven_day.utilization within a local -# calendar day (negative deltas = window reset; ignored by construction). +# { schema, computed_at, days_history, recent_24h, recent_48h, +# pct_per_window, weekday_profile: {"0":sun.. "6":sat, unknown = -1}, +# scoped_name, scoped_recent_24h, scoped_profile, cost } +# Daily burn is the rise of a MONOTONE ENVELOPE, never the sum of raw +# positive deltas (see seven_env below and docs/api/state-dir.md). build_seven_day_profile() { - local jsonl="$CLAUDE_ACCOUNT_DIR/usage.jsonl" local out="$CLAUDE_ACCOUNT_DIR/forecast.cache" - [ -f "$jsonl" ] || return 0 + local corpus_files + corpus_files=$(usage_corpus_files) + [ -n "$corpus_files" ] || return 0 + local nfiles + nfiles=$(printf '%s\n' "$corpus_files" | grep -c .) local acct acct=$(jq -r '.account.uuid // empty' "$CLAUDE_ACCOUNT_DIR/profile.cache" 2>/dev/null) [ -n "$acct" ] || return 0 + # Freshness is not enough: this cache is SHARED, and an hour of trusting + # a foreign shape is an hour of forecasting from it. A cooperating writer + # that keeps the contract stamps its own FORECAST_SCHEMA and preserves + # keys it does not compute; anything else — an older statusline, a tool + # that rebuilt only the fields it knew, a truncated write — is a cache + # this build must replace NOW, not in 59 minutes. Measured: a co-writer + # summing raw deltas published a profile with 149%/day in it and dropped + # pct_per_window, scoped_* and cost on the way past. The walk's + # corrupt-profile guard caught the 149 and went silent, which is the + # right reflex and the wrong resting state — the account had a good + # profile ten minutes earlier and no way back to it until the hour + # turned. if [ -f "$out" ]; then - local computed age - computed=$(jq -r '.computed_at // 0' "$out" 2>/dev/null) + local computed schema age + IFS=$'\t' read -r computed schema <<<"$(jq -r '[(.computed_at // 0), (.schema // 0)] | @tsv' "$out" 2>/dev/null)" age=$(( $(date +%s) - ${computed:-0} )) - [ "$age" -lt 3600 ] 2>/dev/null && return 0 + [ "${schema:-0}" = "$FORECAST_SCHEMA" ] && [ "$age" -lt 3600 ] 2>/dev/null && return 0 fi local now tzoff_s now=$(date +%s) # ±HHMM -> signed seconds (portable; date +%z works on GNU and BSD) tzoff_s=$(date +%z | awk '{ s=substr($0,1,1)=="-"?-1:1; h=substr($0,2,2)+0; m=substr($0,4,2)+0; print s*(h*3600+m*60) }') local data - data=$( { cat "${jsonl}.1" 2>/dev/null; cat "$jsonl"; } | jq -r --arg a "$acct" ' + data=$( cat_usage_corpus | jq -r --arg a "$acct" ' # Window identity for the ratio pairs: resets_at wobbles per # fetch (microseconds, and 06:59:59 vs 07:00:00 across the # boundary), so normalize to the nearest minute. Non-ISO values @@ -2068,15 +2157,26 @@ build_seven_day_profile() { (try (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | fromdateiso8601 | (. + 30) / 60 | floor) catch $raw) end; - select((.user.uuid // "") == $a) - | ([.limits[]? | select(.kind == "weekly_scoped")] | first) as $sc - | [.timestamp, (.seven_day.utilization // ""), - (.five_hour.utilization // ""), - ((.five_hour.resets_at // "") | norm), - ((.seven_day.resets_at // "") | norm), - ($sc.percent // ""), ($sc.scope.model.display_name // ""), - (.session.cost_usd // ""), (.session_id // "")] | @tsv' 2>/dev/null \ - | sort -n | awk -F'\t' -v now="$now" -v tz="$tzoff_s" ' + select((.type // "usage") == "usage" and (.timestamp // null) != null) + # Rows of other accounts and rows with no uuid are dropped — + # and COUNTED, as X-lines the reducer tallies into the corpus + # stamp. A uuid-less row is refused, never guessed (some carry an + # email that would identify them; guessing identity on a log + # that interleaves accounts is how a 9000%/day burn rate gets + # manufactured), but a reader that discards identifiable + # observations silently will discard more just as quietly. + | if (.user.uuid // "") == $a then + ([.limits[]? | select(.kind == "weekly_scoped")] | first) as $sc + | [.timestamp, (.seven_day.utilization // ""), + (.five_hour.utilization // ""), + ((.five_hour.resets_at // "") | norm), + ((.seven_day.resets_at // "") | norm), + ($sc.percent // ""), ($sc.scope.model.display_name // ""), + (.session.cost_usd // ""), (.session_id // "")] | @tsv + elif (.user.uuid // "") == "" then "X\tnouuid" + else "X\tother" end' 2>/dev/null \ + | sort -n | awk -F'\t' -v now="$now" -v tz="$tzoff_s" -v schema="$FORECAST_SCHEMA" \ + -v acct="$acct" -v nfiles="$nfiles" ' # Burn is the rise of a MONOTONE ENVELOPE, not the rise of the last # sample. Summing raw positive deltas counts every stale dip twice: # measured, that read 146 points of "burn" against a real 50-point @@ -2164,7 +2264,13 @@ build_seven_day_profile() { if (now - ts <= 604800) u7d += d } BEGIN { RESET_CONFIRM = 2; RESET_DROP = 15; swin = -1 } + $1 == "X" { if ($2 == "nouuid") drop_nu++; else drop_other++; next } + # The union can hold the same observation twice (a fetch logged by + # two writers before the pool existed); sorted, repeats are adjacent. + $0 == prev_line { next } { + prev_line = $0 + nsamp++; if (oldest == "") oldest = $1 credited = 0 if ($2 != "") seven_env($1, $2 + 0, $5) if ($6 != "" && $7 != "") scoped_env($1, $6 + 0, $7, $5) @@ -2215,7 +2321,7 @@ build_seven_day_profile() { dw = (kp[2] + 4) % 7 cnum[dw] += cburn[k] * w; cden[dw] += w } - printf "{\"computed_at\":%d,\"days_history\":%d,", now, ndays + printf "{\"schema\":%d,\"computed_at\":%d,\"days_history\":%d,", schema, now, ndays printf "\"recent_24h\":%.2f,\"recent_48h\":%.2f,", r24, r48 printf "\"pct_per_window\":%.2f,", ppw printf "\"weekday_profile\":{" @@ -2239,13 +2345,18 @@ build_seven_day_profile() { # mined from two samples is a rumour, not a rate. upp = -1 if (usd_all > 0 && burn_paired >= 5) upp = usd_all / burn_paired - printf "\"cost\":{\"usd_24h\":%.2f,\"usd_7d\":%.2f,\"usd_per_pct\":%.4f,\"paired_pct\":%.1f}", \ + printf "\"cost\":{\"usd_24h\":%.2f,\"usd_7d\":%.2f,\"usd_per_pct\":%.4f,\"paired_pct\":%.1f},", \ u24, u7d, upp, burn_paired + # Which samples this model was run over. `schema` versions the + # model and cannot say: two writers that agree on envelope burn + # and read different stores both pass the gate. + printf "\"corpus\":{\"uuid\":\"%s\",\"files\":%d,\"samples\":%d,\"dropped_no_uuid\":%d,\"oldest\":%d}", \ + acct, nfiles, nsamp, drop_nu, (oldest == "" ? 0 : oldest) printf "}\n" }') if [ -n "$data" ]; then printf '%s\n' "$data" >"${out}.tmp.$$" 2>/dev/null && mv -f "${out}.tmp.$$" "$out" - debug_log "build_seven_day_profile: rebuilt ($(echo "$data" | jq -r '.days_history') days, acct=${acct:0:8})" + debug_log "build_seven_day_profile: rebuilt ($(echo "$data" | jq -r '"\(.days_history) days, \(.corpus.samples) samples in \(.corpus.files) files, \(.corpus.dropped_no_uuid) no-uuid dropped"'), acct=${acct:0:8})" fi } @@ -2406,7 +2517,7 @@ run_usage_report() { case "$days" in '' | *[!0-9]*) days=28 ;; esac [ "$days" -ge 1 ] 2>/dev/null || days=28 local jsonl="$CLAUDE_ACCOUNT_DIR/usage.jsonl" - if [ ! -f "$jsonl" ] && [ ! -f "${jsonl}.1" ]; then + if [ -z "$(usage_corpus_files)" ]; then echo "no usage history yet ($jsonl)" echo "the statusline logs every quota fetch; come back after a session or two." return 1 @@ -2422,7 +2533,7 @@ run_usage_report() { # Mined stream: "S <7d-close-key> " per closed 7d window, # then "F " for 5h windows, "N ". local mined - mined=$( { cat "${jsonl}.1" 2>/dev/null; cat "$jsonl" 2>/dev/null; } | jq -r --arg a "$acct" --argjson cut "$cutoff" ' + mined=$( cat_usage_corpus | jq -r --arg a "$acct" --argjson cut "$cutoff" ' def norm: tostring | . as $raw | if $raw == "" then "" else (try (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") @@ -2532,7 +2643,7 @@ run_usage_report() { when=$(_fmt_epoch "${repoch:-0}" '%a %m-%d %H:%M') read -r gap end <<<"$(_seven_day_walk "$cur_su" "${secs:-0}")" if [ -n "$end" ]; then - printf '\nweek in progress: %.0f%% used, resets %s - heading ~%s%%\n' \ + printf '\nweek in progress: %.0f%% used, resets %s - lands ~%s%%\n' \ "$cur_su" "$when" "$end" else printf '\nweek in progress: %.0f%% used, resets %s\n' "$cur_su" "$when" @@ -2647,8 +2758,8 @@ run_session_summary() { } # `statusline.sh week` — the 7d period as its 5h windows, one cell each: -# ▁▂▃▄▅▆▇█ a window that ran; height = 7d points it burned -# · ran, burned under 1% +# ▂▃▄▅▆▇█ a window that ran; height = 7d points it burned +# ▁ ran, burned under a point — the baseline # ░ unknown: no samples on record for that window # ▮ the window you are in now # ▯ a window still ahead of you @@ -2666,10 +2777,12 @@ run_session_summary() { # period (34; the last a 3h stub) on a fixed grid from the period start, a # thin gap at each local midnight so days read as clusters without a ruler. # The 5h strip: one cell per hour of the CURRENT 5h window (5 cells). -# ▁▂▃▄▅▆▇█ a cell that ran, height ∝ points it burned (7d points per +# ▂▃▄▅▆▇█ a cell that ran, height ∝ points it burned (7d points per # 5h window; 5h points per hour) -# ˍ ran, burned under a point, or idle inside the log's coverage — a -# bar of height zero, on the baseline +# ▁ ran, burned under a point, or idle inside the log's coverage — +# the shortest bar in the same block, dim: the baseline. One +# Unicode run for the whole ladder (LEDGER_BASE_GLYPH); a +# modifier letter used to sit here and broke the row's metrics. # ░ unknown — outside the sample log's coverage # ▮ the cell you are in now # ▯ a cell still ahead of you (the hollow of ▮: an empty slot) @@ -2756,10 +2869,10 @@ windows_ahead() { # step credited to the hour cell the sample fell in. week_scan() { local period_start="$1" five_start="${2:-0}" - local ujl="$CLAUDE_ACCOUNT_DIR/usage.jsonl" wc="$CLAUDE_ACCOUNT_DIR/week.cache" - [ -f "$ujl" ] || [ -f "${ujl}.1" ] || return 0 + local wc="$CLAUDE_ACCOUNT_DIR/week.cache" + [ -n "$(usage_corpus_files)" ] || return 0 local sig - sig="$(stat -c %Y:%s "$ujl" 2>/dev/null || stat -f %m:%z "$ujl" 2>/dev/null || echo 0)" + sig="$(usage_corpus_sig)" if [ -f "$wc" ]; then local c_ps c_fs c_sig c_at c_week c_five eval "$(jq -r '@sh "c_ps=\(.period_start // 0)", @sh "c_fs=\(.five_start // 0)", @@ -2782,7 +2895,7 @@ week_scan() { local acct="" acct=$(jq -r '.account.uuid // empty' "$CLAUDE_ACCOUNT_DIR/profile.cache" 2>/dev/null) || true local scan week five - scan=$( { cat "${ujl}.1" 2>/dev/null; cat "$ujl" 2>/dev/null; } \ + scan=$( cat_usage_corpus \ | jq -sr --argjson ps "$period_start" --argjson w "$WEEK_CELLS" \ --arg acct "$acct" \ --argjson fs "$five_start" --argjson fw "$FIVE_CELLS" --argjson fc "$FIVE_CELL_SECS" ' @@ -2884,11 +2997,15 @@ build_ledger_strip() { -v nsin="$nowslot_in" \ -v lo="${span_lo:--1}" -v hi="${span_hi:--1}" -v cells="$cells" \ -v C_FILL="$fill_color" -v C_DIM="$DIM" -v C_NOW="$BOLD" \ - -v C_DRY="$RED" -v C_OFF="$RESET" ' + -v C_DRY="$RED" -v C_OFF="$RESET" -v BASE="$LEDGER_BASE_GLYPH" ' + # One ladder, one Unicode block: BASE (▁) is the zero line and real + # burn starts at ▂ — see LEDGER_BASE_GLYPH for why the baseline may + # not come from a different block. Buckets above ▅ are unchanged, so + # a fully burned 5h window (~11 7d points) still reads ▆. function glyph(c) { - if (c < 1) return "ˍ" - if (c <= 2) return "▁"; if (c <= 4) return "▂"; if (c <= 6) return "▃" - if (c <= 8) return "▄"; if (c <= 11) return "▅"; if (c <= 15) return "▆" + if (c < 1) return BASE + if (c <= 2) return "▂"; if (c <= 4) return "▃"; if (c <= 7) return "▄" + if (c <= 11) return "▅"; if (c <= 15) return "▆" if (c <= 20) return "▇"; return "█" } BEGIN { @@ -2916,8 +3033,8 @@ build_ledger_strip() { if (i < nowslot) { # a sampled sub-1% cell and an idle one both mean "cost # nothing": one glyph, one tint, no colour-only meaning - if (i in cost) { g = glyph(cost[i]); c = (g == "ˍ" ? C_DIM : C_FILL) } - else if (lo >= 0 && lo <= start + cs && start <= hi) { g = "ˍ"; c = C_DIM } + if (i in cost) { g = glyph(cost[i]); c = (g == BASE ? C_DIM : C_FILL) } + else if (lo >= 0 && lo <= start + cs && start <= hi) { g = BASE; c = C_DIM } else { g = "░"; c = C_DIM } } else if (i == nowslot) { g = "▮"; c = C_NOW } else if (dry >= 0 && i >= dry) { g = "×"; c = C_DRY } @@ -3045,7 +3162,7 @@ strip_tail() { printf '%s %s@%s%s' "$out" "$DIM" "$when" "$RESET" } -# The live row: `5h ▂▅█▮▯ 0.6✕ @04:00 7d ▅▁▂▃▅ ˍ▃▅▃▃ …▮▯▯...▯(✕11) 0.7✕ @Wed 09:00` under the badges — this +# The live row: `5h ▂▅█▮▯ 0.6✕ @04:00 7d ▅▁▂▃▅ ▁▃▅▃▃ …▮▯▯...▯(✕11) 0.7✕ @Wed 09:00` under the badges — this # sitting at the left, the week at the right, one grammar. Prints nothing # when there is no live window, or — in auto mode — when the log holds no # sample for either period yet (a row of ░░░▮▯▯ says nothing the badges do @@ -3144,7 +3261,7 @@ run_week() { local indent=" " # 8 = width of the "7d NNN% " label column # the budget line under the strip: the advisor, always-mode, so calm - # weeks still show runway/even/heading; pressure clauses show as-is + # weeks still show runway/ration/landing; pressure clauses show as-is local last_model last_model=$(last_logged_model) local advisor @@ -3643,12 +3760,12 @@ build_advisor_fleet_hint() { # mid-week underuse, engaged sessions # only — reaches exactly the users # who can act on it -# budget ~19✕5h left · even 1.1%/win · heading ~52% +# budget ~19✕5h left · even 1.1%/win · lands ~52% # always-mode calm line (shared # budget frame with claude.py's # watch advisor); in the last # window: budget last window · -# N% left · heading ~M% +# N% left · lands ~M% # --------------------------------------------------------------------------- # The notice engine. Readers below turn the account's live numbers into # NOTICES: one record per thing worth saying, each carrying both a short @@ -4176,30 +4293,50 @@ notice_collect() { # need arbitrating. "left" means still to come. local windows elapsed7=$((SEVEN_DAY_WINDOW_SECS - seven_secs)) windows=$(windows_ahead "$seven_secs" "${five_secs:-0}") - local heading="" heading_part="" walk_gap walk_end + # Where the week actually ends up. The learned walk answers it from + # YOUR weekday shape; linear pace is the fallback once a day has run, + # and it can only ever restate the week so far. Two different + # questions share this line and the grammar has to keep them apart: + # `even N%/win` is the RATION (spend this per window and the pool + # lands exactly on 100), `lands ~N%` is the PREDICTION (spend like + # you have been and you land here). "heading" said neither — a + # direction is not a destination, and the reader was left deciding + # which of the two numbers beside it was the forecast. + local lands="" lands_part="" walk_gap walk_end read -r walk_gap walk_end <<<"$(_seven_day_walk "$seven_int" "$seven_secs")" if [ -n "$walk_end" ]; then - heading="$walk_end" + lands="$walk_end" elif [ "$elapsed7" -ge 86400 ]; then - heading=$((seven_int * SEVEN_DAY_WINDOW_SECS / elapsed7)) - [ "$heading" -gt 100 ] && heading=100 + lands=$((seven_int * SEVEN_DAY_WINDOW_SECS / elapsed7)) + [ "$lands" -gt 100 ] && lands=100 fi - [ -n "$heading" ] && heading_part=" · heading ~${heading}%" - # 0 ahead is the only "last window": the week ends inside the one you - # are in, and there is nothing to divide the surplus across. At 1 the - # line keeps the count and the grammar — `1✕5h left · 25.0%/win` says - # the same thing the N-window form says, and calling two windows the - # last one to save a redundant clause is the wrong trade. + [ -n "$lands" ] && lands_part=" · lands ~${lands}%" + # Row 2 gets the runway and the LANDING, not the runway and the + # ration. Of the three clauses the long form carries, the landing is + # the only one the reader cannot derive from the badges above: the + # count is on the strip beside it (`...▯(✕9)`) and the ration is + # surplus ÷ that count. It is also the one that answers the question + # a calm week actually asks — not "how do I ration this" but "am I + # going to strand it". Cold, with no landing to state, the ration is + # the best short form there is and the line falls back to it. + local short_tail="" if [ "$windows" -le 0 ]; then notice_add 10 '-' acct "acct.budget.last" "${surplus}%" \ "last window · ${surplus}% left" \ - "budget last window · ${surplus}% left${heading_part}" + "budget last window · ${surplus}% left${lands_part}" else local even even=$(awk -v h="$surplus" -v w="$windows" 'BEGIN{printf "%.1f", h/w}') + # 0 ahead is the only "last window": the week ends inside the one + # you are in, and there is nothing to divide the surplus across. + # At 1 the line keeps the count and the grammar — `1✕5h left · + # 25.0%/win` says the same thing the N-window form says, and + # calling two windows the last one to save a redundant clause is + # the wrong trade. + if [ -n "$lands" ]; then short_tail="lands ~${lands}%"; else short_tail="${even}%/win"; fi notice_add 10 '-' acct "acct.budget.${windows}" "${windows}${MULT_GLYPH}5h" \ - "${windows}${MULT_GLYPH}5h left · ${even}%/win" \ - "budget ~${windows}${MULT_GLYPH}5h left · even ${even}%/win${heading_part}" + "${windows}${MULT_GLYPH}5h left · ${short_tail}" \ + "budget ~${windows}${MULT_GLYPH}5h left · even ${even}%/win${lands_part}" fi fi diff --git a/t/helpers.bash b/t/helpers.bash index 27213b7..c01400e 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -61,6 +61,8 @@ CACHE_TTL_DEFAULT="1h" CACHE_HEAVY_TOKENS=200000 CACHE_GLYPH="≡" MULT_GLYPH="✕" +LEDGER_BASE_GLYPH="▁" +FORECAST_SCHEMA=2 ADVISOR_FLEET_FRESH_SECS=3600 ADVISOR_FLEET_FREE_PCT=40 ADVISOR_EXPIRY_HORIZON_SECS=86400 @@ -96,7 +98,7 @@ debug_log() { # Source individual functions by extracting them from statusline.sh. # This is deliberate: we test the actual production code, not copies. eval "$(awk ' - /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|session_telemetry_json)\(\)/ { capture=1 } + /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|windows_ahead|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|session_telemetry_json|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index df26e1d..de5ec39 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -605,7 +605,7 @@ _write_forecast_fixture() { # count — the scoped series rides the same scan. _write_scoped_profile_cache() { # dir days rate recent24 scope_name cat > "$1/forecast.cache" < "$tmpdir/f" \ + && mv "$tmpdir/f" "$tmpdir/forecast.cache" + build_seven_day_profile + [ "$(jq -r '.computed_at' "$tmpdir/forecast.cache")" = "$(( before - 60 ))" ] + rm -rf "$tmpdir" +} + +# The shared-store defence. A co-writer that summed raw deltas published +# 149%/day into Thursday and dropped pct_per_window on the way past; the +# walk's corrupt-profile guard caught the 149 and went silent for the rest +# of the hour. Freshness alone cannot be the gate on a file more than one +# tool writes — the shape has to match too. +@test "build_seven_day_profile: a fresh cache of a foreign shape is rebuilt on sight" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_forecast_fixture "$tmpdir" 21 + cat > "$tmpdir/forecast.cache" <= 0 && p <= 100)}' + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: an older statusline's unversioned cache is rebuilt too" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_forecast_fixture "$tmpdir" 21 + printf '{"computed_at":%s,"days_history":21,"recent_24h":0,"recent_48h":0,"pct_per_window":9,"weekday_profile":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":1}}\n' \ + "$(date +%s)" > "$tmpdir/forecast.cache" + build_seven_day_profile + [ "$(jq -r '.schema' "$tmpdir/forecast.cache")" = "$FORECAST_SCHEMA" ] + mon=$(jq -r '.weekday_profile["1"]' "$tmpdir/forecast.cache") + awk -v p="$mon" 'BEGIN{exit !(p >= 19 && p <= 21)}' + rm -rf "$tmpdir" +} + @test "forecast_usd_per_pct: no cache, no price, no error" { tmpdir=$(mktemp -d) [ -z "$(CLAUDE_ACCOUNT_DIR="$tmpdir" forecast_usd_per_pct)" ] @@ -1016,8 +1072,8 @@ _write_ledger_fixture() { # dir [[ "$output" == *"(~3.8 ✕ 5h windows unused)"* ]] [[ "$output" == *"one full 5h window = ~10.00% of the week"* ]] [[ "$output" == *"week in progress: 44% used"* ]] - # 44% + 4d x 10%/day: heading ~83-84% - [[ "$output" == *"heading ~8"* ]] + # 44% + 4d x 10%/day: lands ~83-84% + [[ "$output" == *"lands ~8"* ]] rm -rf "$tmpdir" } @@ -1169,9 +1225,10 @@ _write_ledger_fixture() { # dir [ "$(echo "$bar" | grep -o . | wc -l)" -eq 34 ] # burn heights appear, slots before coverage stay unknown [[ "$bar" == ░* ]] - [[ "$bar" =~ [▁▂▃▄▅▆▇█] ]] - # the zero-burn sampled window reads idle, not unknown - [[ "$bar" =~ [▁▂▃▄▅▆▇█]ˍ[▁▂▃▄▅▆▇█] ]] + [[ "$bar" =~ [▂▃▄▅▆▇█] ]] + # the zero-burn sampled window reads idle, not unknown — ▁ is the + # baseline, so the burning neighbours must be ▂ or taller + [[ "$bar" =~ [▂▃▄▅▆▇█]▁[▂▃▄▅▆▇█] ]] rm -rf "$tmpdir" } @@ -1217,7 +1274,7 @@ _seed_week_store() { nowslot=$(( ($(date +%s) - ps) / 18000 )) [ "$(printf '%s' "$drawn" | grep -o . | wc -l)" -eq $((nowslot + 1 + WEEK_FUTURE_KEEP)) ] [ $((nowslot + 1 + WEEK_FUTURE_KEEP)) -lt 34 ] - [[ "$bar" =~ [▁▂▃▄▅▆▇█]ˍ[▁▂▃▄▅▆▇█] ]] + [[ "$bar" =~ [▂▃▄▅▆▇█]▁[▂▃▄▅▆▇█] ]] [[ "$bar" == *▮* ]] # day gaps: single spaces inside the strip, several of them across a week [ "$(printf '%s' "$strip" | tr -cd ' ' | wc -c)" -ge 5 ] @@ -1267,6 +1324,24 @@ _seed_week_store() { [[ "$plain" =~ @[A-Z][a-z][a-z]\ [0-9]{2}:[0-9]{2}$ ]] } +# One Unicode block for the whole ladder. ˍ (U+02CD) is a spacing modifier +# LETTER: terminals resolve it through the text font while ▂▃▄ fall back to +# the box-drawing face, so the zero line sat at a different height and width +# than the bars beside it. ▁ is the shortest bar of the same run, which costs +# one rung — burn now starts at ▂ — and buys a strip that is one typeface. +@test "build_ledger_strip: the baseline is the shortest bar of the same block" { + hist="0 100000 0:0,1:0.4,2:1,3:2,4:3,5:5,6:9,7:13,8:18,9:40" + # cell 10 is `now`, so every cell above is history and draws its height + strip=$(strip_ansi "$(build_ledger_strip 50 100000 0 -1 "$hist" 11 10000 0 0 0 10)") + [ "$strip" = "▁▁▂▂▃▄▅▆▇█▮" ] + # nothing from outside Block Elements, and in particular no modifier letter + [[ "$strip" != *ˍ* ]] + # an idle cell inside coverage draws the same baseline as a sub-point one: + # one glyph, one tint, no colour-only meaning + idle=$(strip_ansi "$(build_ledger_strip 50 100000 0 -1 "0 100000 0:5" 4 10000 0 0 0 3)") + [ "$idle" = "▄▁▁▮" ] +} + @test "build_five_strip: five slots, always — the hollow run is the hours left" { # An axis, not a growing bar: five cells for five hours, ▮ walking them, # so "how long have I got" is a count of ▯ and never a second glance at @@ -1544,7 +1619,7 @@ _seed_week_store() { plain=$(strip_ansi "$(build_week_row "$usage" auto)") [[ "$plain" == "5h "* ]] five="${plain#5h }"; five="${five%% 7d*}"; five=$(printf '%s' "$five" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//') - [ "$five" = "▃▅ˍ█▮" ] + [ "$five" = "▄▅▁█▮" ] # 30% in 4h+ -> dry past the reset: holds, so no × in this window [[ "$five" != *×* ]] rm -rf "$tmpdir" @@ -3622,10 +3697,14 @@ JSON plain=$(strip_ansi "$(build_advisor_line "$usage" always)") # the budget voice wears no sigil: it interrupts nothing, and a leading # '-' on a dim line reads as a bullet in a list of one - [[ "$plain" =~ ^24✕5h\ left\ ·\ 3\.3%/win$ ]] - # the whole sentence is still there for surfaces with a line to spend + # row 2 carries the runway and the LANDING: the count is already drawn + # on the strip beside it and the ration is surplus ÷ count, but where the + # week ends up is nowhere else on screen + [[ "$plain" =~ ^24✕5h\ left\ ·\ lands\ ~70%$ ]] + # the whole sentence is still there for surfaces with a line to spend — + # ration and prediction both, each named for what it is long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") - [[ "$long" =~ ^budget\ ~24✕5h\ left\ ·\ even\ 3\.3%/win\ ·\ heading\ ~70%$ ]] + [[ "$long" =~ ^budget\ ~24✕5h\ left\ ·\ even\ 3\.3%/win\ ·\ lands\ ~70%$ ]] } @test "build_advisor_line: budget degrades to plain headroom in the last window" { @@ -3643,7 +3722,7 @@ JSON [[ "$plain" =~ ^last\ window\ ·\ 25%\ left$ ]] [[ "$plain" != *"/win"* ]] long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") - [[ "$long" =~ ^budget\ last\ window\ ·\ 25%\ left\ ·\ heading\ ~[0-9]+%$ ]] + [[ "$long" =~ ^budget\ last\ window\ ·\ 25%\ left\ ·\ lands\ ~[0-9]+%$ ]] } @test "build_advisor_line: one window ahead keeps the count and the grammar" { @@ -3654,7 +3733,32 @@ JSON reset_7d=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ') usage="{\"five_hour\":{\"utilization\":20,\"resets_at\":\"$reset_5h\"},\"seven_day\":{\"utilization\":75,\"resets_at\":\"$reset_7d\"}}" plain=$(strip_ansi "$(build_advisor_line "$usage" always)") - [ "$plain" = "1✕5h left · 25.0%/win" ] + [[ "$plain" =~ ^1✕5h\ left\ · ]] + [[ "$plain" != *"last window"* ]] + # and the ration is still true at N=1: the long form divides by one + long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") + [[ "$long" == *"even 25.0%/win"* ]] +} + +@test "build_advisor_line: the short budget states the landing, the long one both" { + # A week with a day behind it: linear pace can speak even with no learned + # profile, so row 2 has a destination to name. The ration and the + # prediction answer different questions and the long form labels each — + # `even N%/win` is what to spend, `lands ~N%` is where you end up. + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + reset_5h=$(date -u -d '+45 minutes' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+5 days' '+%Y-%m-%dT%H:%M:%SZ') + usage="{\"five_hour\":{\"utilization\":20,\"resets_at\":\"$reset_5h\"},\"seven_day\":{\"utilization\":14,\"resets_at\":\"$reset_7d\"}}" + plain=$(strip_ansi "$(build_advisor_line "$usage" always)") + [[ "$plain" =~ ^[0-9]+✕5h\ left\ ·\ lands\ ~[0-9]+%$ ]] + [[ "$plain" != *"/win"* ]] + long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") + [[ "$long" == *"even "*"%/win"* ]] + [[ "$long" == *"lands ~"* ]] + # one line, two futures, never the same word for both + [[ "$long" != *heading* ]] + rm -rf "$tmpdir" } @test "build_advisor_line: hot 5h pace projects the cap wall-clock" { @@ -4549,3 +4653,116 @@ make_deadman_shim() { # $1=tmpdir $2=chip output [ -z "$result" ] rm -rf "$tmpdir" } + +# --- corpus: history is the ACCOUNT's, across every store it was written to + +_write_corpus_fixture() { + # root holds the account's OLD history (untagged days); the tagged dir + # holds the recent week. Same uuid, two directories — the real layout. + local root="$1" now d ts + local tag="$root/accounts/work" + now=$(date +%s) + mkdir -p "$tag" + echo '{"account":{"uuid":"acct-A"}}' > "$tag/profile.cache" + for (( d = 30; d >= 8; d-- )); do + ts=$(( now - d * 86400 )) + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":10},"five_hour":{"utilization":10,"resets_at":"R%s"}}\n' "$ts" "$d" + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":22},"five_hour":{"utilization":40,"resets_at":"R%s"}}\n' "$(( ts + 14400 ))" "$d" + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":30},"five_hour":{"utilization":70,"resets_at":"R%s"}}\n' "$(( ts + 28800 ))" "$d" + done > "$root/usage.jsonl" + # sediment: a row with no uuid, a row of someone else, a marker + printf '{"type":"usage","timestamp":%s,"user":{"email":"x@y"},"seven_day":{"utilization":99}}\n' "$(( now - 9 * 86400 ))" >> "$root/usage.jsonl" + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-B"},"seven_day":{"utilization":80}}\n' "$(( now - 9 * 86400 ))" >> "$root/usage.jsonl" + printf '{"type":"session_start","timestamp":%s,"session_id":"s"}\n' "$(( now - 9 * 86400 ))" >> "$root/usage.jsonl" + for (( d = 7; d >= 1; d-- )); do + ts=$(( now - d * 86400 )) + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":10},"five_hour":{"utilization":10,"resets_at":"R%s"}}\n' "$ts" "$d" + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":22},"five_hour":{"utilization":40,"resets_at":"R%s"}}\n' "$(( ts + 14400 ))" "$d" + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":30},"five_hour":{"utilization":70,"resets_at":"R%s"}}\n' "$(( ts + 28800 ))" "$d" + done > "$tag/usage.jsonl" +} + +@test "usage_corpus_files: root + every accounts/*/ + own dir, backups first, no repeats" { + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/accounts/a" "$tmpdir/accounts/b" + : > "$tmpdir/usage.jsonl"; : > "$tmpdir/usage.jsonl.1" + : > "$tmpdir/accounts/a/usage.jsonl"; : > "$tmpdir/accounts/b/usage.jsonl.1" + CLAUDE_DATA_DIR="$tmpdir" CLAUDE_ACCOUNT_DIR="$tmpdir/accounts/a" + run usage_corpus_files + [ "$output" = "$tmpdir/usage.jsonl.1 +$tmpdir/usage.jsonl +$tmpdir/accounts/a/usage.jsonl +$tmpdir/accounts/b/usage.jsonl.1" ] + # no data root (function-level callers): the account dir alone + CLAUDE_DATA_DIR="" + run usage_corpus_files + [ "$output" = "$tmpdir/accounts/a/usage.jsonl" ] + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: a fresh tag learns from the account's history at the root" { + tmpdir=$(mktemp -d) + _write_corpus_fixture "$tmpdir" + # per-directory scope: 7 days in the tagged dir, below the 14-day floor + CLAUDE_DATA_DIR="" CLAUDE_ACCOUNT_DIR="$tmpdir/accounts/work" + build_seven_day_profile + [ "$(jq -r '.days_history' "$tmpdir/accounts/work/forecast.cache")" -lt 14 ] + rm -f "$tmpdir/accounts/work/forecast.cache" + # the union: 30 days for the same uuid, one level up + CLAUDE_DATA_DIR="$tmpdir" + build_seven_day_profile + [ "$(jq -r '.days_history' "$tmpdir/accounts/work/forecast.cache")" -ge 28 ] + # still one account: B's 80 never leaks into the profile + mon=$(jq -r '.weekday_profile["1"]' "$tmpdir/accounts/work/forecast.cache") + awk -v p="$mon" 'BEGIN{exit !(p >= 19 && p <= 21)}' + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: stamps the corpus it ran over, drops counted" { + tmpdir=$(mktemp -d) + _write_corpus_fixture "$tmpdir" + CLAUDE_DATA_DIR="$tmpdir" CLAUDE_ACCOUNT_DIR="$tmpdir/accounts/work" + build_seven_day_profile + fc="$tmpdir/accounts/work/forecast.cache" + [ "$(jq -r '.corpus.uuid' "$fc")" = "acct-A" ] + [ "$(jq -r '.corpus.files' "$fc")" = "2" ] + [ "$(jq -r '.corpus.samples' "$fc")" = "90" ] + [ "$(jq -r '.corpus.dropped_no_uuid' "$fc")" = "1" ] + [ "$(jq -r '.corpus.oldest' "$fc")" = "$(head -1 "$tmpdir/usage.jsonl" | jq -r .timestamp)" ] + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: the same observation in two stores is counted once" { + tmpdir=$(mktemp -d) + _write_corpus_fixture "$tmpdir" + cp "$tmpdir/accounts/work/usage.jsonl" "$tmpdir/usage.jsonl.1" + CLAUDE_DATA_DIR="$tmpdir" CLAUDE_ACCOUNT_DIR="$tmpdir/accounts/work" + build_seven_day_profile + [ "$(jq -r '.corpus.samples' "$tmpdir/accounts/work/forecast.cache")" = "90" ] + rm -rf "$tmpdir" +} + +@test "week_scan: a sample landing in another store invalidates week.cache" { + tmpdir=$(mktemp -d) + _write_corpus_fixture "$tmpdir" + CLAUDE_DATA_DIR="$tmpdir" CLAUDE_ACCOUNT_DIR="$tmpdir/accounts/work" + sig1=$(usage_corpus_sig) + sleep 1 + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":31},"five_hour":{"utilization":71,"resets_at":"R1"}}\n' "$(date +%s)" >> "$tmpdir/usage.jsonl" + sig2=$(usage_corpus_sig) + [ "$sig1" != "$sig2" ] + rm -rf "$tmpdir" +} + +@test "session_telemetry_json: carries the project basename, never the path" { + cost_usd=1.5 project_dir="/Users/x/wrk/src/repo-name/" cwd="/Users/x/wrk/src/repo-name/sub" + run session_telemetry_json + [ "$(echo "$output" | jq -r .project)" = "repo-name" ] + [[ "$output" != *"/Users"* ]] + cost_usd=1.5 project_dir="" cwd="/tmp/other" + run session_telemetry_json + [ "$(echo "$output" | jq -r .project)" = "other" ] + cost_usd=1.5 project_dir="" cwd="" + run session_telemetry_json + [ "$(echo "$output" | jq -r .project)" = "null" ] +}