From 7cfc2e7c99d6003eeb3da30b26a7aeba4a588de5 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Mon, 24 Aug 2026 10:16:02 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(rows):=20one=20model,=20one=20landing?= =?UTF-8?q?=20=E2=80=94=20and=20a=20ledger=20in=20one=20typeface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forecast messages were bad because a co-writer was poisoning the model behind them. `forecast.cache` sits in `~/.claude/statusline/`, a published shared store, and it is the one derived file more than one tool wants to write. ccpace rebuilt the five fields it knew, wrote them flat over the file, and did it with an accounting this script abandoned two releases ago — burn as the sum of raw positive deltas, which credits every stale dip and then credits the re-climb. Downstream, everything did the right thing and the result was still wrong. The published profile had 149%/day in Thursday; the walk's corrupt-profile guard 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 dropped to linear pace: ~92% became ~61% on a week nothing had changed about. 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. So the cache carries `schema`, 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 — stamp the schema you implement, and either emit the full key set or merge into what is there. Silence is a defence against a bad model; it is not a substitute for knowing whose model you are reading. `heading` was neither a direction nor a destination. 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. It reads `lands ~91%` now, in the budget line and in report's week-in-progress alike, and 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, and it answers what a calm week actually asks — not "how do I ration this" but "am I going to strand it". The strips are one typeface again. The baseline was `ˍ` (U+02CD MODIFIER LETTER LOW MACRON) while every bar above it came from Block Elements; terminals resolve those through different faces, so the zero line sat at a different height and 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. Burn starts one rung up, at `▂`; `▅` and above keep their thresholds, so a fully burned window reads the height it always did. `LEDGER_BASE_GLYPH` overrides it. 441 tests. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 55 +++++++++++++++++ DESIGN.md | 6 +- README.md | 16 ++--- docs/api/state-dir.md | 26 +++++++- statusline.sh | 137 ++++++++++++++++++++++++++++++------------ t/helpers.bash | 2 + t/statusline.bats | 130 +++++++++++++++++++++++++++++++++++---- 7 files changed, 309 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e44c1..3a4a9cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,61 @@ ## Unreleased +**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 **The 5h strip is five cells again — but the future in them is empty, not 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..f8d756d 100644 --- a/docs/api/state-dir.md +++ b/docs/api/state-dir.md @@ -102,7 +102,7 @@ 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}, @@ -112,6 +112,30 @@ Output of the hourly usage.jsonl scan (EWMA half-life 14 days): "usd_per_pct": 1.6820, "paired_pct": 50.0}} ``` +#### 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 diff --git a/statusline.sh b/statusline.sh index 79390d5..93a58ad 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); @@ -2016,6 +2026,18 @@ get_seven_day_color() { USAGE_LOG_MAX_BYTES="${USAGE_LOG_MAX_BYTES:-33554432}" # 32 MiB cap +# 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,10 +2056,11 @@ 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" @@ -2045,11 +2068,23 @@ build_seven_day_profile() { 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) @@ -2076,7 +2111,7 @@ build_seven_day_profile() { ((.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" ' + | sort -n | awk -F'\t' -v now="$now" -v tz="$tzoff_s" -v schema="$FORECAST_SCHEMA" ' # 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 @@ -2215,7 +2250,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\":{" @@ -2532,7 +2567,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 +2682,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 +2701,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) @@ -2884,11 +2921,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 +2957,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 +3086,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 +3185,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 +3684,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 +4217,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..57180c2 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 diff --git a/t/statusline.bats b/t/statusline.bats index df26e1d..9cf8443 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" { From 2c86cbac62a79b193fb8974af0a3bb61880ef142 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Thu, 27 Aug 2026 00:58:52 -0700 Subject: [PATCH 2/2] feat(store): a directory is where a sample landed, not who it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregating readers (forecast, week strip, report) read every usage store under the data root — top level plus accounts/*/ — and partition by user.uuid, instead of the one directory they were standing in. The same account had 301 days at the root and 28 in its tagged dir; a fresh DEVA_AUTH_TAG rendered 'still learning' for two weeks over ten months of history one level up. forecast.cache gains a corpus stamp (uuid, files, samples, dropped_no_uuid, oldest): schema versions the model and cannot say what it was run over. uuid-less rows stay dropped but are counted. Each session block now carries session.project (basename only). 447 tests. Hourly rebuild over 31 MiB / 5 files: 1.2 s. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 41 ++++++++++++++- docs/api/state-dir.md | 31 +++++++++-- statusline.sh | 120 ++++++++++++++++++++++++++++++++++-------- t/helpers.bash | 2 +- t/statusline.bats | 113 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4a9cf..d4b8642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,45 @@ # 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 diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index f8d756d..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 @@ -109,9 +118,18 @@ Output of the hourly usage.jsonl scan (EWMA half-life 14 days): "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 @@ -250,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 @@ -315,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 93a58ad..0a30c81 100755 --- a/statusline.sh +++ b/statusline.sh @@ -1098,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() { @@ -2026,6 +2035,48 @@ 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`. @@ -2062,9 +2113,12 @@ rotate_usage_log() { # 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 @@ -2091,7 +2145,7 @@ build_seven_day_profile() { # ±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 @@ -2103,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" -v schema="$FORECAST_SCHEMA" ' + 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 @@ -2199,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) @@ -2274,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 } @@ -2441,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 @@ -2457,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") @@ -2793,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)", @@ -2819,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" ' diff --git a/t/helpers.bash b/t/helpers.bash index 57180c2..c01400e 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -98,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 9cf8443..de5ec39 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -4653,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" ] +}