From da75ee64fe8c84c06c44c751d6f26607993d3c12 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 28 Jul 2026 22:53:30 -0700 Subject: [PATCH 1/4] Fix ratio-learner window identity: catch . yields the error, not the input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jq's `catch .` binds the error MESSAGE as the value, not the original input. Snapshots with an empty or unparseable five_hour.resets_at all normalized to the same error string — one shared fake window identity — so the pct_per_window pair walk could pair samples across real windows. Observed on real data: phantom window transitions and a skewed ratio (9.91 -> 10.06 after the fix on the same log). Normalize via a named def that treats empty as empty and falls back to the raw string on parse failure. Co-Authored-By: Claude Fable 5 --- statusline.sh | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/statusline.sh b/statusline.sh index a319299..564c46c 100755 --- a/statusline.sh +++ b/statusline.sh @@ -1804,16 +1804,21 @@ build_seven_day_profile() { 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" ' - select((.user.uuid // "") == $a) # 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 - # fall back to the raw string. + # boundary), so normalize to the nearest minute. Non-ISO values + # fall back to the raw string ($raw — NOT `catch .`, which would + # yield the error MESSAGE and give every empty/broken value one + # shared fake identity, silently pairing across real windows). + def norm: tostring | . as $raw + | if $raw == "" then "" else + (try (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") + | fromdateiso8601 | (. + 30) / 60 | floor) catch $raw) + end; + select((.user.uuid // "") == $a) | [.timestamp, (.seven_day.utilization // ""), (.five_hour.utilization // ""), - ((.five_hour.resets_at // "") | tostring - | (try (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") - | fromdateiso8601 | (. + 30) / 60 | floor) catch .))] | @tsv' 2>/dev/null \ + ((.five_hour.resets_at // "") | norm)] | @tsv' 2>/dev/null \ | sort -n | awk -F'\t' -v now="$now" -v tz="$tzoff_s" ' $2 != "" { if (prev_set && $2 > prev) { From 02af0c748e4e8161358023d79daa9a2ad1484806 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 28 Jul 2026 22:53:40 -0700 Subject: [PATCH 2/4] The waste ledger: report subcommand, snapshots widened for the learner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `statusline.sh report [--days N]` replays usage.jsonl and ledgers every closed window: 7d closes as used%/expired% (converted to 5h-windows-worth via the learned pct_per_window ratio), 5h closes as count/avg/capped, plus the week in progress projected with the same learned walk the advisor uses — the surfaces cannot disagree. A window closes when consecutive samples disagree on resets_at, normalized to the minute (the ratio learner's identity rule). The advisor prevents waste prospectively; this proves it retroactively. Every usage snapshot now also logs limits[] (scoped weekly caps, verbatim), model (id active in the logging session), and predicted_end — the learned walk's projection at sample time, the calibration seed that lets closed windows score the forecast later. Learning lags logging by weeks; a field absent today is a pattern that can't be learned next month. Subcommands dispatch after all function definitions, take no stdin, and are read-only against the state dir. Co-Authored-By: Claude Fable 5 --- statusline.sh | 181 +++++++++++++++++++++++++++++++++++++++++++++- t/helpers.bash | 2 +- t/statusline.bats | 112 ++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 3 deletions(-) diff --git a/statusline.sh b/statusline.sh index 564c46c..2665740 100755 --- a/statusline.sh +++ b/statusline.sh @@ -22,6 +22,9 @@ cache_display_mode="auto" # auto, always, off test_mode=false test_data="" debug_mode=false +subcommand="" # report (see run_usage_report); dispatched late, + # after every function it needs is defined +report_days="" # Auto-display thresholds. The 5h countdown itself is always visible (see # build_usage_display); these gate the recovery color and extra visibility. @@ -235,6 +238,14 @@ debug_log() { while [[ $# -gt 0 ]]; do case $1 in + report) + subcommand="report" + shift + ;; + --days) + report_days="$2" + shift 2 + ;; --style) progress_bar_style="$2" shift 2 @@ -336,7 +347,12 @@ if [ -n "$theme" ]; then apply_theme fi -if [ "$test_mode" = true ]; then +if [ -n "$subcommand" ]; then + # Subcommands take no stdin; the shared parse below still runs, so give + # it an empty object and let the late dispatch (after all function + # definitions) do the real work. + input='{}' +elif [ "$test_mode" = true ]; then if [ -n "$test_data" ]; then input="$test_data" else @@ -1032,9 +1048,22 @@ log_usage_snapshot() { debug_log "log_usage_snapshot: no token provided" fi + # Calibration seed: the learned walk's end-of-week projection AT SAMPLE + # TIME. When this window later closes, `report` can compare what we + # predicted against what actually happened — the forecast's accuracy + # becomes measurable instead of assumed. Empty until the profile is warm. + local predicted_end="" _s_util _s_secs + _s_util=$(echo "$usage_data" | jq -r '.seven_day.utilization // empty' 2>/dev/null) + if [ -n "$_s_util" ]; then + _s_secs=$(get_reset_seconds "$(echo "$usage_data" | jq -r '.seven_day.resets_at // empty' 2>/dev/null)") + [ -n "$_s_secs" ] && read -r _ predicted_end <<<"$(_seven_day_walk "$_s_util" "$_s_secs")" + fi + echo "$usage_data" | jq -c \ --arg sid "$session_id" \ --arg ts "$(date +%s)" \ + --arg model "${model_id:-}" \ + --arg pend "${predicted_end:-}" \ --arg email "$user_email" \ --arg name "$user_name" \ --arg uuid "$user_uuid" \ @@ -1068,7 +1097,10 @@ log_usage_snapshot() { five_hour:.five_hour, seven_day:.seven_day, seven_day_opus:.seven_day_opus, - extra_usage:.extra_usage + extra_usage:.extra_usage, + limits:(.limits // []), + model:($model | if . == "" then null else . end), + predicted_end:($pend | if . == "" then null else tonumber end) }' \ >>"$usage_log" 2>/dev/null } @@ -1958,6 +1990,140 @@ forecast_pct_per_window() { awk -v p="${ppw:--1}" 'BEGIN{ if (p > 0) printf "%.2f", p }' } +# --------------------------------------------------------------------------- +# The waste ledger: `statusline.sh report [--days N]`. Mines usage.jsonl for +# closed windows and says, in percent and in windows, what expired unused. +# The advisor prevents waste prospectively; this proves it retroactively. +# +# A window "closes" when consecutive samples disagree on resets_at (normalized +# to the minute — the same identity rule the ratio learner uses): the last +# sample before the flip is that window's final utilization. Honest limits: +# usage from other clients after the last local render is invisible, and a +# window that was never sampled never existed as far as the log knows. +# --------------------------------------------------------------------------- +run_usage_report() { + local days="${1:-28}" + 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 + echo "no usage history yet ($jsonl)" + echo "the statusline logs every quota fetch; come back after a session or two." + return 1 + fi + local acct + acct=$(jq -r '.account.uuid // empty' "$CLAUDE_ACCOUNT_DIR/profile.cache" 2>/dev/null) + # profile.cache can postdate the log (fresh install): fall back to the + # last snapshot's identity rather than reporting nothing. + [ -n "$acct" ] || acct=$(tail -1 "$jsonl" 2>/dev/null | jq -r '.user.uuid // empty' 2>/dev/null) + local now cutoff + now=$(date +%s) + cutoff=$(( now - days * 86400 )) + # 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" ' + def norm: tostring | . as $raw + | if $raw == "" then "" else + (try (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") + | fromdateiso8601 | (. + 30) / 60 | floor) catch $raw) + end; + select((.type // "usage") == "usage" and (.user.uuid // "") == $a + and (.timestamp // 0) >= $cut) + | [.timestamp, (.five_hour.utilization // ""), (.seven_day.utilization // ""), + ((.five_hour.resets_at // "") | norm), ((.seven_day.resets_at // "") | norm)] + | @tsv' 2>/dev/null \ + | sort -n | awk -F'\t' ' + { + n++ + if ($4 != "" && pfk != "" && $4 != pfk && pfu != "") { + fcl++; fsum += pfu; if (pfu >= 99) fcap++ + } + if ($5 != "" && psk != "" && $5 != psk && psu != "") { + print "S", psk, psu + } + if ($4 != "") { pfk = $4; if ($2 != "") pfu = $2 } + if ($5 != "") { psk = $5; if ($3 != "") psu = $3 } + } + END { + printf "F %d %.0f %d\n", fcl, (fcl ? fsum / fcl : 0), fcap + printf "N %d\n", n + }') + + local ppw + ppw=$(forecast_pct_per_window) + local f_closed=0 f_avg=0 f_cap=0 samples=0 + read -r _ f_closed f_avg f_cap <<<"$(printf '%s\n' "$mined" | grep '^F ' | head -1)" + read -r _ samples <<<"$(printf '%s\n' "$mined" | grep '^N ' | head -1)" + + printf 'usage report - %s (last %sd, %s samples)\n\n' "${ACCOUNT_TAG:-default}" "$days" "${samples:-0}" + + local s_count + s_count=$(printf '%s\n' "$mined" | grep -c '^S ') + printf '7d windows closed: %s\n' "$s_count" + if [ "$s_count" -gt 0 ] 2>/dev/null; then + local key final used_i waste wins when sum_final=0 + while read -r _ key final; do + used_i=$(printf '%.0f' "$final" 2>/dev/null || echo 0) + waste=$(( 100 - used_i )); [ "$waste" -lt 0 ] && waste=0 + sum_final=$(( sum_final + used_i )) + case "$key" in + *[!0-9]*) when="$key" ;; + *) when=$(_fmt_epoch "$(( key * 60 ))" '%a %m-%d %H:%M') ;; + esac + if [ -n "$ppw" ]; then + wins=$(awk -v w="$waste" -v p="$ppw" 'BEGIN{printf "%.1f", w / p}') + printf ' %s used %s%% expired %s%% (~%s x 5h windows unused)\n' \ + "$when" "$used_i" "$waste" "$wins" + else + printf ' %s used %s%% expired %s%%\n' "$when" "$used_i" "$waste" + fi + done <<<"$(printf '%s\n' "$mined" | grep '^S ')" + if [ "$s_count" -gt 1 ] 2>/dev/null; then + local avg_used=$(( sum_final / s_count )) + printf ' avg at close: %s%% used / %s%% expired\n' "$avg_used" "$(( 100 - avg_used ))" + fi + fi + + if [ "${f_closed:-0}" -gt 0 ] 2>/dev/null; then + printf '\n5h windows closed: %s avg %s%% at close %s hit the cap\n' \ + "$f_closed" "$f_avg" "$f_cap" + else + printf '\n5h windows closed: 0\n' + fi + + if [ -n "$ppw" ]; then + local wpw + wpw=$(awk -v p="$ppw" 'BEGIN{printf "%.1f", 100 / p}') + printf 'exchange rate: one full 5h window = ~%s%% of the week (~%s windows/week, learned)\n' \ + "$ppw" "$wpw" + else + printf 'exchange rate: still learning (needs ~half a window of paired burn)\n' + fi + + # 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" + if [ -f "$uc" ]; then + local cur_su cur_reset secs repoch when gap end + cur_su=$(jq -r '.seven_day.utilization // empty' "$uc" 2>/dev/null) + cur_reset=$(jq -r '.seven_day.resets_at // empty' "$uc" 2>/dev/null) + if [ -n "$cur_su" ] && [ -n "$cur_reset" ]; then + secs=$(get_reset_seconds "$cur_reset") + repoch=$(_epoch_from_ts "$cur_reset") + 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' \ + "$cur_su" "$when" "$end" + else + printf '\nweek in progress: %.0f%% used, resets %s\n' "$cur_su" "$when" + fi + fi + fi + return 0 +} + # Generalized change flash: signed delta between the current value and the # last value THIS session rendered, held for QUOTA_BUMP_NOTICE_SECS after the # change so the refresh right after a jump still shows it. One JSON state @@ -2912,6 +3078,17 @@ build_1m_tag() { echo "[1m]" } +# Subcommand dispatch. Sits here on purpose: every function a subcommand +# needs is defined above, and none of the per-session render work (fetches, +# state writes, component building) has started below. Subcommands are +# read-only against the state dir. +if [ -n "$subcommand" ]; then + case "$subcommand" in + report) run_usage_report "${report_days:-28}"; exit $? ;; + esac + exit 0 +fi + runtime_model=$(get_runtime_model "$model_id") abbreviate_model_id() { diff --git a/t/helpers.bash b/t/helpers.bash index ffbfff1..45e9ec9 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -64,7 +64,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|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)\(\)/ { 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|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|log_usage_snapshot|detect_session_boundary|run_usage_report)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index 5dd346c..b375aef 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -650,6 +650,118 @@ _write_forecast_fixture() { rm -rf "$tmpdir" } +# --- widened snapshots + the waste ledger ---------------------------------- + +@test "log_usage_snapshot: records limits, model, null prediction while unlearned" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + model_id="claude-fable-5" + usage='{"five_hour":{"utilization":50},"seven_day":{"utilization":40},"limits":[{"kind":"weekly_scoped","percent":97,"scope":{"model":{"display_name":"Fable"}}}]}' + log_usage_snapshot "sess-1" "$usage" "" + line=$(grep '"type":"usage"' "$tmpdir/usage.jsonl" | tail -1) + [ "$(echo "$line" | jq -r '.model')" = "claude-fable-5" ] + [ "$(echo "$line" | jq -r '.limits[0].kind')" = "weekly_scoped" ] + [ "$(echo "$line" | jq -r '.predicted_end')" = "null" ] + rm -rf "$tmpdir" +} + +@test "log_usage_snapshot: stamps the learned end-of-week projection" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + model_id="" + printf '{"computed_at":0,"days_history":20,"recent_24h":0,"recent_48h":0,"weekday_profile":{"0":10,"1":10,"2":10,"3":10,"4":10,"5":10,"6":10}}' > "$tmpdir/forecast.cache" + reset=$(date -u -d '+4 days' '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"five_hour":{"utilization":50},"seven_day":{"utilization":43,"resets_at":"%s"}}' "$reset") + log_usage_snapshot "sess-1" "$usage" "" + line=$(grep '"type":"usage"' "$tmpdir/usage.jsonl" | tail -1) + pe=$(echo "$line" | jq -r '.predicted_end') + # 43% used + 4 days x 10%/day = ~83 (walk rounding may land 82) + [ "$pe" -ge 82 ] && [ "$pe" -le 83 ] + [ "$(echo "$line" | jq -r '.model')" = "null" ] + rm -rf "$tmpdir" +} + +# Two 7d windows (W1 closes at 62%), three 5h windows (F1 closes at 80, +# F2 at 99.5 = capped; F3 still open). Timestamps relative to now so the +# default cutoff keeps everything. +_write_ledger_fixture() { # dir + local dir="$1" now w1 w2 f1 f2 f3 + now=$(date +%s) + echo '{"account":{"uuid":"acct-A"}}' > "$dir/profile.cache" + w1=$(date -u -d "@$(( now - 3 * 86400 ))" '+%Y-%m-%dT%H:%M:%SZ') + w2=$(date -u -d "@$(( now + 4 * 86400 ))" '+%Y-%m-%dT%H:%M:%SZ') + f1=$(date -u -d "@$(( now - 4 * 86400 + 10800 ))" '+%Y-%m-%dT%H:%M:%SZ') + f2=$(date -u -d "@$(( now - 2 * 86400 + 10800 ))" '+%Y-%m-%dT%H:%M:%SZ') + f3=$(date -u -d "@$(( now - 86400 + 10800 ))" '+%Y-%m-%dT%H:%M:%SZ') + : > "$dir/usage.jsonl" + _lg() { # ts five five_reset seven seven_reset + printf '{"type":"usage","timestamp":%s,"user":{"uuid":"acct-A"},"five_hour":{"utilization":%s,"resets_at":"%s"},"seven_day":{"utilization":%s,"resets_at":"%s"}}\n' \ + "$1" "$2" "$3" "$4" "$5" >> "$dir/usage.jsonl" + } + _lg "$(( now - 4 * 86400 ))" 30 "$f1" 55 "$w1" + _lg "$(( now - 4 * 86400 + 3600 ))" 80 "$f1" 62 "$w1" + _lg "$(( now - 2 * 86400 ))" 20 "$f2" 5 "$w2" + _lg "$(( now - 2 * 86400 + 3600 ))" 99.5 "$f2" 12 "$w2" + _lg "$(( now - 86400 ))" 10 "$f3" 20 "$w2" +} + +@test "run_usage_report: ledgers closed windows and expired capacity" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_ledger_fixture "$tmpdir" + run run_usage_report 28 + [ "$status" -eq 0 ] + [[ "$output" == *"7d windows closed: 1"* ]] + [[ "$output" == *"used 62% expired 38%"* ]] + [[ "$output" == *"5h windows closed: 2 avg 90% at close 1 hit the cap"* ]] + [[ "$output" == *"still learning"* ]] + rm -rf "$tmpdir" +} + +@test "run_usage_report: converts waste to windows once the ratio is learned" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_ledger_fixture "$tmpdir" + printf '{"computed_at":0,"days_history":20,"recent_24h":0,"recent_48h":0,"pct_per_window":10,"weekday_profile":{"0":10,"1":10,"2":10,"3":10,"4":10,"5":10,"6":10}}' > "$tmpdir/forecast.cache" + reset=$(date -u -d '+4 days' '+%Y-%m-%dT%H:%M:%SZ') + printf '{"seven_day":{"utilization":44,"resets_at":"%s"}}' "$reset" > "$tmpdir/usage.cache" + run run_usage_report 28 + [ "$status" -eq 0 ] + # 38% expired / 10 ppw = 3.8 windows' worth + [[ "$output" == *"(~3.8 x 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"* ]] + rm -rf "$tmpdir" +} + +@test "run_usage_report: --days cutoff hides older closes; no history exits 1" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_ledger_fixture "$tmpdir" + run run_usage_report 2 + [ "$status" -eq 0 ] + [[ "$output" == *"7d windows closed: 0"* ]] + empty=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$empty" + run run_usage_report 28 + [ "$status" -eq 1 ] + [[ "$output" == *"no usage history yet"* ]] + rm -rf "$tmpdir" "$empty" +} + +@test "integration: statusline.sh report renders the ledger end-to-end" { + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/data" + _write_ledger_fixture "$tmpdir/data" + run env HOME="$tmpdir" CLAUDE_DATA_DIR="$tmpdir/data" bash "$SCRIPT_DIR/statusline.sh" report --days 14 + [ "$status" -eq 0 ] + [[ "$output" == *"usage report - default"* ]] + [[ "$output" == *"7d windows closed: 1"* ]] + rm -rf "$tmpdir" +} + _write_profile_cache() { # $1=dir $2=days_history $3=rate_all_days $4=recent24 cat > "$1/forecast.cache" < Date: Tue, 28 Jul 2026 22:55:38 -0700 Subject: [PATCH 3/4] v0.20.0: waste ledger release surfaces CHANGELOG entry, README section with a real ledger example, state-dir contract gains the widened usage.jsonl fields (limits, model, predicted_end) and names report as the reference consumer, counts synced to 327 tests across README / llms.txt / site. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 46 +++++++++++++++++++++++++++++++++++++++++++ README.md | 32 +++++++++++++++++++++++++++++- docs/api/state-dir.md | 18 +++++++++++++++-- docs/index.html | 6 +++--- llms.txt | 7 ++++--- 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de27e42..24e4cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## v0.20.0 — 2026-07-28 — the waste ledger: see what you paid for and didn't use + +**New: `statusline.sh report [--days N]` — the waste ledger.** The +advisor (v0.19) prevents waste prospectively; this proves it +retroactively. It replays the usage log the statusline has been writing +all along, detects every window close (consecutive samples disagreeing +on `resets_at`, normalized to the minute — the ratio learner's identity +rule), and ledgers each closed 7d window as used% / expired%, converted +into 5h-windows-worth via the learned `pct_per_window` exchange rate: + +``` +7d windows closed: 1 + Tue 07-28 00:00 used 51% expired 49% (~4.7 x 5h windows unused) +5h windows closed: 3 avg 95% at close 2 hit the cap +exchange rate: one full 5h window = ~10.46% of the week (~9.6 windows/week, learned) +week in progress: 5% used, resets Mon 08-03 23:59 +``` + +"week in progress" runs the same learned walk the advisor's heading +uses — the surfaces cannot disagree. Honest limits are documented: the +final utilization is the last sample before a reset, so usage from other +clients after your last local render is invisible, and never-sampled +windows don't appear. + +**Widened snapshots: log today what the learner needs next month.** +Every `usage` line in usage.jsonl now also records `limits[]` (scoped +per-model weekly caps, verbatim), `model` (the id active in the logging +session), and `predicted_end` — the learned walk's end-of-week +projection *at sample time*. That last one is the calibration seed: once +windows close, predictions can be scored against observed finals, and +the forecast's accuracy becomes measurable instead of assumed. Learning +lags logging by weeks; fields absent today are patterns that can't be +learned next month. + +**Fixed: `catch .` gave every empty `resets_at` one shared fake window +identity.** jq's `catch .` yields the error *message*, not the original +input — so snapshots with an empty/unparseable `resets_at` all +normalized to the same error string and could pair across real windows +in the ratio learner, quietly skewing `pct_per_window` (observed on real +data: phantom window closes and a drifted ratio). Both norm sites now +fall back to the raw input string and treat empty as empty. + +Mechanics: subcommands dispatch after all function definitions, take no +stdin, and are read-only against the state dir. 327 tests (315 +statusline + 12 installer). + ## v0.19.0 — 2026-07-28 — smart advisor line, claude-watch retired **New: the advisor — a second statusline row that interprets the badges.** diff --git a/README.md b/README.md index dbd39b9..1216380 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,36 @@ notifications), see `claude.py --watch-usage` in [claudex](https://github.com/thevibeworks/claudex) — it consumes the same state dir this script maintains (see `docs/api/state-dir.md`). +## The waste ledger + +The advisor prevents waste prospectively; `report` proves it +retroactively. It replays the usage log the statusline has been writing +all along and ledgers every closed window — what you used, what expired: + +```bash +$ ~/.claude/statusline.sh report # or --days 90 +usage report - work (last 28d, 79 samples) + +7d windows closed: 1 + Tue 07-28 00:00 used 51% expired 49% (~4.7 x 5h windows unused) + +5h windows closed: 3 avg 95% at close 2 hit the cap +exchange rate: one full 5h window = ~10.46% of the week (~9.6 windows/week, learned) + +week in progress: 5% used, resets Mon 08-03 23:59 +``` + +That "expired 49%" line is the subscription math nobody shows you: half +a week of paid capacity, gone. The windows-worth figure uses the same +learned `pct_per_window` ratio the advisor's feasibility check uses, and +"week in progress" runs the same learned projection — the surfaces +cannot disagree. + +Honest limits: a window's final utilization is the last sample before +its reset, so usage from other devices after your last local render is +invisible, and a week you never opened a session in never appears at +all. The ledger reports what the log observed, nothing more. +
OAuth and API behavior Quota, profile, and extra-usage requests use Claude Code's OAuth credentials @@ -332,7 +362,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior. npm exec --yes bats -- t/ ``` -321 tests across `t/statusline.bats` (309 statusline + integration) and +327 tests across `t/statusline.bats` (315 statusline + integration) and `t/install.bats` (12 installer). CI runs on push and PR to `main`. ## Project Structure diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index 3a6bf21..58a7536 100644 --- a/docs/api/state-dir.md +++ b/docs/api/state-dir.md @@ -7,7 +7,7 @@ is the ONLY writer; everything here is safe to read concurrently. - Contract version: **1** (bump on any breaking layout/field change; this file is the changelog) -- Synced with: statusline.sh v0.19.0 +- Synced with: statusline.sh v0.20.0 - Permissions: the script runs under `umask 077` — files are owner-only. Caches hold account PII (email, uuid, org names). @@ -91,13 +91,27 @@ Append-only, one JSON object per line, three event types: | `type` | Emitted | Payload | |--------|---------|---------| -| `usage` | every successful usage fetch | `session_id`, `timestamp`, `user{email,name,uuid,…}`, `organization{…}`, `five_hour`, `seven_day`, `seven_day_opus`, `extra_usage` | +| `usage` | every successful usage fetch | `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_end` | 5h window rolled while a different session was last | `session_id`, `timestamp` | +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): + +| Field | Type | Meaning | +|-------|------|---------| +| `limits[]` | array | scoped limits verbatim (per-model weekly caps) | +| `model` | string \| null | model id active in the logging session | +| `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. | + Rotation keeps exactly one `.1` backup; readers wanting full history read `usage.jsonl.1` then `usage.jsonl`. +The `report` subcommand (`statusline.sh report [--days N]`) is the +reference consumer: it replays this log and ledgers what each closed +window expired unused. + ## Consumer rules 1. Read-only. Locks, TTLs, and rotation are the writer's job. diff --git a/docs/index.html b/docs/index.html index 9cf1ce6..c08c1b5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ Claude Code Statusline — quota, context, cost. Live, in one bash file. - +