diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5bd0f..133de4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,81 @@ ## Unreleased +## v0.29.0 — 2026-08-19 — the store learns to count + +**Fixed: burn was counted wrong, by a factor of three.** The weekday +profile summed raw positive deltas of `seven_day.utilization`. Every +stale reading — an idle session reporting the numbers it last saw — was +therefore refunded and then re-earned: measured against a real 23 MiB +log, 146 points of "burn" for a week that actually moved 50. The +learner now walks a **monotone envelope**, crediting only the rise of +the running max, and the forecast that reads it stopped inventing +dry-outs that were never coming. + +**Telling a stale reading from a real reset needs two signals, because +`resets_at` cannot.** It looks like a window key and for 5h it behaves +like one, but on 2026-08-17 an account's `seven_day.utilization` went +`100.0 → 0.0` and stayed there, sampled by two independent writers, +with `seven_day.resets_at` unchanged: the weekly counter can be reset +out of band. A newer key is certainly a new window; an unchanged one +proves nothing. The envelope re-baselines only on a drop that is both +sustained (≥ 2 consecutive samples) and deep (≥ 15 points) — the +failure mode is a bounded under-count, which costs a missed warning +where the over-count cost a false alarm on every render. + +**Fixed: an expired window is never a sample.** A session idle across a +boundary — or a fixture piped in by hand — reports the window it last +saw. Logged, that pair read as a 49-point drop that the next real +sample re-climbed. Rejected now on its own merits: a window whose reset +is already behind us cannot be the current one. + +**Fixed: 26% of the log was markers for windows that never rolled.** +`session_start`/`session_end` compared `resets_at` as a raw string, and +the server jitters it (`06:00:00.515434` vs `06:00:00.087190`, and +`05:59:59`/`06:00:00` straddling one boundary), so nearly every fetch +wrote a marker pair — 24,747 of them in a log holding 25,004 real +samples, eaten straight out of the 32 MiB rotation cap. The boundary +test now compares window *instants* with 300 s of slack and requires +the new window to be newer. + +**Fixed: the week ledger drew every account at once.** `week_scan` did +not partition by `user.uuid`, which the state-dir contract has required +since v2. The default dir predates account scoping and a real one holds +a dozen uuids. + +**New: the model-scoped weekly cap gets the learned forecast.** `fb +caps ~Mon 14:00, 2d before reset` — where linear pace is structurally +silent, because 45% with four days left *is* a calm straight line. A +week whose Tuesday burns 39%/day and whose Sunday burns 6%/day is not a +line. One walker (`_profile_walk`) now serves both the account 7d and +the scoped cap, so the two forecasts cannot disagree about physics, and +the scoped one only ever speaks for the scope its profile was built +from (`scoped_name`) — which model carries the cap is Anthropic's +choice and has changed before. + +**New: the statusline records what a percentage costs.** Claude Code +hands us cost, tokens, context size, effort and CLI version on stdin at +every render; until now they were read for the badges and thrown away. +Each `usage` record now carries a `session` block, and `forecast.cache` +gains a `cost` object pricing a 7d point in dollars — the join no +single source can make, since the quota API reports percent and never +dollars on a subscription plan (`limit_dollars` is null) while the +transcripts report dollars and never percent. `report` shows the price +and the spend. The denominator is **paired**: only points observed by a +sample that also carried a dollar figure, because the log predates the +`session` block by months and dividing by all of it would price a week +at pennies. + +**Fixed: three test fixtures raced the clock.** The fixture reads +`date`, the function reads it again, and one tick between them flips +`2h30m` to `2h29m`. Reproduced once in ten full runs; +30 s of slack +absorbs the tick without weakening the assertion. + +The contract doc gains a **Reading the quota series** section: the four +properties above, plus the two things the series cannot tell you — +gaps are not idleness (samples exist only while a statusline renders), +and `model` is the logging session's, not the spender's. + ## v0.28.0 — 2026-08-19 — the notice engine **New: a notice engine behind rows 2 and 3.** The advisor was one diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index b2e764c..bb870fc 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.22.0 +- Synced with: statusline.sh v0.29.0 - Permissions: the script runs under `umask 077` — files are owner-only. Caches hold account PII (email, uuid, org names). @@ -105,7 +105,11 @@ Output of the hourly usage.jsonl scan (EWMA half-life 14 days): {"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}} + "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}} ``` `weekday_profile` keys are days-of-week `0`=Sun..`6`=Sat, values are @@ -116,6 +120,38 @@ inside the same 5h window; `-1` until enough paired burn has been observed (>= half a window). Snapshots are partitioned by `account.uuid` before aggregation. +`scoped_*` mirror the all-model fields for the `weekly_scoped` limit — +the per-model weekly cap, `Fable` at time of writing. `scoped_name` is +the scope the profile is ABOUT (`null` while unobserved); burn is +tracked per scope name so a change in which model is capped cannot blend +two series into one profile. + +Both profiles use the burn accounting described under [Reading the +quota series](#reading-the-quota-series) — envelope rise, not raw +positive deltas, and both are walked by the same simulator, so the +account's forecast and the model's cannot disagree about physics. + +`cost` prices the quota. `usd_per_pct` is what one 7d percentage point +costs this account, and it is the join no single source can make: the +quota API reports percent and never dollars on a subscription plan +(`limit_dollars` is null), the transcripts report dollars and never +percent. Only a sample carrying both can price a point. + +Its denominator is **paired**, not total: `paired_pct` counts only the +7d points observed by a sample that also carried `session.cost_usd`. A +log that predates the `session` block by months holds far more points +than dollars, and dividing by all of them would price a week at pennies. +`usd_per_pct` is `-1` until at least 5 paired points exist — a price +mined from two samples is a rumour, not a rate. + +`usd_24h` / `usd_7d` are summed per session as the rise of each +session's own cumulative `cost_usd`, never as deltas of the raw column +(see the aggregation rule under usage.jsonl). + +A reader must not attribute `scoped_profile` to a model other than +`scoped_name`: which model carries the weekly cap is Anthropic's choice +and has changed before, and one model's weekday shape is not another's. + ### week.cache The week row's history, so a render never scans the log: @@ -137,7 +173,10 @@ samples keyed by `five_hour.resets_at` rounded to 5 min). `five`: per half hour of the current 5h window, the 5h points added (each positive step between consecutive samples credited to the later sample's cell). Rebuilt when either period, the signature, or a 5-min TTL disagrees; -safe to delete. +safe to delete. Partitioned by `user.uuid` against `profile.cache` — +the default (untagged) dir predates account scoping and a real one holds +a dozen uuids. Note the cell math rounds the window key to 5 min but +floors the slot, so an unaligned period can draw a window one cell left. ### usage.jsonl @@ -146,9 +185,20 @@ Append-only, one JSON object per line, three event types: | `type` | Emitted | Payload | |--------|---------|---------| | `usage` | every successful usage fetch; and (`source:"stdin"`) every changed 5h/7d pair Claude Code hands the statusline on stdin, >= 60 s apart — same window keys, `user.uuid` from profile.cache, no `organization`/`extra_usage`/`limits` | `session_id`, `timestamp`, `user{email,name,uuid,…}`, `organization{…}`, `five_hour`, `seven_day`, `seven_day_opus`, `extra_usage`, `limits[]`, `model`, `predicted_end` | -| `session_start` | first fetch of a new 5h window | `session_id`, `timestamp`, `five_hour_window_end`, `seven_day_window_end` | +| `session_start` | first fetch of a genuinely newer 5h window | `session_id`, `timestamp`, `five_hour_window_end`, `seven_day_window_end` | | `session_end` | 5h window rolled while a different session was last | `session_id`, `timestamp` | +**Markers in logs written before this fix are noise.** The boundary test +compared `resets_at` as a raw string, and the server jitters it +(`06:00:00.515434` vs `06:00:00.087190`, and `05:59:59` vs `06:00:00` +straddling one boundary), so nearly every fetch wrote a +`session_end`/`session_start` pair: 26% of a measured 23 MiB log was +markers for windows that never rolled — history the 32 MiB rotation cap +then threw away. The test now compares window *instants* with 300 s of +slack and requires the new window to be NEWER, so a stale sample opens +nothing. Readers should treat a marker density near one-per-sample as a +pre-fix log and ignore the markers. + Since v0.20.0 each `usage` line also records what the learner will need later (learning lags logging — a field absent today is a pattern that cannot be learned next month): @@ -156,9 +206,33 @@ cannot be learned next month): | Field | Type | Meaning | |-------|------|---------| | `limits[]` | array | scoped limits verbatim (per-model weekly caps) | -| `model` | string \| null | model id active in the logging session | +| `model` | string \| null | model id active in the logging session, `[1m]` suffix included | | `predicted_end` | int \| null | the learned walk's end-of-week projection at sample time; null until the profile is warm. Compare against the window's observed final to measure forecast accuracy. | +And a `session` object — the half of the picture `/api/oauth/usage` does +not have. The quota endpoint answers "how full is the account" in +percent and nothing else (`limit_dollars` is null on subscription +plans); Claude Code hands the rest to the statusline on stdin, on every +render, for free. Both writers (`api` and `source:"stdin"`) carry it; +`null` when there is no stdin context (subcommands, tests). + +| Field | Type | Meaning | +|-------|------|---------| +| `session.cost_usd` | number | `cost.total_cost_usd` — **cumulative for that session** | +| `session.dur_ms` / `session.api_ms` | int | wall and API duration, cumulative per session | +| `session.lines_add` / `session.lines_del` | int | code written, cumulative per session | +| `session.ctx_in` | int | `context_window.total_input_tokens` at sample time — a level, not a flow | +| `session.ctx_size` | int | the session's context window (200k / 1M) | +| `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 | + +**Aggregating cost.** `cost_usd` is per session and cumulative, and +consecutive samples routinely come from different sessions. Account +spend over a period is the sum over `session_id` of each session's max +`cost_usd` — never the sum, and never the delta, of the raw column. +A session that spans a period boundary is attributed by its samples. + Rotation keeps exactly one `.1` backup; readers wanting full history read `usage.jsonl.1` then `usage.jsonl`. @@ -166,6 +240,54 @@ The `report` subcommand (`statusline.sh report [--days N]`) is the reference consumer: it replays this log and ledgers what each closed window expired unused. +## Reading the quota series + +Four properties of the raw series that a naive reader gets wrong. Every +one of them was measured against a real 23 MiB log, and each cost the +forecast an order of magnitude before it was fixed. + +**1. `resets_at` is not a window-instance key for 7d.** It looks like +one, and for 5h it behaves like one. But on 2026-08-17 an account's +`seven_day.utilization` went `100.0 -> 0.0` and *stayed* there, sampled +from two independent writers, with `seven_day.resets_at` unchanged at +the same instant. The weekly counter can be reset out of band (grant, +plan change, promo) without moving the window. A NEWER key is certainly +a new window; an unchanged key proves nothing. + +**2. Utilization is monotone inside a window; a dip is a stale reading.** +`rate_limits` on stdin is per session, and an idle session keeps +reporting what it last saw. A sample below the running max is almost +always that, not a refund. Burn is therefore the rise of a monotone +ENVELOPE, not the sum of positive deltas — the naive sum re-earns every +dip and read 146 points of burn against a real 50-point week. + +**3. Telling (2) from (1) needs two signals.** A stale reading is one +sample and a small step back; a real reset sticks and is a long fall. +The rule here: re-baseline the envelope only when the drop is both +sustained (>= 2 consecutive samples below the envelope) and deep (>= 15 +points). The failure mode is a bounded under-count, which costs a missed +warning — the over-count cost a false alarm on every render. + +**4. `source:"stdin"` samples are integer-truncated.** Claude Code sends +`used_percentage` as an int; the API sends `utilization` as a float. +Mixing them puts a +-1 sawtooth in the series, which (2) also absorbs. +stdin samples additionally carry no `limits[]`, `extra_usage` or +`organization` — the scoped (per-model) series has holes wherever a +stdin sample is the only one in an interval. + +Two more things the series cannot tell you, worth stating so nobody +infers them: + +- **Gaps are not idleness.** Samples exist only while a statusline + renders. A multi-hour gap means no session was running *on this + machine* — burn from other devices or claude.ai lands as a step at the + next sample, attributed to the wrong time. +- **`model` is the logging session's, not the spender's.** Quota is + account-wide and many sessions share it; the model on a record is + whichever session happened to win the fetch. Per-model attribution + comes from `limits[]` (the scoped cap) or from Claude Code's own + transcripts, never from this field. + ## Consumer rules 1. Read-only. Locks, TTLs, and rotation are the writer's job. diff --git a/statusline.sh b/statusline.sh index 535d258..1f4343f 100755 --- a/statusline.sh +++ b/statusline.sh @@ -453,6 +453,7 @@ eval "$(echo "$input" | jq -r ' @sh "exceeds_200k=\(.exceeds_200k_tokens // false)", @sh "ctx_pct=\(.context_window.used_percentage // "")", @sh "ctx_size=\(.context_window.context_window_size // "")", + @sh "ctx_total_in=\(.context_window.total_input_tokens // "")", @sh "effort_level=\(.effort.level // "")", @sh "fast_mode=\(.fast_mode // false)", @sh "rl_five_pct=\(.rate_limits.five_hour.used_percentage // "")", @@ -1070,6 +1071,22 @@ fetch_prepaid_balance() { # ccpace read them as history without knowing the difference. Never rebuilds # the weekday profile: that stays on the fetch path. STDIN_LOG_MIN_SECS=60 +session_telemetry_json() { + [ -n "${cost_usd:-}" ] || [ -n "${ctx_total_in:-}" ] || return 0 + 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:-}" ' + 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 +} + log_stdin_snapshot() { local session_id="$1" fp="$2" fr="$3" sp="$4" sr="$5" [ -n "$fp" ] && [ -n "$fr" ] && [ -n "$sp" ] || return 0 @@ -1086,24 +1103,43 @@ log_stdin_snapshot() { # (8% between 21% and 23%). Inside a window utilization only climbs; # log a stdin pair only when it would win the display merge — same # window and not below the cache, or a newer window. + # An EXPIRED window is never news. A session that sat idle across a + # boundary — or a hand-piped fixture — reports the window it last saw; + # logged, that pair reads as a 49-point drop and the next real sample + # re-climbs it, so every learner counts the same burn twice. This guard + # needs no cache: a window whose reset is already behind us cannot be + # the current one. 300 s of slack covers clock skew at the boundary. + local fe_now se_now + fe_now=$(_epoch_from_ts "$fr") + [ -n "$fe_now" ] && [ $(( fe_now - now )) -gt -300 ] 2>/dev/null || return 0 + if [ -n "$sr" ]; then + se_now=$(_epoch_from_ts "$sr") + [ -n "$se_now" ] && [ $(( se_now - now )) -gt -300 ] 2>/dev/null || return 0 + fi + local uc="$CLAUDE_ACCOUNT_DIR/usage.cache" if [ -f "$uc" ]; then local c5p c5r c7p c7r fe0 ce0 eval "$(jq -r '@sh "c5p=\(.five_hour.utilization // "")", @sh "c5r=\(.five_hour.resets_at // "")", @sh "c7p=\(.seven_day.utilization // "")", @sh "c7r=\(.seven_day.resets_at // "")"' "$uc" 2>/dev/null)" fe0=$(_epoch_from_ts "$fr"); ce0=$(_epoch_from_ts "$c5r") - if [ -n "$c5p" ] && [ -n "$fe0" ] && [ -n "$ce0" ] && [ $(( fe0 - ce0 )) -lt 300 ] 2>/dev/null \ - && [ $(( ce0 - fe0 )) -lt 300 ] 2>/dev/null \ - && awk -v a="$fp" -v b="$c5p" 'BEGIN{exit !((a+0) < (b+0))}'; then - return 0 + if [ -n "$c5p" ] && [ -n "$fe0" ] && [ -n "$ce0" ]; then + # older window than the cache: stale by construction, drop it + [ $(( ce0 - fe0 )) -ge 300 ] 2>/dev/null && return 0 + if [ $(( fe0 - ce0 )) -lt 300 ] 2>/dev/null \ + && awk -v a="$fp" -v b="$c5p" 'BEGIN{exit !((a+0) < (b+0))}'; then + return 0 + fi fi if [ -n "$c7p" ] && [ -n "$sr" ] && [ -n "$c7r" ]; then local se0 ce7 se0=$(_epoch_from_ts "$sr"); ce7=$(_epoch_from_ts "$c7r") - if [ -n "$se0" ] && [ -n "$ce7" ] && [ $(( se0 - ce7 )) -lt 300 ] 2>/dev/null \ - && [ $(( ce7 - se0 )) -lt 300 ] 2>/dev/null \ - && awk -v a="$sp" -v b="$c7p" 'BEGIN{exit !((a+0) < (b+0))}'; then - return 0 + if [ -n "$se0" ] && [ -n "$ce7" ]; then + [ $(( ce7 - se0 )) -ge 300 ] 2>/dev/null && return 0 + if [ $(( se0 - ce7 )) -lt 300 ] 2>/dev/null \ + && awk -v a="$sp" -v b="$c7p" 'BEGIN{exit !((a+0) < (b+0))}'; then + return 0 + fi fi fi fi @@ -1123,14 +1159,17 @@ log_stdin_snapshot() { mkdir -p "$CLAUDE_ACCOUNT_DIR" local usage_log="$CLAUDE_ACCOUNT_DIR/usage.jsonl" rotate_usage_log "$usage_log" + local sess="" + sess=$(session_telemetry_json) || true; [ -n "$sess" ] || sess=null jq -nc --arg sid "$session_id" --argjson ts "$now" --arg model "${model_id:-}" \ --argjson fp "$fp" --arg fr "$fiso" --argjson sp "$sp" --arg sr "$siso" \ - --arg uuid "$uuid" --arg email "$email" \ + --arg uuid "$uuid" --arg email "$email" --argjson sess "$sess" \ '{type:"usage", source:"stdin", session_id:$sid, timestamp:$ts, user:{email:$email, uuid:$uuid}, five_hour:{utilization:$fp, resets_at:$fr}, seven_day:{utilization:$sp, resets_at:(if $sr == "" then null else $sr end)}, - model:(if $model == "" then null else $model end)}' >>"$usage_log" 2>/dev/null \ + model:(if $model == "" then null else $model end), + session:$sess}' >>"$usage_log" 2>/dev/null \ && printf '%s %s\n' "$pair" "$now" >"$seen_file.tmp.$$" && mv -f "$seen_file.tmp.$$" "$seen_file" debug_log "log_stdin_snapshot: logged 5h=$fp 7d=$sp" } @@ -1194,11 +1233,15 @@ log_usage_snapshot() { [ -n "$_s_secs" ] && read -r _ predicted_end <<<"$(_seven_day_walk "$_s_util" "$_s_secs")" fi + local sess="" + sess=$(session_telemetry_json) || true; [ -n "$sess" ] || sess=null + echo "$usage_data" | jq -c \ --arg sid "$session_id" \ --arg ts "$(date +%s)" \ --arg model "${model_id:-}" \ --arg pend "${predicted_end:-}" \ + --argjson sess "$sess" \ --arg email "$user_email" \ --arg name "$user_name" \ --arg uuid "$user_uuid" \ @@ -1235,7 +1278,8 @@ log_usage_snapshot() { extra_usage:.extra_usage, limits:(.limits // []), model:($model | if . == "" then null else . end), - predicted_end:($pend | if . == "" then null else tonumber end) + predicted_end:($pend | if . == "" then null else tonumber end), + session:$sess }' \ >>"$usage_log" 2>/dev/null } @@ -1261,23 +1305,40 @@ detect_session_boundary() { return 0 fi - local last_entry=$(tail -1 "$usage_log" 2>/dev/null) + # The newest record that actually carries a window, not just the newest + # line: session_start/session_end markers have no .five_hour, and a + # bounded tail keeps this off the whole-log path. + local last_entry + last_entry=$(tail -n 200 "$usage_log" 2>/dev/null \ + | jq -c 'select((.five_hour.resets_at // .data.five_hour.resets_at // "") != "")' 2>/dev/null | tail -1) if [ -z "$last_entry" ]; then _emit_session_start return 0 fi - local last_five_hour_reset=$(echo "$last_entry" | jq -r '.five_hour.resets_at // .data.five_hour.resets_at // empty' 2>/dev/null) - local last_session_id=$(echo "$last_entry" | jq -r '.session_id // empty' 2>/dev/null) - - if [ "$last_five_hour_reset" != "$current_five_hour_reset" ] && [ -n "$last_five_hour_reset" ]; then - if [ -n "$last_session_id" ] && [ "$last_session_id" != "$session_id" ]; then - jq -n -c --arg sid "$last_session_id" --arg ts "$(date +%s)" \ - '{type:"session_end",session_id:$sid,timestamp:($ts|tonumber)}' \ - >>"$usage_log" 2>/dev/null - fi - _emit_session_start + local last_five_hour_reset last_session_id + last_five_hour_reset=$(echo "$last_entry" | jq -r '.five_hour.resets_at // .data.five_hour.resets_at // empty' 2>/dev/null) + last_session_id=$(echo "$last_entry" | jq -r '.session_id // empty' 2>/dev/null) + + # Compare WINDOWS, not strings. resets_at carries microseconds and + # wobbles per fetch (06:00:00.515434 vs 06:00:00.087190 — one window, + # two strings), and 05:59:59/06:00:00 straddle the same boundary. The + # raw-string compare this replaced fired on nearly every fetch and wrote + # a session_end/session_start pair each time: 26% of the log was markers + # for windows that never rolled. A boundary is a window that is NEWER + # by more than the jitter — a stale sample never opens one. + local cur_e last_e + cur_e=$(_epoch_from_ts "$current_five_hour_reset") + last_e=$(_epoch_from_ts "$last_five_hour_reset") + [ -n "$cur_e" ] && [ -n "$last_e" ] || return 0 + [ $(( cur_e - last_e )) -ge 300 ] 2>/dev/null || return 0 + + if [ -n "$last_session_id" ] && [ "$last_session_id" != "$session_id" ]; then + jq -n -c --arg sid "$last_session_id" --arg ts "$(date +%s)" \ + '{type:"session_end",session_id:$sid,timestamp:($ts|tonumber)}' \ + >>"$usage_log" 2>/dev/null fi + _emit_session_start } should_show_extra() { @@ -1986,19 +2047,110 @@ build_seven_day_profile() { | 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)] | @tsv' 2>/dev/null \ + ((.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" ' - $2 != "" { - if (prev_set && $2 > prev) { - d = $2 - prev - day = int(($1 + tz) / 86400) - burn[day] += d - if (now - $1 <= 86400) r24 += d - if (now - $1 <= 172800) r48 += d + # 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 + # week, and the walk then forecast a dry-out that was never coming. + # + # Two things pull a sample below the envelope, and they need + # opposite answers: + # stale a session that sat idle reports the numbers it last saw. + # One sample, small step back. Hold the envelope. + # reset the account s counter actually went back to zero. Sticks, + # and it is a long fall. Re-baseline, credit nothing. + # resets_at cannot tell them apart: an observed 7d reset (100 -> 0, + # 2026-08-17) left resets_at untouched, so the window key is only + # ever a one-way hint — a NEWER key is certainly a reset; an + # unchanged one proves nothing. Hence the two-signal test below: + # sustained (>= RESET_CONFIRM samples) AND deep (>= RESET_DROP + # points). Both cheap, both independent, and the failure mode is a + # bounded UNDER-count — which costs a missed warning, where the + # over-count cost a false alarm on every render. + # Functions, not bare rules: the ratio learner below is a separate + # pass over the same line, and a `next` in an envelope rule would + # silently starve it. + function key_ok(k) { return (k != "" && k ~ /^[0-9]+$/) } + function seven_env(ts, v, key, d, day) { + if (key_ok(key)) { + if (key + 0 < swin) return # stale window + if (key + 0 > swin) { swin = key + 0; env = v; lo_n = 0; env_set = 1; return } } - prev = $2; prev_set = 1 + # The first sample is a BASELINE, not burn: we are seeing where + # the account already stood, not watching it climb there. + if (!env_set) { env = v; env_set = 1; return } + if (v < env) { + lo_n++; if (lo_n == 1 || v < lo_min) lo_min = v + if (lo_n < RESET_CONFIRM || env - lo_min < RESET_DROP) return + env = lo_min; lo_n = 0 # confirmed reset + } + lo_n = 0 + if (v <= env) return + d = v - env; env = v; day = int((ts + tz) / 86400) + burn[day] += d; credited = d + if (now - ts <= 86400) r24 += d + if (now - ts <= 172800) r48 += d + } + # Same envelope, same reset test, for the model-scoped weekly cap + # (limits[] kind=weekly_scoped — Fable today). Kept per scope name so + # a change in WHICH model is capped cannot blend two series into one + # profile, and so a reader can tell what the profile is ABOUT. + function scoped_env(ts, v, nm, key, d, day) { + sc_last = nm + if (key_ok(key)) { + if (key + 0 < cwin[nm]) return + if (key + 0 > cwin[nm]) { cwin[nm] = key + 0; cenv[nm] = v; cn[nm] = 0; sset[nm] = 1; return } + } + if (!sset[nm]) { cenv[nm] = v; sset[nm] = 1; return } + if (v < cenv[nm]) { + cn[nm]++; if (cn[nm] == 1 || v < cmin[nm]) cmin[nm] = v + if (cn[nm] < RESET_CONFIRM || cenv[nm] - cmin[nm] < RESET_DROP) return + cenv[nm] = cmin[nm]; cn[nm] = 0 + } + cn[nm] = 0 + if (v <= cenv[nm]) return + d = v - cenv[nm]; cenv[nm] = v; day = int((ts + tz) / 86400) + cburn[nm SUBSEP day] += d + if (now - ts <= 86400) cr24[nm] += d + } + # Dollars. cost_usd is cumulative PER SESSION, and consecutive + # samples come from different sessions, so the column is not a series + # — it is many interleaved ones. Per session it only climbs, which + # makes it the same envelope shape as a quota window with one + # difference: a session opening at $0 has no reset to distinguish, so + # a drop is simply a different session and the per-key envelope + # handles it. The first sample of a session is a baseline, not spend. + # + # This is the join the quota API cannot make and Claude Code does not: + # the API knows percent and no dollars (limit_dollars is null on + # subscription), the transcripts know dollars and no percent. Only a + # sample carrying both can price a percentage point. + function cost_env(ts, v, sid, d, day) { + if (!(sid in cset)) { cost_env_v[sid] = v; cset[sid] = 1; return } + if (v <= cost_env_v[sid]) return + d = v - cost_env_v[sid]; cost_env_v[sid] = v + day = int((ts + tz) / 86400) + usd[day] += d; usd_all += d + if (now - ts <= 86400) u24 += d + if (now - ts <= 604800) u7d += d + } + BEGIN { RESET_CONFIRM = 2; RESET_DROP = 15; swin = -1 } + { + credited = 0 + if ($2 != "") seven_env($1, $2 + 0, $5) + if ($6 != "" && $7 != "") scoped_env($1, $6 + 0, $7, $5) + # The price denominator must be PAIRED: only points watched by a + # sample that also carried a dollar figure. The log predates the + # session block by months, so dividing recent dollars by all of + # history would price a whole week at pennies. + if ($8 != "" && $9 != "") { burn_paired += credited; cost_env($1, $8 + 0, $9) } } # Cross-window ratio: pair consecutive samples inside the SAME 5h # window (resets_at identity guards against pairing across a reset) @@ -2029,6 +2181,18 @@ build_seven_day_profile() { if (ppw < 1) ppw = 1 if (ppw > 50) ppw = 50 } + # Scoped weekday profile, newest capped model only, same EWMA. + # -1 on a weekday never observed — exactly like the all-model + # profile, so a reader cannot mistake 0 for "quiet" when it + # means "unknown". + for (k in cburn) { + split(k, kp, SUBSEP) + if (kp[1] != sc_last) continue + age = today - kp[2]; if (age < 0) age = 0 + w = exp(-0.0495 * age) + dw = (kp[2] + 4) % 7 + cnum[dw] += cburn[k] * w; cden[dw] += w + } printf "{\"computed_at\":%d,\"days_history\":%d,", now, ndays printf "\"recent_24h\":%.2f,\"recent_48h\":%.2f,", r24, r48 printf "\"pct_per_window\":%.2f,", ppw @@ -2038,7 +2202,24 @@ build_seven_day_profile() { p = (den[i] > 0) ? num[i] / den[i] : -1 printf "%s\"%d\":%.2f", sep, i, p; sep = "," } - printf "}}\n" + printf "}," + printf "\"scoped_name\":%s,", (sc_last == "" ? "null" : "\"" sc_last "\"") + printf "\"scoped_recent_24h\":%.2f,", (sc_last == "" ? -1 : cr24[sc_last]) + printf "\"scoped_profile\":{" + sep = "" + for (i = 0; i <= 6; i++) { + p = (cden[i] > 0) ? cnum[i] / cden[i] : -1 + printf "%s\"%d\":%.2f", sep, i, p; sep = "," + } + printf "}," + # What a 7d point costs, over the samples that carried both. -1 + # until a real window of paired observation exists: a price + # 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}", \ + u24, u7d, upp, burn_paired + printf "}\n" }') if [ -n "$data" ]; then printf '%s\n' "$data" >"${out}.tmp.$$" 2>/dev/null && mv -f "${out}.tmp.$$" "$out" @@ -2059,8 +2240,8 @@ build_seven_day_profile() { # outlasts the window # projected_end final utilization at the reset, capped at 100 # Silent on cold start (<14 days history) or missing/empty inputs. -_seven_day_walk() { - local used="$1" secs_left="$2" +_profile_walk() { + local used="$1" secs_left="$2" prof_key="${3:-weekday_profile}" recent_key="${4:-recent_24h}" local fc="$CLAUDE_ACCOUNT_DIR/forecast.cache" [ -f "$fc" ] || return 0 [ -n "$secs_left" ] && [ "$secs_left" -gt 0 ] 2>/dev/null || return 0 @@ -2070,13 +2251,15 @@ _seven_day_walk() { local now tzoff_s now=$(date +%s) 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) }') - jq -r '[.days_history, .recent_24h, - .weekday_profile["0"], .weekday_profile["1"], .weekday_profile["2"], - .weekday_profile["3"], .weekday_profile["4"], .weekday_profile["5"], - .weekday_profile["6"]] | @tsv' "$fc" 2>/dev/null \ + jq -r --arg p "$prof_key" --arg r "$recent_key" ' + (.[$p] // {}) as $wp + | [.days_history, (.[$r] // -1), + ($wp["0"] // -1), ($wp["1"] // -1), ($wp["2"] // -1), ($wp["3"] // -1), + ($wp["4"] // -1), ($wp["5"] // -1), ($wp["6"] // -1)] | @tsv' "$fc" 2>/dev/null \ | awk -F'\t' -v used="$used_int" -v left="$secs_left" -v now="$now" -v tz="$tzoff_s" ' { ndays = $1 + 0; r24 = $2 + 0 + if (r24 < 0) r24 = 0 # unlearned recent: no blend, no lie for (i = 0; i <= 6; i++) prof[i] = $(i + 3) + 0 if (ndays < 14) exit # cold start: not enough history # fallback rate for never-seen weekdays: mean of known ones @@ -2105,6 +2288,11 @@ _seven_day_walk() { }' } +# The account 7d and the model-scoped cap, each by name. Callers say which +# question they are asking; neither has to know how a profile is stored. +_seven_day_walk() { _profile_walk "$1" "$2" weekday_profile recent_24h; } +_scoped_walk() { _profile_walk "$1" "$2" scoped_profile scoped_recent_24h; } + seven_day_forecast() { local used="$1" secs_left="$2" local used_int @@ -2117,6 +2305,42 @@ seven_day_forecast() { echo "$level $gap_h" } +# The scoped cap's learned forecast, same shape and contract as +# seven_day_forecast. This is the one that can say on Thursday that Fable +# runs out on Monday: linear pace only ever measures the week so far, and a +# week whose Tuesday is 39%/day and whose Sunday is 6%/day is not a line. +scoped_forecast() { + local used="$1" secs_left="$2" + local used_int + used_int=$(printf '%.0f' "$used" 2>/dev/null || echo 0) + local gap_h proj_end + read -r gap_h proj_end <<<"$(_scoped_walk "$used_int" "$secs_left")" + [ -n "$gap_h" ] && [ "$gap_h" != "-1" ] || return 0 + local level="yellow" + { [ "$gap_h" -ge 48 ] || [ "$used_int" -ge 90 ]; } 2>/dev/null && level="red" + echo "$level $gap_h" +} + +# The scope the learned profile describes, or nothing while unlearned. A +# caller must not attribute a forecast to a model the profile is not about. +scoped_profile_name() { + local fc="$CLAUDE_ACCOUNT_DIR/forecast.cache" + [ -f "$fc" ] || return 0 + jq -r '.scoped_name // empty' "$fc" 2>/dev/null +} + +# What a 7d percentage point costs this account, in dollars, or nothing while +# unlearned. The join no single source can make: the quota API reports percent +# and never dollars on a subscription plan, the transcripts report dollars and +# never percent. +forecast_usd_per_pct() { + local fc="$CLAUDE_ACCOUNT_DIR/forecast.cache" + [ -f "$fc" ] || return 0 + local v + v=$(jq -r '.cost.usd_per_pct // -1' "$fc" 2>/dev/null) || return 0 + awk -v p="${v:--1}" 'BEGIN{ if (p > 0) printf "%.4f", p }' +} + # Learned cross-window ratio from forecast.cache: 7d percentage points a # fully burned 5h window costs THIS account. Echoes a positive decimal, or # nothing while unlearned — callers must not advise without it. @@ -2239,6 +2463,24 @@ run_usage_report() { printf 'exchange rate: still learning (needs ~half a window of paired burn)\n' fi + # Price. A percentage is not a quantity you can reason about; a dollar is. + local upp usd24 usd7d paired + upp=$(forecast_usd_per_pct) + eval "$(jq -r '@sh "usd24=\(.cost.usd_24h // 0)", @sh "usd7d=\(.cost.usd_7d // 0)", + @sh "paired=\(.cost.paired_pct // 0)"' \ + "$CLAUDE_ACCOUNT_DIR/forecast.cache" 2>/dev/null)" 2>/dev/null || true + if [ -n "$upp" ]; then + printf 'price: ~$%s per 7d point%s · one 5h window ~$%s · a full week ~$%s\n' \ + "$(awk -v p="$upp" 'BEGIN{printf "%.2f", p}')" \ + "${paired:+ (learned from ${paired} paired points)}" \ + "$(awk -v p="$upp" -v w="${ppw:-0}" 'BEGIN{printf "%.0f", p * w}')" \ + "$(awk -v p="$upp" 'BEGIN{printf "%.0f", p * 100}')" + else + printf 'price: still learning (samples carrying both dollars and percent)\n' + fi + awk -v a="${usd24:-0}" -v b="${usd7d:-0}" \ + 'BEGIN{ if (a > 0 || b > 0) printf "spent: $%.2f in 24h · $%.2f in 7d\n", a, b }' + # Week in progress, from the freshest source (usage.cache), projected with # the same learned walk the advisor uses — the surfaces must not disagree. local uc="$CLAUDE_ACCOUNT_DIR/usage.cache" @@ -2441,14 +2683,26 @@ week_scan() { return 0 fi fi + # Aggregating readers partition by account uuid, never by directory + # placement (state-dir contract v2). The default dir is the untagged + # account s, but ten months of it predate account scoping — this log + # holds twelve uuids, and without the filter the ledger draws all of + # them as one account s week. + # `|| true`: under `set -e` a bare `x=$(cmd)` aborts AT the assignment, + # and jq exits nonzero on a missing profile.cache — which is the normal + # state before the first fetch. + 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; } \ | 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" ' def wkey: .five_hour.resets_at | sub("\\.[0-9]+";"") | sub("\\+00:00";"Z") | fromdateiso8601 | . / 300 | round * 300; def span: (map(.t) | min | tostring) + " " + (map(.t) | max | tostring); - [ .[] | select(.five_hour.resets_at) ] as $all + [ .[] | select(.five_hour.resets_at) + | select($acct == "" or (.user.uuid // "") == $acct) ] as $all | ( $all | map(select(.seven_day.utilization != null and .timestamp >= $ps) | { k: (wkey - 18000), t: .timestamp, s: .seven_day.utilization }) @@ -3508,18 +3762,45 @@ notice_collect() { fi # Where the running model's weekly quota runs out, if it does before its - # own reset. Same linear math and >= 80 gate as its badge colour. Held as - # a fact, not a sentence: the steering reader below wants to carry it. + # own reset. Held as a fact, not a sentence: the steering reader below + # wants to carry it. + # + # The LEARNED scoped walk speaks first, exactly as it does for the account + # 7d. Linear pace can only measure the week so far, and a week whose + # Tuesday burns 39%/day and whose Sunday burns 6%/day is not a line. That + # is the difference between "you are at 84%" on a quiet Thursday and + # "Fable runs out Monday, two days before it comes back" — one is a + # number, the other is a decision. It only ever speaks for the scope its + # profile was built from: which model carries the weekly cap is + # Anthropic's choice and has changed before, and one model's weekday shape + # is not another's. Cold, it falls back to the old linear math behind the + # badge's own >= 80 gate. local sc_str="" sc_gap="" sc_voice='!yellow' - if [ -n "$scope_int" ] && [ "$scope_int" -ge 80 ] && [ "$scope_int" -lt 100 ] 2>/dev/null \ + if [ -n "$scope_int" ] && [ "$scope_int" -gt 0 ] && [ "$scope_int" -lt 100 ] 2>/dev/null \ && [ -n "$scope_secs" ] && [ "$scope_secs" -gt "$SEVEN_DAY_RECOVERY_SECS" ] 2>/dev/null; then - local sc_elapsed=$((SEVEN_DAY_WINDOW_SECS - scope_secs)) - if [ "$sc_elapsed" -gt 0 ]; then - local sc_cap=$(( (100 - scope_int) * sc_elapsed / scope_int )) - if [ "$sc_cap" -lt "$scope_secs" ]; then - sc_str=$(_fmt_epoch $((now + sc_cap)) '%a %H:%M') - sc_gap=$(format_duration $(( (scope_secs - sc_cap) * 1000 ))) - [ "$scope_int" -ge 90 ] && sc_voice='!red' + local sc_cap="" sc_prof_lc + sc_prof_lc=$(scoped_profile_name | tr '[:upper:]' '[:lower:]') + if [ -n "$sc_prof_lc" ] && [ "$sc_prof_lc" = "$scope_name" ]; then + local sw_level sw_gap + read -r sw_level sw_gap <<<"$(scoped_forecast "$scope_int" "$scope_secs")" + if [ -n "$sw_level" ] && [ "${sw_gap:-0}" -gt 0 ] 2>/dev/null; then + sc_cap=$((scope_secs - sw_gap * 3600)) + [ "$sw_level" = "red" ] && sc_voice='!red' + fi + fi + if [ -z "$sc_cap" ] && [ "$scope_int" -ge 80 ] 2>/dev/null; then + local sc_elapsed=$((SEVEN_DAY_WINDOW_SECS - scope_secs)) + [ "$sc_elapsed" -gt 0 ] && sc_cap=$(( (100 - scope_int) * sc_elapsed / scope_int )) + [ "$scope_int" -ge 90 ] && sc_voice='!red' + fi + if [ -n "$sc_cap" ] && [ "$sc_cap" -ge 0 ] 2>/dev/null \ + && [ "$sc_cap" -lt "$scope_secs" ] 2>/dev/null; then + local sc_gap_secs=$((scope_secs - sc_cap)) + sc_str=$(_fmt_epoch $((now + sc_cap)) '%a %H:%M') + if [ "$sc_gap_secs" -ge 172800 ]; then + sc_gap="$((sc_gap_secs / 86400))d" + else + sc_gap=$(format_duration $((sc_gap_secs * 1000))) fi fi fi diff --git a/t/helpers.bash b/t/helpers.bash index 5af5453..ea54856 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -93,7 +93,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|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|build_ledger_strip|build_week_strip|build_five_strip|five_dry_cell|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_collect)\(\)/ { 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|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|build_ledger_strip|build_week_strip|build_five_strip|five_dry_cell|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_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|session_telemetry_json)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index 1ab7ea7..a1f74a8 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -103,13 +103,13 @@ setup() { # --- format_reset_relative --- @test "format_reset_relative: days and hours" { - ts=$(date -u -d '+2 days 5 hours' '+%Y-%m-%dT%H:%M:%SZ') + ts=$(date -u -d '+2 days 5 hours 30 seconds' '+%Y-%m-%dT%H:%M:%SZ') result=$(format_reset_relative "$ts") [ "$result" = "2d5h" ] } @test "format_reset_relative: hours and minutes" { - ts=$(date -u -d '+3 hours 30 minutes' '+%Y-%m-%dT%H:%M:%SZ') + ts=$(date -u -d '+3 hours 30 minutes 30 seconds' '+%Y-%m-%dT%H:%M:%SZ') result=$(format_reset_relative "$ts") [ "$result" = "3h30m" ] } @@ -133,7 +133,7 @@ setup() { } @test "format_reset_relative: unix epoch produces relative time" { - epoch=$(date -d '+2 hours 30 minutes' +%s) + epoch=$(date -d '+2 hours 30 minutes 30 seconds' +%s) result=$(format_reset_relative "$epoch") [ "$result" = "2h30m" ] } @@ -600,6 +600,163 @@ _write_forecast_fixture() { rm -rf "$tmpdir" } +# A learned profile for the model-scoped weekly cap: every weekday burns +# $3 percent of it, and $4 is the last 24h. days_history is the ALL-model +# count — the scoped series rides the same scan. +_write_scoped_profile_cache() { # dir days rate recent24 scope_name + cat > "$1/forecast.cache" < "$tmpdir/profile.cache" + now=$(date +%s) + : > "$tmpdir/usage.jsonl" + # 10 -> 30 -> 50, then ONE sample at 4 (an idle session reporting the + # window it last saw), then 54. Real burn: 44. Summing positive deltas + # reads 90 — the dip is refunded and then re-earned. + i=0 + for u in 10 30 50 4 54; do + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":%s}}\n' \ + "$(( now - 3600 + i * 60 ))" "$u" >> "$tmpdir/usage.jsonl" + i=$(( i + 1 )) + done + build_seven_day_profile + [ "$(jq -r '.recent_24h' "$tmpdir/forecast.cache")" = "44.00" ] + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: a confirmed reset re-baselines, mid-window and all" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + echo '{"account":{"uuid":"acct-A"}}' > "$tmpdir/profile.cache" + now=$(date +%s) + : > "$tmpdir/usage.jsonl" + # The counter really can go back to zero with resets_at UNCHANGED (seen + # 2026-08-17: 100 -> 0, same reset instant). A sustained, deep drop is a + # reset: 40 before it, 20 after, 60 total — and nothing credited for the + # fall itself. + i=0 + for u in 10 30 50 0 0 20; do + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":%s,"resets_at":"2026-08-19T16:00:00+00:00"}}\n' \ + "$(( now - 3600 + i * 60 ))" "$u" >> "$tmpdir/usage.jsonl" + i=$(( i + 1 )) + done + build_seven_day_profile + [ "$(jq -r '.recent_24h' "$tmpdir/forecast.cache")" = "60.00" ] + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: learns the model-scoped weekly cap too" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + echo '{"account":{"uuid":"acct-A"}}' > "$tmpdir/profile.cache" + now=$(date +%s) + : > "$tmpdir/usage.jsonl" + i=0 + for u in 5 25 60 12 90; do + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":1},"limits":[{"kind":"weekly_scoped","percent":%s,"scope":{"model":{"display_name":"Fable"}}}]}\n' \ + "$(( now - 3600 + i * 60 ))" "$u" >> "$tmpdir/usage.jsonl" + i=$(( i + 1 )) + done + build_seven_day_profile + [ "$(jq -r '.scoped_name' "$tmpdir/forecast.cache")" = "Fable" ] + # 5 -> 90 with one stale dip (12) held: 85, not 85+78. + [ "$(jq -r '.scoped_recent_24h' "$tmpdir/forecast.cache")" = "85.00" ] + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: no scoped samples leaves the scoped profile unlearned" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_forecast_fixture "$tmpdir" 16 + build_seven_day_profile + [ "$(jq -r '.scoped_name' "$tmpdir/forecast.cache")" = "null" ] + [ "$(jq -r '.scoped_profile["1"]' "$tmpdir/forecast.cache")" = "-1.00" ] + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: prices a 7d point from paired samples" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + echo '{"account":{"uuid":"acct-A"}}' > "$tmpdir/profile.cache" + now=$(date +%s) + # Two sessions interleaved. cost_usd is cumulative PER SESSION, so the + # raw column goes 1, 2, 9, 12, 15 — deltas of that are nonsense. Per + # session: s1 1->9->15 (+14), s2 2->12 (+10) = $24. 7d 10->30 with the + # first sample a baseline = 20 paired points. $1.20 a point. + : > "$tmpdir/usage.jsonl" + i=0 + for spec in "10 s1 1.0" "15 s2 2.0" "20 s1 9.0" "25 s2 12.0" "30 s1 15.0"; do + set -- $spec + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":%s},"session_id":"%s","session":{"cost_usd":%s}}\n' \ + "$(( now - 3000 + i * 300 ))" "$1" "$2" "$3" >> "$tmpdir/usage.jsonl" + i=$(( i + 1 )) + done + build_seven_day_profile + [ "$(jq -r '.cost.usd_24h' "$tmpdir/forecast.cache")" = "24.00" ] + [ "$(jq -r '.cost.paired_pct' "$tmpdir/forecast.cache")" = "20.0" ] + [ "$(jq -r '.cost.usd_per_pct' "$tmpdir/forecast.cache")" = "1.2000" ] + [ "$(CLAUDE_ACCOUNT_DIR="$tmpdir" forecast_usd_per_pct)" = "1.2000" ] + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: quota points with no dollars beside them are never priced" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + echo '{"account":{"uuid":"acct-A"}}' > "$tmpdir/profile.cache" + now=$(date +%s) + # months of quota history with no session block: the denominator must not + # borrow those points, or a week prices at pennies. + : > "$tmpdir/usage.jsonl" + for (( d = 40; d >= 1; d-- )); do + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":10}}\n' "$(( now - d * 86400 ))" + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":40}}\n' "$(( now - d * 86400 + 3600 ))" + done >> "$tmpdir/usage.jsonl" + build_seven_day_profile + [ "$(jq -r '.cost.usd_per_pct' "$tmpdir/forecast.cache")" = "-1.0000" ] + [ "$(jq -r '.cost.paired_pct' "$tmpdir/forecast.cache")" = "0.0" ] + [ -z "$(CLAUDE_ACCOUNT_DIR="$tmpdir" forecast_usd_per_pct)" ] + 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)" ] + printf '{"computed_at":0}' > "$tmpdir/forecast.cache" + [ -z "$(CLAUDE_ACCOUNT_DIR="$tmpdir" forecast_usd_per_pct)" ] + rm -rf "$tmpdir" +} + +@test "scoped_forecast: the learned walk warns where linear pace cannot" { + tmpdir=$(mktemp -d) + # 45% used with 4 days left is calm to a straight line — pace says you + # land near 79%. The profile says 20%/day, so the cap arrives on day 3. + _write_scoped_profile_cache "$tmpdir" 20 20 0 Fable + read -r level gap <<<"$(CLAUDE_ACCOUNT_DIR="$tmpdir" scoped_forecast 45 345600)" + [ "$level" = "red" ] || [ "$level" = "yellow" ] + [ "$gap" -gt 0 ] + # and the account-7d walk is untouched by it: that profile is unlearned + [ -z "$(CLAUDE_ACCOUNT_DIR="$tmpdir" seven_day_forecast 45 345600)" ] + rm -rf "$tmpdir" +} + +@test "scoped_forecast: an unlearned scoped profile says nothing" { + tmpdir=$(mktemp -d) + _write_scoped_profile_cache "$tmpdir" 20 -1 -1 Fable + [ -z "$(CLAUDE_ACCOUNT_DIR="$tmpdir" scoped_forecast 45 345600)" ] + # cold start: enough weekdays, not enough days + _write_scoped_profile_cache "$tmpdir" 3 20 0 Fable + [ -z "$(CLAUDE_ACCOUNT_DIR="$tmpdir" scoped_forecast 45 345600)" ] + rm -rf "$tmpdir" +} + @test "build_seven_day_profile: no paired 5h samples leaves the ratio unlearned" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" @@ -665,6 +822,134 @@ _write_forecast_fixture() { rm -rf "$tmpdir" } +@test "detect_session_boundary: microsecond jitter is not a new window" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + log="$tmpdir/usage.jsonl" + printf '{"type":"usage","session_id":"s1","timestamp":1,"five_hour":{"resets_at":"2026-07-28T06:00:00.515434+00:00"}}\n' > "$log" + # resets_at wobbles per fetch and 05:59:59/06:00:00 straddle one + # boundary. Compared as strings, every fetch looked like a roll and + # wrote a marker pair: 26% of a real log was markers for windows that + # never rolled, and the rotation cap ate that much history. + detect_session_boundary s1 '{"five_hour":{"resets_at":"2026-07-28T06:00:00.087190+00:00"}}' + detect_session_boundary s1 '{"five_hour":{"resets_at":"2026-07-28T05:59:59.344798+00:00"}}' + [ "$(grep -c 'session_start\|session_end' "$log")" -eq 0 ] + # a real roll: one end (the previous session) and one start + detect_session_boundary s2 '{"five_hour":{"resets_at":"2026-07-28T11:00:00.123456+00:00"}}' + [ "$(grep -c '"session_start"' "$log")" -eq 1 ] + [ "$(grep -c '"session_end"' "$log")" -eq 1 ] + # an OLDER window is a stale sample: it opens nothing + detect_session_boundary s2 '{"five_hour":{"resets_at":"2026-05-20T16:00:00.000000+00:00"}}' + [ "$(grep -c '"session_start"' "$log")" -eq 1 ] + rm -rf "$tmpdir" +} + +@test "detect_session_boundary: markers between samples do not hide the window" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + log="$tmpdir/usage.jsonl" + printf '{"type":"usage","session_id":"s1","timestamp":1,"five_hour":{"resets_at":"2026-07-28T06:00:00.5+00:00"}}\n' > "$log" + printf '{"type":"session_start","session_id":"s1","timestamp":2}\n' >> "$log" + detect_session_boundary s1 '{"five_hour":{"resets_at":"2026-07-28T06:00:00.1+00:00"}}' + [ "$(grep -c '"session_start"' "$log")" -eq 1 ] + rm -rf "$tmpdir" +} + +@test "session telemetry: what stdin knows and the quota API does not" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + model_id="claude-opus-5" + cost_usd=2.45; duration_ms=834656; api_duration_ms=297096 + lines_added=11; lines_removed=3 + ctx_total_in=115348; ctx_size=1000000 + effort_level="xhigh"; fast_mode=false; cli_version="2.1.235" + usage='{"five_hour":{"utilization":50},"seven_day":{"utilization":40}}' + log_usage_snapshot "sess-1" "$usage" "" + line=$(grep '"type":"usage"' "$tmpdir/usage.jsonl" | tail -1) + [ "$(echo "$line" | jq -r '.session.cost_usd')" = "2.45" ] + [ "$(echo "$line" | jq -r '.session.api_ms')" = "297096" ] + [ "$(echo "$line" | jq -r '.session.ctx_in')" = "115348" ] + [ "$(echo "$line" | jq -r '.session.effort')" = "xhigh" ] + [ "$(echo "$line" | jq -r '.session.fast')" = "false" ] + [ "$(echo "$line" | jq -r '.session.cli')" = "2.1.235" ] + # and on the free stdin path too, so the series has no holes + printf '{"account":{"uuid":"u-1"}}' > "$tmpdir/profile.cache" + log_stdin_snapshot sid1 12 "$(( $(date +%s) + 3600 ))" 39 "$(( $(date +%s) + 86400 ))" + line=$(grep '"source":"stdin"' "$tmpdir/usage.jsonl" | tail -1) + [ "$(echo "$line" | jq -r '.session.cost_usd')" = "2.45" ] + [ "$(echo "$line" | jq -r '.session.lines_add')" = "11" ] + rm -rf "$tmpdir" +} + +@test "session telemetry: no stdin context, no block — never a broken record" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + model_id=""; cost_usd=""; ctx_total_in=""; duration_ms=""; api_duration_ms="" + lines_added=""; lines_removed=""; ctx_size=""; effort_level=""; fast_mode=""; cli_version="" + log_usage_snapshot "sess-1" '{"five_hour":{"utilization":50}}' "" + line=$(grep '"type":"usage"' "$tmpdir/usage.jsonl" | tail -1) + echo "$line" | jq -e . >/dev/null + [ "$(echo "$line" | jq -r '.session')" = "null" ] + rm -rf "$tmpdir" +} + +@test "week_scan: partitions by account uuid, not by directory" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _seed_week_store "$tmpdir" + # the default dir predates account scoping — a real one holds a dozen + # uuids. Tag the seeded rows as A and add a fat B window; only A's + # burn may reach the strip. + # derive the period from the seeded cache, not from a second `date` — + # a second of drift shifts every slot index + ps=$(( $(date -u -d "$(jq -r '.seven_day.resets_at' "$tmpdir/usage.cache")" +%s) - 604800 )) + tmpf="$tmpdir/tagged.jsonl" + jq -c '. + {user:{uuid:"acct-A"}}' "$tmpdir/usage.jsonl" > "$tmpf" && mv "$tmpf" "$tmpdir/usage.jsonl" + w_end=$((ps + 8 * 18000 + 18000)) + for u in 0 99; do + printf '{"timestamp":%s,"user":{"uuid":"acct-B"},"five_hour":{"resets_at":"%s"},"seven_day":{"utilization":%s}}\n' \ + "$((w_end - 18000))" "$(date -u -d "@$w_end" '+%Y-%m-%dT%H:%M:%SZ')" "$u" \ + >> "$tmpdir/usage.jsonl" + done + echo '{"account":{"uuid":"acct-A"}}' > "$tmpdir/profile.cache" + # slot-agnostic: week_scan rounds the window key to 5 min but FLOORS the + # slot, so an unaligned period can drop a window one cell left. The costs + # are what this test is about. + cells=$(week_history_cells "$ps") + [[ ! "$cells" =~ :99(,|$) ]] # acct-B's fat window never lands + [[ "$cells" =~ (^|,|\ )[0-9]+:8(,|$) ]] # acct-A's 10->18 window does + [[ "$cells" =~ (^|,|\ )[0-9]+:7(,|$) ]] + rm -rf "$tmpdir" +} + +@test "build_advisor_line: the learned scoped walk projects the model cap days early" { + tmpdir=$(mktemp -d) + # the env prefix must cover notice_collect too — it reads the profile + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_scoped_profile_cache "$tmpdir" 20 20 0 Fable + reset=$(date -u -d '+4 days' '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"five_hour":{"utilization":20},"seven_day":{"utilization":30,"resets_at":"%s"},"limits":[{"kind":"weekly_scoped","percent":45,"resets_at":"%s","scope":{"model":{"display_name":"Fable"}}}]}' "$reset" "$reset") + plain=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" auto claude-fable-5)")") + [[ "$plain" =~ fb\ caps\ ~[A-Z][a-z]{2}\ [0-9]{2}:[0-9]{2},\ .*\ before\ reset ]] + # linear pace alone is silent here: 45% with 4d left is under the 80 gate + printf '{"computed_at":0,"days_history":0}' > "$tmpdir/forecast.cache" + [ -z "$(notice_long_line "$(notice_collect "$usage" auto claude-fable-5)")" ] + rm -rf "$tmpdir" +} + +@test "build_advisor_line: a scoped profile about another model is never borrowed" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + # the profile was learned while OPUS carried the scoped cap; the session + # runs Fable. Attributing opus's weekday shape to fable would invent a + # forecast, so the clause falls back to linear (silent under 80). + _write_scoped_profile_cache "$tmpdir" 20 20 0 Opus + reset=$(date -u -d '+4 days' '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"five_hour":{"utilization":20},"seven_day":{"utilization":30,"resets_at":"%s"},"limits":[{"kind":"weekly_scoped","percent":45,"resets_at":"%s","scope":{"model":{"display_name":"Fable"}}}]}' "$reset" "$reset") + [ -z "$(notice_long_line "$(notice_collect "$usage" auto claude-fable-5)")" ] + rm -rf "$tmpdir" +} + @test "log_usage_snapshot: stamps the learned end-of-week projection" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" @@ -935,18 +1220,21 @@ _seed_week_store() { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" printf '{"account":{"uuid":"u-1","email":"e@x"}}' > "$tmpdir/profile.cache" - log_stdin_snapshot sid1 12 1787119200 39 1787155200 - log_stdin_snapshot sid1 12 1787119200 39 1787155200 # same pair: no row - log_stdin_snapshot sid2 13 1787119200 39 1787155200 # changed, but < 60s: no row + # windows must be LIVE, not a frozen epoch: an expired window is never + # logged, so a hardcoded one turns this test into a time bomb. + five=$(( $(date +%s) + 3600 )); seven=$(( $(date +%s) + 86400 )) + log_stdin_snapshot sid1 12 "$five" 39 "$seven" + log_stdin_snapshot sid1 12 "$five" 39 "$seven" # same pair: no row + log_stdin_snapshot sid2 13 "$five" 39 "$seven" # changed, but < 60s: no row [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 1 ] row=$(cat "$tmpdir/usage.jsonl") [ "$(echo "$row" | jq -r .source)" = "stdin" ] [ "$(echo "$row" | jq -r .user.uuid)" = "u-1" ] - [ "$(echo "$row" | jq -r .five_hour.resets_at)" = "2026-08-19T06:00:00Z" ] + [ "$(echo "$row" | jq -r .five_hour.resets_at)" = "$(date -u -d "@$five" '+%Y-%m-%dT%H:%M:%SZ')" ] [ "$(echo "$row" | jq -r .seven_day.utilization)" = "39" ] # older than the floor: the changed pair lands printf '12|39 %s\n' "$(( $(date +%s) - 120 ))" > "$tmpdir/stdin_seen" - log_stdin_snapshot sid2 13 1787119200 39 1787155200 + log_stdin_snapshot sid2 13 "$five" 39 "$seven" [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 2 ] # the week strip reads them like fetched samples (same window key) rm -rf "$tmpdir" @@ -984,18 +1272,45 @@ _seed_week_store() { rm -rf "$tmpdir" } +@test "log_stdin_snapshot: an EXPIRED window is never a sample" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + printf '{"account":{"uuid":"u-1"}}' > "$tmpdir/profile.cache" + # An idle session — or a hand-piped fixture — reports the window it last + # saw. Logged, that pair reads as a huge drop and the next real sample + # re-climbs it: the learner counts the same burn twice. Measured on a + # real log, two such rows inflated a 50-point week to 146. + old5=$(( $(date +%s) - 7200 )); old7=$(( $(date +%s) - 3600 )) + log_stdin_snapshot stale 4 "$old5" 4 "$old7" + [ ! -f "$tmpdir/usage.jsonl" ] || [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 0 ] + # a live 5h window with an expired 7d one is still not a sample + log_stdin_snapshot stale 4 "$(( $(date +%s) + 3600 ))" 4 "$old7" + [ ! -f "$tmpdir/usage.jsonl" ] || [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 0 ] + # both live: logged + log_stdin_snapshot live 4 "$(( $(date +%s) + 3600 ))" 4 "$(( $(date +%s) + 86400 ))" + [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 1 ] + rm -rf "$tmpdir" +} + @test "log_stdin_snapshot: a stdin pair behind the cache in the same window is a stale session — not logged" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" printf '{"account":{"uuid":"u-1"}}' > "$tmpdir/profile.cache" - printf '{"five_hour":{"utilization":21,"resets_at":"2026-08-19T11:00:00.2+00:00"},"seven_day":{"utilization":43,"resets_at":"2026-08-19T16:00:00.1+00:00"},"fetched_at":1}' > "$tmpdir/usage.cache" - log_stdin_snapshot s 8 1787137200 41 1787155200 # same windows, behind the cache + five=$(( $(date +%s) + 3600 )); seven=$(( $(date +%s) + 86400 )) + printf '{"five_hour":{"utilization":21,"resets_at":"%s"},"seven_day":{"utilization":43,"resets_at":"%s"},"fetched_at":1}' \ + "$(date -u -d "@$five" '+%Y-%m-%dT%H:%M:%S.2+00:00')" \ + "$(date -u -d "@$seven" '+%Y-%m-%dT%H:%M:%S.1+00:00')" > "$tmpdir/usage.cache" + log_stdin_snapshot s 8 "$five" 41 "$seven" # same windows, behind the cache [ ! -f "$tmpdir/usage.jsonl" ] || [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 0 ] - log_stdin_snapshot s 23 1787137200 43 1787155200 # ahead: logged + log_stdin_snapshot s 23 "$five" 43 "$seven" # ahead: logged [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 1 ] # a NEWER 5h window is logged even though its number is lower printf '12|39 0\n' > "$tmpdir/stdin_seen" - log_stdin_snapshot s 2 1787155200 43 1787155200 + log_stdin_snapshot s 2 "$seven" 43 "$seven" + [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 2 ] + # ... but an OLDER 5h window than the cache is a stale session, not news + printf '12|39 0\n' > "$tmpdir/stdin_seen" + log_stdin_snapshot s 99 "$(( five - 18000 ))" 43 "$seven" [ "$(wc -l < "$tmpdir/usage.jsonl")" -eq 2 ] rm -rf "$tmpdir" }