From 2ae8da6300c9d1a439fbd0f21e6c69d2c5eadbcd Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Mon, 31 Aug 2026 23:33:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(forecast):=20the=20night=20is=20not=20runw?= =?UTF-8?q?ay=20=E2=80=94=20an=20hour=20shape=20for=20the=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code can work around the clock; the human cannot, and every projection this script made burned flat through the night. Four measured consequences: `7d dry ~Thu 03:00`, which is a false alarm at 23:00 and a missed warning at 09:00; an L1 blend projecting a hot day's rate through eight idle hours; "spend it" at 23:00 for a surplus no waking hour can reach; and a ration dividing the week by windows you sleep through. The corpus already held the answer — burn credited by the envelope pass carries a timestamp, and hours that never burn across weeks are rest. `forecast.cache` gains `hour_profile`: 24 burn multipliers, local hour "0".."23", mean 1, so the rate at hour h is `weekday_rate x mult[h]` and integrating any whole day reproduces the weekday total exactly. Same envelope pass, same 14-day EWMA half-life, today excluded (a day that has only reached noon reports every evening hour as rest). Floored at 0.1 and renormalized at BUILD time so every reader sees the same numbers: a rest hour projects a tenth of a uniform hour, never zero, which is what the occasional overnight autonomous run costs. Additive, so `schema` stays 2; ccpace v0.4.0 implements the identical model and `docs/api/state-dir.md` is the contract, down to REST_MULT_MAX = 0.25. Read under one validation — 24 keys, each numeric in [0, 24], mean in [0.9, 1.1], days_history >= 14 — and anything else walks flat. A bad hour shape narrows no clause and silences nothing; only the weekday guards take the walk's voice away. The walk steps local hours now (<= 169 of them), because a rate that changes at 08:00 cannot be integrated a day at a time. That also ends the L1 blend at exactly 24h: day-sized steps let a blend starting mid-day run to the end of the day holding now+24h, up to 47 hours of a hot rate on a sentence claiming 24. On a calm 10%/day profile with a 40% trailing day, `lands ~70%` where it used to be ~73%. Surfaced in three places. The budget long form gains `~6 awake` — the windows falling in hours you have actually burned in — shown only when it cuts the calendar count, and `even` then divides by it, because a ration you can only hit by not sleeping is not a ration; at zero awake windows both clauses go and the landing stands alone. The surplus feasibility check counts awake seconds per leg, so `~35% expires even at full burn` replaces `spend it` on a night. `report` states the rhythm it walks: the longest circular run of rest hours, three or more, else "no rest learned". Row 2, the strip fold and the windows-ahead countdown are untouched. Dry warnings need no new words: the shaped walk moves the dry TIME out of sleep by itself, which was the early-warning fix all along. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 67 ++++++++ DESIGN.md | 17 ++ README.md | 5 +- docs/api/state-dir.md | 50 +++++- skills/usage-insight/SKILL.md | 12 +- statusline.sh | 264 +++++++++++++++++++++++++---- t/helpers.bash | 3 +- t/statusline.bats | 301 ++++++++++++++++++++++++++++++++++ 8 files changed, 682 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b8642..99b356e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # Changelog +## v0.36.0 — 2026-09-01 — the night is not runway + +**The forecast learns when you work, not just how much.** Claude Code can +work around the clock; the human cannot, and every projection this script +made burned flat through the night. The bill came in four places: a +`7d dry ~Thu 03:00` that is a false alarm at 23:00 and a missed warning at +09:00; an L1 blend projecting a hot day's rate through eight idle hours; +"spend it" appearing at 23:00 for a surplus no waking hour can reach; and +a ration dividing the week by windows you sleep through. The corpus +already held the answer — burn credited by the envelope pass carries a +timestamp, and hours that never burn across weeks are rest. + +`forecast.cache` gains `hour_profile`: 24 burn multipliers, local hour +`"0"`..`"23"`, mean 1, so the rate at hour h is `weekday_rate x mult[h]` +and integrating any whole day reproduces the weekday total exactly. Same +envelope pass, same 14-day EWMA half-life, and today is excluded from it — +a day that has only reached noon reports every evening hour as rest. The +shape is floored at 0.1 and renormalized at BUILD time, so every reader +sees the same numbers: a rest hour projects a tenth of a uniform hour, +never zero, which is what the occasional overnight autonomous run costs. +The field is additive, so `schema` stays 2; ccpace v0.4.0 writes and reads +the identical model (`docs/api/state-dir.md` is the contract, down to +`REST_MULT_MAX = 0.25`). + +**Absent or broken, the walk is exactly what it was.** The shape is read +under one validation — 24 keys, each numeric in `[0, 24]`, mean in +`[0.9, 1.1]`, `days_history >= 14` — and anything else walks flat. A bad +hour shape narrows no clause and silences nothing; only the weekday guards +can take the walk's voice away, which is the same division of labour the +corrupt-profile guard has always had. + +**The walk steps hours now, and the L1 blend ends where it says it does.** +Day-sized steps could not integrate a rate that changes at 08:00. They +also let the recent-24h blend, once it started mid-day, run to the end of +the day holding `now + 24h` — up to 47 hours of a hot day's rate on a +sentence that claims 24. The blend is tested at each hour segment now, so +it ends at exactly 24h out, and both tools reading this cache walk the +same horizon. On a calm 10%/day profile with a 40% trailing day, that is +`lands ~70%` where it used to be `~73%`. + +**`~6 awake` beside the window count.** The budget line ends up with three +clauses and each answers a different question: `budget ~9✕5h left · ~6 +awake · even 3.8%/win · lands ~52%`. The awake count is the windows that +fall in hours you have actually burned in; it appears only when it cuts +the calendar count, and it is there to name the denominator beside it — +`even` divides by the awake windows when the clause is present. A ration +you can only hit by not sleeping is not a ration. At zero awake windows +both clauses go and the landing stands alone. Row 2 is unchanged: columns +are precious, and the strip beside it still counts every window ahead. + +**"Spend it" costs awake hours now.** The surplus feasibility check +(`pct_per_window x time / 5h`, capped by the current window's headroom) +counts only awake seconds in each leg. The current-window leg keeps its +own cap; unlearned, the arithmetic is identical to before. A 35% surplus +with 20 hours to the reset and 22 of the next 24 at rest reads `~35% +expires even at full burn` instead of `spend it`. + +**`report` states the rhythm it walks.** One line, when learned: +`rhythm: rest ~00:00-08:00 · 16h awake/day (learned)` — the longest +circular run of rest hours, printed at three or more, because sleep wraps +midnight and a run cut there reads as two short ones. An account that +never stops gets `rhythm: no rest learned — burns around the clock`. + +Dry warnings need no new words: the shaped walk moves the dry TIME out of +sleep by itself, which was the early-warning fix all along. The strip fold +`(✕N)` and the windows-ahead countdown are untouched. + ## 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 diff --git a/DESIGN.md b/DESIGN.md index 0864b41..9fff311 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -167,6 +167,23 @@ fb 91% vs 7d 55% the MODEL caps first, not the account: switch, and the ~62% will expire on pace to strand a large chunk of the subscription ``` +The budget line is the one sentence with three clauses, and each answers a +different question: + +``` +budget ~9✕5h left · ~6 awake · even 3.8%/win · lands ~52% + count awake ration prediction +``` + +`~N awake` is how many of those windows fall in hours this account has, week +after week, actually burned in (`hour_profile`, `mult ≥ 0.25`). It appears +only when it *cuts* the count, and it is there to name the denominator beside +it: `even` divides by the awake windows when the clause is present and by all +of them when it is not. A ration you can only hit by not sleeping is not a +ration. At zero awake windows both clauses go and the landing stands alone. +Row 2 is unchanged — it carries the landing, and falls back to the ration +using the same denominator the long form would. + ## Requests The API is asked for what the protocol does not hand us, and no more. diff --git a/README.md b/README.md index 5ffd50e..d398507 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ clauses about one window can never disagree. |--------|-----------------------------------| | `! fb capped ~Thu 07:00` | The weekly limit scoped to *this session's model* hit 100%: that model is gone until then. The long form names a model that still has room. | | `! 5h caps ~05:18` | Linear projection off this window's own pace: you hit the wall before the reset. Long form adds how long you'd sit blocked. | -| `! 7d dry ~Thu 09:00 · hard stop` | The learned per-weekday burn profile (EWMA over your own history), not a straight line — your heavy Tuesday counts more than an average. Tail says what happens at 100%: extra billing, or a stop. | +| `! 7d dry ~Thu 09:00 · hard stop` | The learned per-weekday burn profile (EWMA over your own history), not a straight line — your heavy Tuesday counts more than an average, and the learned hour-of-day shape puts the dry point where you are awake to act on it instead of at 03:00. Tail says what happens at 100%: extra billing, or a stop. | | `! fb 91% vs 7d 55% · go op` | **The relation** between two badges: the *model* caps before the account does. Switching models buys the week's remaining capacity back; the roomiest other `weekly_scoped` limit in the payload gets named. | | `+ 7d rebased 53%→12%` | Utilization fell *inside* one window instance: burn never runs backwards, so this is a plan change or an out-of-band reset. The projections restart; the ledger still draws the old period. | | `+ last 5h of the week · 47% unused` | The 7d window resets inside this 5h window: no later window exists to spend the remainder through. | @@ -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 · 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. | +| `19✕5h left · lands ~52%` | The calm budget: runway plus the destination. The long form carries all three clauses — `budget ~19✕5h left · ~13 awake · even 1.6%/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). `~13 awake` is how many of those windows fall in hours you have actually burned in (the learned `hour_profile`); it shows only when it cuts the count, and `even` then divides by it — a ration you can only hit by not sleeping is not a ration. 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 @@ -419,6 +419,7 @@ usage report - work (last 28d, 79 samples) 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) +rhythm: rest ~00:00-08:00 · 16h awake/day (learned) week in progress: 5% used, resets Mon 08-03 23:59 ``` diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index a7113e6..3e98a36 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.35.0 +- Synced with: statusline.sh v0.36.0 - Permissions: the script runs under `umask 077` — files are owner-only. Caches hold account PII (email, uuid, org names). @@ -56,7 +56,7 @@ a writer that cannot honor all of them must use its own dir instead: usage.cache last /api/oauth/usage response + fetched_at profile.cache last /api/oauth/profile response (24h TTL) prepaid_credits.cache prepaid balance + fetched_at (5m TTL) - forecast.cache learned weekday burn profile (hourly rebuild) + forecast.cache learned weekday + hour burn profile (hourly rebuild) week.cache week-row cells: 7d per-window + 5h per-half-hour burn (rebuilt when usage.jsonl grows) stdin_seen last stdin rate_limits pair logged (`5h|7d epoch`) — dedupe for source:stdin samples usage.jsonl append-only usage/session event log @@ -115,6 +115,7 @@ Output of the hourly usage.jsonl scan (EWMA half-life 14 days): "recent_24h": 14.20, "recent_48h": 22.10, "pct_per_window": 11.83, "weekday_profile": {"0": 5.1, "1": 27.3, "…": 0, "6": -1}, + "hour_profile": {"0": 0.09, "…": 1.83, "23": 1.24}, "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, @@ -162,6 +163,51 @@ inside the same 5h window; `-1` until enough paired burn has been observed (>= half a window). Snapshots are partitioned by `account.uuid` before aggregation. +`hour_profile` is the same account on the other axis: 24 keys, local hour +`"0"`..`"23"`, values are burn MULTIPLIERS with mean 1. The instantaneous +rate at hour h is `weekday_rate * mult[h]` (%/day), so integrating any +whole day reproduces the weekday total exactly — the weekday says what a +Tuesday costs, the hour says when in it. The field is additive, so +`schema` stays 2: a reader that has never heard of it walks flat, which is +what every reader did before it existed. + +Built inside the same envelope pass — each credited delta is also credited +to `(local_day, local_hour)`, EWMA-weighted by day age with the same +14-day half-life, and today is EXCLUDED, because a day that has only +reached noon reports every evening hour as rest. The weighted share per +hour becomes `m[h] = max(share[h] * 24, 0.1)`, then the whole vector is +scaled so the mean is exactly 1, rounded to 2 decimals. Floored AND +renormalized at BUILD time so every reader sees the same numbers instead +of each applying the hedge its own way; a floored hour therefore prints +slightly under the floor (`0.09`) once many hours were floored — that is +correct, do not re-floor on read. The 0.1 floor is the hedge for the +occasional overnight autonomous run: a rest hour projects a tenth of a +uniform hour, never zero. Omitted entirely when no weighted burn exists. + +Use it only if it is an object with all 24 keys, every value numeric in +`[0, 24]`, and the mean in `[0.9, 1.1]` — plus the `days_history >= 14` +gate every learned surface shares. Invalid or absent means FLAT (mult +== 1), never silence: a bad hour shape must not take a good forecast away, +and only the weekday guards do that. Both readers walk the profile by +stepping local-hour boundaries, and the recent-24h blend that opens the +walk ends at exactly 24h out. + +``` +REST_MULT_MAX = 0.25 an hour below this is a REST hour +``` + +An AWAKE WINDOW is a 5h window you are awake for. Over the span +`[now + five_secs, now + seven_secs]` — the same span the windows-ahead +count covers, with `five_secs = 0` when no 5h window is live — +`awake_secs` is the part of it falling in local hours with `mult >= +REST_MULT_MAX`, and `awake = min(windows_ahead, ceil(awake_secs / +18000))`; it ceils for the reason windows-ahead does, a partial window +being still spendable. A surface names the awake count only when the +shape is learned, `windows_ahead >= 1`, and `awake < windows_ahead` — an +equal count would spend a clause to say nothing. At `awake == 0` both the +awake clause and the ration that would divide by it go away; the landing +still speaks. + `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 diff --git a/skills/usage-insight/SKILL.md b/skills/usage-insight/SKILL.md index 086cc96..ce66898 100644 --- a/skills/usage-insight/SKILL.md +++ b/skills/usage-insight/SKILL.md @@ -47,12 +47,18 @@ the deep-dive version of what the advisor row says in one sentence. ```json {"days_history": 21, "recent_24h": 14.2, "pct_per_window": 11.83, - "weekday_profile": {"0": 5.1, "1": 27.3, "6": -1}} + "weekday_profile": {"0": 5.1, "1": 27.3, "6": -1}, + "hour_profile": {"0": 0.09, "9": 1.83, "23": 1.24}} ``` - `weekday_profile`: learned percent-of-7d-quota burned per weekday (0=Sun..6=Sat), EWMA half-life 14 days; `-1` = never observed. Cold start below 14 `days_history` — say projections are unlearned. +- `hour_profile`: burn multiplier per local hour, mean 1 — the rate at + hour h is `weekday_profile[dow] x hour_profile[h]`. Below 0.25 is a + REST hour; absent or malformed means flat, never silence. Hours the + account sleeps through are not capacity: count only those at or above + 0.25 when you say what is spendable before a reset. - `pct_per_window`: the exchange rate — 7d percentage points one fully burned 5h window costs THIS account. `-1` = unlearned. A week holds roughly `100 / pct_per_window` full windows. @@ -66,7 +72,9 @@ the deep-dive version of what the advisor row says in one sentence. hours to its reset, the burnable maximum is roughly `pct_per_window x (T / 5h)` (capped by the current 5h window's own headroom). If that is less than S, do NOT say "use it up" — say how - much expires no matter what. + much expires no matter what. T is AWAKE hours once `hour_profile` is + learned: "spend it" at 23:00 is advice to burn a week through eight + hours of sleep. - With `pct_per_window = -1`, state facts, not advice. - A scoped limit (`limits[]`) at 100% means that model is unavailable: the useful fact is its `resets_at`, not the percentage. diff --git a/statusline.sh b/statusline.sh index 0a30c81..f20d368 100755 --- a/statusline.sh +++ b/statusline.sh @@ -2089,6 +2089,13 @@ usage_corpus_sig() { # not", without either tool having to know the other exists. FORECAST_SCHEMA=2 +# An hour whose learned burn multiplier sits below this is a REST hour: week +# after week, this account has burned a tenth of a uniform hour in it. Part of +# the cache contract (docs/api/state-dir.md) rather than a local threshold — +# ccpace divides the same surplus by the same awake windows, and two tools +# that disagree about which hours are spendable disagree about the ration. +REST_MULT_MAX=0.25 + # 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() { @@ -2109,6 +2116,7 @@ rotate_usage_log() { # can read for free: # { schema, computed_at, days_history, recent_24h, recent_48h, # pct_per_window, weekday_profile: {"0":sun.. "6":sat, unknown = -1}, +# hour_profile: {"0".."23" burn multipliers, mean 1; absent = unlearned}, # 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). @@ -2217,6 +2225,11 @@ build_seven_day_profile() { if (v <= env) return d = v - env; env = v; day = int((ts + tz) / 86400) burn[day] += d; credited = d + # The same delta on a second axis: the LOCAL HOUR it landed in. + # Claude Code can work at 03:00 and the human cannot, so a walk + # that burns flat through the night puts a dry-out in the middle + # of sleep — a false alarm at 23:00 and a missed warning at 09:00. + hburn[day SUBSEP int(((ts + tz) % 86400) / 3600)] += d if (now - ts <= 86400) r24 += d if (now - ts <= 172800) r48 += d } @@ -2321,6 +2334,17 @@ build_seven_day_profile() { dw = (kp[2] + 4) % 7 cnum[dw] += cburn[k] * w; cden[dw] += w } + # The hour shape: burn SHARE per local hour, weighted by the same + # 14-day half-life. Today is excluded — a day that has only + # reached noon reports every evening hour as rest, and the shape + # is exactly the thing that must not learn that. + for (k in hburn) { + split(k, kp, SUBSEP) + if (kp[1] + 0 == today) continue + age = today - kp[1]; if (age < 0) age = 0 + w = exp(-0.0495 * age) + hnum[kp[2] + 0] += hburn[k] * w; hden += hburn[k] * w + } 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 @@ -2331,6 +2355,27 @@ build_seven_day_profile() { printf "%s\"%d\":%.2f", sep, i, p; sep = "," } printf "}," + # Published floored AND renormalized, so every reader sees the + # same numbers rather than each applying the hedge its own way: + # a rest hour projects a tenth of a uniform hour, never zero, + # which is what the occasional overnight autonomous run costs. + # Omitted entirely when nothing has been credited — a reader with + # no shape walks flat, which is the behavior that predates this + # field. + if (hden > 0) { + hsum = 0 + for (i = 0; i < 24; i++) { + hm[i] = hnum[i] / hden * 24 + if (hm[i] < 0.1) hm[i] = 0.1 + hsum += hm[i] + } + printf "\"hour_profile\":{" + sep = "" + for (i = 0; i < 24; i++) { + printf "%s\"%d\":%.2f", sep, i, hm[i] * 24 / hsum; sep = "," + } + printf "}," + } printf "\"scoped_name\":%s,", (sc_last == "" ? "null" : "\"" sc_last "\"") printf "\"scoped_recent_24h\":%.2f,", (sc_last == "" ? -1 : cr24[sc_last]) printf "\"scoped_profile\":{" @@ -2360,7 +2405,7 @@ build_seven_day_profile() { fi } -# Project the remaining window day-by-day against the learned profile and +# Project the remaining window hour-by-hour against the learned profile and # echo " " when the quota dries up BEFORE the reset # ("you will be out of usage while days still remain"). Empty output = no # verdict (no cache, cold start < 14 days of history, or quota outlasts the @@ -2368,13 +2413,18 @@ build_seven_day_profile() { # hot streak escalates before the weekday average catches up (L1 blend). # Shared learned-profile walker: simulate burn from now to the reset using # the weekday profile (recent-24h blended over the first day, exactly the -# seven_day_forecast behavior). Echoes "gap_h projected_end": +# seven_day_forecast behavior), shaped by the learned hour profile — the +# weekday says how much a Tuesday costs, the hour says WHEN in it. Echoes +# "gap_h projected_end": # gap_h hours between drying up and the reset, -1 if the quota # outlasts the window # projected_end final utilization at the reset, capped at 100 -# Silent on cold start (<14 days history) or missing/empty inputs. +# Silent on cold start (<14 days history) or missing/empty inputs. `now` is +# injectable because the hour shape makes the answer depend on when the walk +# runs, and a test that cannot pin the hour cannot assert where a dry-out +# lands. _profile_walk() { - local used="$1" secs_left="$2" prof_key="${3:-weekday_profile}" recent_key="${4:-recent_24h}" + local used="$1" secs_left="$2" prof_key="${3:-weekday_profile}" recent_key="${4:-recent_24h}" now="${5:-}" local fc="$CLAUDE_ACCOUNT_DIR/forecast.cache" [ -f "$fc" ] || return 0 [ -n "$secs_left" ] && [ "$secs_left" -gt 0 ] 2>/dev/null || return 0 @@ -2389,14 +2439,22 @@ _profile_walk() { local used_int used_int=$(printf '%.0f' "$used" 2>/dev/null || echo 0) [ "$used_int" -gt 0 ] 2>/dev/null || return 0 - local now tzoff_s - now=$(date +%s) + local tzoff_s + [ -n "$now" ] || 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) }') + # The 24 hour multipliers ride the same TSV. Anything that is not a + # number lands as -1 and fails the read validation below: @tsv refuses an + # object or an array outright, and a walk that goes silent because a + # foreign writer put a nested value in hour_profile would be a bad shape + # silencing a good forecast. jq -r --arg p "$prof_key" --arg r "$recent_key" ' (.[$p] // {}) as $wp + | (if ((.hour_profile // null) | type) == "object" then .hour_profile else {} end) as $hp | [.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 \ + ($wp["4"] // -1), ($wp["5"] // -1), ($wp["6"] // -1)] + + [range(0; 24) | ($hp[tostring] // -1) + | if type == "number" then . else -1 end] | @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 @@ -2416,20 +2474,44 @@ _profile_walk() { for (i = 0; i <= 6; i++) if (prof[i] >= 0) { sum += prof[i]; known++ } if (known == 0) exit fb = sum / known + # The hour shape, validated exactly as the cache contract states it: + # 24 keys, each numeric in [0, 24], mean in [0.9, 1.1]. Absent or + # broken reads as FLAT, which is this walk before the field existed — + # a bad hour shape narrows nothing and silences nothing. Only the + # weekday guards above can silence the walk. + hp_ok = 1; hsum = 0 + for (i = 0; i < 24; i++) { + hm[i] = $(i + 10) + 0 + if (hm[i] < 0 || hm[i] > 24) hp_ok = 0 + hsum += hm[i] + } + if (hsum / 24 < 0.9 || hsum / 24 > 1.1) hp_ok = 0 + if (!hp_ok) for (i = 0; i < 24; i++) hm[i] = 1 remaining = 100 - used t = now; end = now + left; burned = 0; gap_h = -1 + # Local-hour steps (<= 169 of them), because a rate that changes at + # 08:00 cannot be integrated a day at a time. while (t < end) { - day = int((t + tz) / 86400) - day_end = (day + 1) * 86400 - tz - step = (day_end < end ? day_end : end) - t + lt = t + tz + day = int(lt / 86400) + seg = t + 3600 - (lt % 3600) + if (seg > end) seg = end + step = seg - t rate = prof[(day + 4) % 7]; if (rate < 0) rate = fb + # L1 blend, tested at the segment start as it always was — which + # on hour steps means it now ends at exactly 24h out. Day steps + # let a blend that began mid-day run to the end of the day + # holding t+24h, so a hot rate could be projected through 47 + # hours; the 24h horizon is what the field says and what ccpace + # walks, and two surfaces reading one cache may not disagree. if (t - now < 86400 && r24 > rate) rate = r24 # L1 blend + rate *= hm[int((lt % 86400) / 3600)] add = rate * step / 86400.0 if (gap_h < 0 && burned + add >= remaining && rate > 0) { dry = t + (remaining - burned) / rate * 86400.0 gap_h = int((end - dry) / 3600.0) } - burned += add; t = day_end + burned += add; t = seg } total = used + burned if (total > 100) total = 100 @@ -2439,8 +2521,8 @@ _profile_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_walk() { _profile_walk "$1" "$2" weekday_profile recent_24h "${3:-}"; } +_scoped_walk() { _profile_walk "$1" "$2" scoped_profile scoped_recent_24h "${3:-}"; } seven_day_forecast() { local used="$1" secs_left="$2" @@ -2501,6 +2583,51 @@ forecast_pct_per_window() { awk -v p="${ppw:--1}" 'BEGIN{ if (p > 0) printf "%.2f", p }' } +# The learned hour shape as 24 space-separated multipliers, or nothing while +# it is unlearned. The read validation is the cache contract +# (docs/api/state-dir.md): 24 keys, every value numeric in [0, 24], mean in +# [0.9, 1.1], plus the same >= 14 days of history every other learned surface +# waits for. Anything else reads as flat — no clause narrows, nothing goes +# quiet. `|| true`: jq exits nonzero on a truncated cache, and under `set -e` +# a bare `x=$(cmd)` aborts AT the assignment. +hour_profile_mults() { + local fc="$CLAUDE_ACCOUNT_DIR/forecast.cache" + [ -f "$fc" ] || return 0 + jq -r 'if (.days_history // 0) < 14 then empty else + (if ((.hour_profile // null) | type) == "object" then .hour_profile else {} end) as $h + | [range(0; 24) | ($h[tostring] // -1) | if type == "number" then . else -1 end] + | if (map(select(. >= 0 and . <= 24)) | length) == 24 + and (add / 24) >= 0.9 and (add / 24) <= 1.1 + then map(tostring) | join(" ") else empty end + end' "$fc" 2>/dev/null || true +} + +# Seconds of the span [now + from, now + to] that fall in AWAKE local hours — +# the ones whose learned multiplier clears REST_MULT_MAX. Nothing (not zero) +# while the shape is unlearned: a caller must be able to tell "you sleep +# through all of it" from "I have no idea when you sleep". `now` is +# injectable for the same reason the walk's is. +awake_secs() { + local mults="$1" from="${2:-0}" to="${3:-0}" now="${4:-}" + [ -n "$mults" ] || return 0 + local tzoff_s + [ -n "$now" ] || 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) }') + awk -v now="$now" -v tz="$tzoff_s" -v from="$from" -v to="$to" \ + -v rest="$REST_MULT_MAX" -v mults="$mults" 'BEGIN{ + if (split(mults, m, " ") != 24) { print 0; exit } + t = now + from; hi = now + to; awake = 0 + while (t < hi) { + lt = t + tz + seg = t + 3600 - (lt % 3600) + if (seg > hi) seg = hi + if (m[int((lt % 86400) / 3600) + 1] + 0 >= rest) awake += seg - t + t = seg + } + printf "%d\n", awake + }' +} + # --------------------------------------------------------------------------- # The waste ledger: `statusline.sh report [--days N]`. Mines usage.jsonl for # closed windows and says, in percent and in windows, what expired unused. @@ -2628,7 +2755,38 @@ run_usage_report() { 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 }' + 'BEGIN{ if (a > 0 || b > 0) printf "spent: $%.2f in 24h · $%.2f in 7d\n", a, b }' + + # The rhythm the walk below is now shaped by. Stated because it changes + # every projection in this report and is the one learned fact the reader + # can check against their own week. + local hour_mults + hour_mults=$(hour_profile_mults) + if [ -n "$hour_mults" ]; then + awk -v m="$hour_mults" -v rest="$REST_MULT_MAX" 'BEGIN{ + split(m, mult, " ") + awake = 0 + for (i = 0; i < 24; i++) if (mult[i + 1] + 0 >= rest) awake++ + # Longest CIRCULAR run of rest hours: sleep wraps 23 -> 0, and a + # run cut at midnight reads as two short ones. Two passes over + # the ring find it; the mean is 1, so some hour is always awake + # and the run can never swallow the ring. + best = 0; bs = -1; run = 0; start = 0 + for (i = 0; i < 48; i++) { + h = i % 24 + if (mult[h + 1] + 0 < rest) { + if (run == 0) start = h + run++ + if (run > best) { best = run; bs = start } + } else run = 0 + } + if (best >= 3) + printf "rhythm: rest ~%02d:00-%02d:00 · %dh awake/day (learned)\n", \ + bs, (bs + best) % 24, awake + else + printf "rhythm: no rest learned — burns around the clock\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. @@ -3760,11 +3918,15 @@ 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 · lands ~52% +# budget ~19✕5h left · ~13 awake · even 1.6%/win · lands ~52% # always-mode calm line (shared # budget frame with claude.py's -# watch advisor); in the last -# window: budget last window · +# watch advisor); the awake clause +# appears only once the hour shape +# is learned and only when it cuts +# the count, and it names the +# denominator `even` divides by; in +# the last window: budget last window · # N% left · lands ~M% # --------------------------------------------------------------------------- # The notice engine. Readers below turn the account's live numbers into @@ -4192,15 +4354,28 @@ notice_collect() { when=$(format_reset_absolute "$seven_reset") ppw=$(forecast_pct_per_window) if [ -n "$ppw" ]; then - reachable=$(awk -v ppw="$ppw" -v ss="$seven_secs" -v fs="${five_secs:-0}" -v fu="$five_int" 'BEGIN{ + # Two legs: the window you are in (time-limited by its own reset, + # rate-limited by its own headroom) and everything after it. The + # TIME terms are awake seconds once the rhythm is learned — + # "spend it" at 23:00 was advice to burn a week of surplus + # through eight hours of sleep. Unlearned, the legs are wall + # seconds and the arithmetic is exactly what it was. + local leg_secs="$seven_secs" rest_secs=0 hour_mults + if [ "${five_secs:-0}" -gt 0 ] 2>/dev/null \ + && [ "$five_secs" -lt "$seven_secs" ] 2>/dev/null; then + leg_secs="$five_secs"; rest_secs=$(( seven_secs - five_secs )) + fi + hour_mults=$(hour_profile_mults) + if [ -n "$hour_mults" ]; then + local awake_leg awake_rest=0 + awake_leg=$(awake_secs "$hour_mults" 0 "$leg_secs") + [ "$rest_secs" -gt 0 ] && awake_rest=$(awake_secs "$hour_mults" "$leg_secs" "$seven_secs") + leg_secs="$awake_leg"; rest_secs="$awake_rest" + fi + reachable=$(awk -v ppw="$ppw" -v leg="$leg_secs" -v rest="$rest_secs" -v fu="$five_int" 'BEGIN{ cap = ppw * (100 - fu) / 100 # current window headroom - if (fs > 0 && fs < ss) { - leg = ppw * fs / 18000 # time-limited until 5h reset - r = (leg < cap ? leg : cap) + ppw * (ss - fs) / 18000 - } else { - leg = ppw * ss / 18000 # 5h window outlasts 7d reset - r = (leg < cap ? leg : cap) - } + l = ppw * leg / 18000 # time-limited until 5h reset + r = (l < cap ? l : cap) + ppw * rest / 18000 printf "%d", int(r + 0.5) }') fi @@ -4325,18 +4500,47 @@ notice_collect() { "last window · ${surplus}% left" \ "budget last window · ${surplus}% left${lands_part}" else - local even - even=$(awk -v h="$surplus" -v w="$windows" 'BEGIN{printf "%.1f", h/w}') + # How many of those windows you are actually awake for. A window + # you sleep through is not capacity you can spend, and a ration + # that divides by it is a number nobody can hit. Clamped to the + # calendar count — the two describe the same future and must + # never disagree about it — and spoken only when it REFINES it: + # equal counts would spend a clause to say nothing. + local awake_n="" awake_part="" den="$windows" hour_mults asecs + hour_mults=$(hour_profile_mults) + asecs=$(awake_secs "$hour_mults" "${five_secs:-0}" "$seven_secs") + if [ -n "$asecs" ]; then + awake_n=$(( (asecs + 17999) / 18000 )) + [ "$awake_n" -gt "$windows" ] && awake_n="$windows" + if [ "$awake_n" -lt "$windows" ]; then + den="$awake_n" + # At zero the awake clause and the ration both go: there + # is no denominator, and "~0 awake" is a sentence about + # the next eight hours pretending to be one about the + # week. The landing still speaks. + [ "$awake_n" -gt 0 ] && awake_part=" · ~${awake_n} awake" + else + awake_n="" + fi + fi + local even="" even_part="" + if [ "$den" -gt 0 ] 2>/dev/null; then + even=$(awk -v h="$surplus" -v w="$den" 'BEGIN{printf "%.1f", h/w}') + even_part=" · even ${even}%/win" + fi # 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 + if [ -n "$lands" ]; then short_tail="lands ~${lands}%" + elif [ -n "$even" ]; then short_tail="${even}%/win"; fi + local short_line="${windows}${MULT_GLYPH}5h left" + [ -n "$short_tail" ] && short_line="${short_line} · ${short_tail}" notice_add 10 '-' acct "acct.budget.${windows}" "${windows}${MULT_GLYPH}5h" \ - "${windows}${MULT_GLYPH}5h left · ${short_tail}" \ - "budget ~${windows}${MULT_GLYPH}5h left · even ${even}%/win${lands_part}" + "$short_line" \ + "budget ~${windows}${MULT_GLYPH}5h left${awake_part}${even_part}${lands_part}" fi fi diff --git a/t/helpers.bash b/t/helpers.bash index c01400e..39b2ca2 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -63,6 +63,7 @@ CACHE_GLYPH="≡" MULT_GLYPH="✕" LEDGER_BASE_GLYPH="▁" FORECAST_SCHEMA=2 +REST_MULT_MAX=0.25 ADVISOR_FLEET_FRESH_SECS=3600 ADVISOR_FLEET_FREE_PCT=40 ADVISOR_EXPIRY_HORIZON_SECS=86400 @@ -98,7 +99,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|usage_corpus_files|cat_usage_corpus|usage_corpus_sig)\(\)/ { 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|hour_profile_mults|awake_secs|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 de5ec39..2184e98 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -851,6 +851,203 @@ EOF rm -rf "$tmpdir" } +# --- the hour shape (rest model) ------------------------------------------- + +# A valid hour_profile: 24 keys, mean 1, the named local hours at the 0.1 +# floor and the rest sharing what is left. TZ is pinned by every test that +# uses these, so "hour" means one thing on any host. +_hour_profile_at() { # comma-separated rest hours + awk -v rest="$1" 'BEGIN{ + n = split(rest, r, ","); rv = 0.1; av = (24 - n * rv) / (24 - n) + printf "{" + for (i = 0; i < 24; i++) { + is = 0 + for (j = 1; j <= n; j++) if (r[j] + 0 == i) is = 1 + printf "%s\"%d\":%.4f", (i ? "," : ""), i, (is ? rv : av) + } + printf "}" + }' +} + +# The same, placed relative to the CURRENT local hour: how a test puts the +# span it asks about inside or outside sleep without owning the clock. +_hour_profile_from_now() { # offset count + local off="$1" n="$2" h0 list="" i + h0=$(date +%H); h0=$((10#$h0)) + for (( i = 0; i < n; i++ )); do list="$list,$(( (h0 + off + i + 48) % 24 ))"; done + _hour_profile_at "${list#,}" +} + +_add_hour_profile() { # cache-file profile-json + jq -c --argjson h "$2" '. + {hour_profile: $h}' "$1" > "$1.t" && mv "$1.t" "$1" +} + +@test "build_seven_day_profile: learns the hour shape, floored and mean 1" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + # The fixture burns twice a day, at +4h and +8h from the hour it was + # written in, 12 points then 8. Two hot hours, twenty-two at rest. + _write_forecast_fixture "$tmpdir" 21 + build_seven_day_profile + [ "$(jq -r '.hour_profile | length' "$tmpdir/forecast.cache")" = "24" ] + mean=$(jq -r '[.hour_profile[]] | add / 24' "$tmpdir/forecast.cache") + awk -v m="$mean" 'BEGIN{exit !(m >= 0.99 && m <= 1.01)}' + # The floor is the hedge for the occasional overnight autonomous run: a + # rest hour projects a tenth of a uniform hour, never zero. + [ "$(jq -r '[.hour_profile[] | select(. < 0.05)] | length' "$tmpdir/forecast.cache")" = "0" ] + [ "$(jq -r '[.hour_profile[] | select(. > 1)] | length' "$tmpdir/forecast.cache")" = "2" ] + # 60% of the burn in one hour is 14.4 uniform hours before the floor + # takes its cut and the shape is scaled back to mean 1. + top=$(jq -r '[.hour_profile[]] | max' "$tmpdir/forecast.cache") + awk -v p="$top" 'BEGIN{exit !(p >= 12 && p <= 14)}' + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: today never trains the hour shape" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + _write_forecast_fixture "$tmpdir" 21 + # 30 points burned an hour ago, in an hour the history has never used. A + # day that has only reached noon reports every evening hour as rest, so + # today is evidence about burn and never about rhythm. + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":60}}\n' \ + "$(( $(date +%s) - 3600 ))" >> "$tmpdir/usage.jsonl" + build_seven_day_profile + [ "$(jq -r '[.hour_profile[] | select(. > 1)] | length' "$tmpdir/forecast.cache")" = "2" ] + top=$(jq -r '[.hour_profile[]] | max' "$tmpdir/forecast.cache") + awk -v p="$top" 'BEGIN{exit !(p >= 12 && p <= 14)}' + # ...and the weekday side counted it, untouched by any of this + awk -v r="$(jq -r '.recent_24h' "$tmpdir/forecast.cache")" 'BEGIN{exit !(r >= 30)}' + rm -rf "$tmpdir" +} + +@test "build_seven_day_profile: a first day of burn publishes no hour shape" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + echo '{"account":{"uuid":"acct-A"}}' > "$tmpdir/profile.cache" + now=$(date +%s) + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":10}}\n' \ + "$(( now - 7200 ))" > "$tmpdir/usage.jsonl" + printf '{"timestamp":%s,"user":{"uuid":"acct-A"},"seven_day":{"utilization":40}}\n' \ + "$(( now - 3600 ))" >> "$tmpdir/usage.jsonl" + build_seven_day_profile + # The burn is real and counted; with today excluded there is nothing left + # to shape it with, and an absent field is how a reader is told to walk + # flat. A shape guessed from one day is worse than none. + [ "$(jq -r '.recent_24h' "$tmpdir/forecast.cache")" = "30.00" ] + [ "$(jq -r '.hour_profile' "$tmpdir/forecast.cache")" = "null" ] + rm -rf "$tmpdir" +} + +@test "_profile_walk: an absent or broken hour shape walks exactly as before" { + tmpdir=$(mktemp -d) + export TZ=UTC + # Pinned clock: the shape makes the walk depend on the hour it runs in, + # so a regression test that let the clock move would compare two + # different questions. + now=$(date -u -d '2026-01-05 22:00:00' +%s) + base='{"computed_at":0,"days_history":20,"recent_24h":0,"recent_48h":0,"weekday_profile":{"0":20,"1":20,"2":20,"3":20,"4":20,"5":20,"6":20}}' + printf '%s' "$base" > "$tmpdir/forecast.cache" + want=$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 50 432000 "$now") + # the release's own numbers, so the baseline cannot drift with the fixture + read -r gap end <<<"$want" + [ "$gap" -ge 59 ] && [ "$gap" -le 60 ] + [ "$end" = "100" ] + # mean 3; one key of 24; a value of 30; a value that is not a number; a + # shape that is not an object at all. Every one of them walks flat, and + # none of them takes the walk's voice away — only the weekday guards do + # that. + while IFS= read -r bad; do + printf '%s' "$base" | jq -c --argjson h "$bad" '. + {hour_profile: $h}' > "$tmpdir/forecast.cache" + [ "$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 50 432000 "$now")" = "$want" ] + done < "$tmpdir/forecast.cache" + read -r gap _ <<<"$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 95 259200 "$now")" + # Flat, it dries at 03:00 — a false alarm at 23:00 and a missed warning + # at 09:00, which is the whole complaint against a flat night. + [ "$(date -u -d "@$(( now + 259200 - gap * 3600 ))" '+%H')" -lt 8 ] + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 0,1,2,3,4,5,6,7)" + read -r gap _ <<<"$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 95 259200 "$now")" + # Shaped, the same 5 points take until after wake: the night burns a + # tenth of an hour each, so the crossing lands in the morning. + [ "$(date -u -d "@$(( now + 259200 - gap * 3600 ))" '+%H')" -ge 8 ] + # ...and over a whole number of days the shape is neutral by + # construction: every hour class is covered exactly as often, so the + # landing is the weekday total and nothing else. + printf '%s' "$base" | jq -c '.weekday_profile |= map_values(10)' > "$tmpdir/forecast.cache" + flat=$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 40 259200 "$now") + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 0,1,2,3,4,5,6,7)" + [ "$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 40 259200 "$now")" = "$flat" ] + [ "$flat" = "-1 70" ] + rm -rf "$tmpdir" +} + +@test "_profile_walk: the L1 blend ends at 24h, not at the end of that day" { + tmpdir=$(mktemp -d) + export TZ=UTC + now=$(date -u -d '2026-01-05 22:00:00' +%s) + # Calm 10%/day profile, a hot trailing day at 40%, 10% used, 3 days left. + # 24h of blend (40) plus 48h of profile (20) lands on 70. Day steps used + # to blend the whole day holding t+24h — 26 hours of hot rate the day + # never earned, and 73 on this fixture. The horizon is 24h in both tools + # that read this cache. + printf '{"computed_at":0,"days_history":20,"recent_24h":40,"recent_48h":0,"weekday_profile":{"0":10,"1":10,"2":10,"3":10,"4":10,"5":10,"6":10}}' > "$tmpdir/forecast.cache" + [ "$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 10 259200 "$now")" = "-1 70" ] + # The shape is still neutral over whole days with the blend binding: it + # redistributes the hot day, it does not add to it. + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 0,1,2,3,4,5,6,7)" + [ "$(CLAUDE_ACCOUNT_DIR="$tmpdir" _seven_day_walk 10 259200 "$now")" = "-1 70" ] + rm -rf "$tmpdir" +} + +@test "awake_secs: counts only the hours the account is awake for" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + now=$(date -u -d '2026-01-05 22:00:00' +%s) + printf '{"days_history":20}' > "$tmpdir/forecast.cache" + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 0,1,2,3,4,5,6,7)" + m=$(hour_profile_mults) + # a whole day holds 16 awake hours whatever hour it starts in + [ "$(awake_secs "$m" 0 86400 "$now")" = "57600" ] + # 22:00 to midnight is all awake; midnight to 08:00 is all rest + [ "$(awake_secs "$m" 0 7200 "$now")" = "7200" ] + [ "$(awake_secs "$m" 7200 36000 "$now")" = "0" ] + # partial hours count as the part they are + [ "$(awake_secs "$m" 0 1800 "$now")" = "1800" ] + [ "$(awake_secs "$m" 5400 9000 "$now")" = "1800" ] + # no live 5h window: the span is the whole remainder, from now + [ "$(awake_secs "$m" 0 36000 "$now")" = "7200" ] + # a 5h window outlasting the 7d reset leaves no span at all + [ "$(awake_secs "$m" 36000 7200 "$now")" = "0" ] + # unlearned is not zero: a caller must tell "you sleep through all of it" + # from "I do not know when you sleep" + [ -z "$(awake_secs "" 0 86400 "$now")" ] + printf '{"days_history":13}' > "$tmpdir/forecast.cache" + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 0,1,2,3,4,5,6,7)" + [ -z "$(hour_profile_mults)" ] + rm -rf "$tmpdir" +} + @test "build_seven_day_profile: hourly gate skips rebuild" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" @@ -1077,6 +1274,38 @@ _write_ledger_fixture() { # dir rm -rf "$tmpdir" } +@test "run_usage_report: states the rhythm the walk is shaped by" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + _write_ledger_fixture "$tmpdir" + base='{"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}}' + # Unlearned: the report claims no rhythm it has not measured. + printf '%s' "$base" > "$tmpdir/forecast.cache" + run run_usage_report 28 + [[ "$output" != *rhythm* ]] + printf '%s' "$base" > "$tmpdir/forecast.cache" + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 0,1,2,3,4,5,6,7)" + run run_usage_report 28 + [[ "$output" == *"rhythm: rest ~00:00-08:00 · 16h awake/day (learned)"* ]] + # Sleep wraps midnight; a run cut there would read as two short ones. + printf '%s' "$base" > "$tmpdir/forecast.cache" + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 22,23,0,1,2,3,4,5)" + run run_usage_report 28 + [[ "$output" == *"rhythm: rest ~22:00-06:00 · 16h awake/day (learned)"* ]] + # An account that never stops, and one whose quiet hours are scattered + # rather than slept: neither has a rest block to name. + printf '%s' "$base" > "$tmpdir/forecast.cache" + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at "")" + run run_usage_report 28 + [[ "$output" == *"rhythm: no rest learned — burns around the clock"* ]] + printf '%s' "$base" > "$tmpdir/forecast.cache" + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_at 3,9,15)" + run run_usage_report 28 + [[ "$output" == *"rhythm: no rest learned — burns around the clock"* ]] + rm -rf "$tmpdir" +} + @test "run_usage_report: --days cutoff hides older closes; no history exits 1" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" @@ -3761,6 +3990,57 @@ JSON rm -rf "$tmpdir" } +@test "build_advisor_line: the ration divides by the windows you are awake for" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + reset_5h=$(date -u -d '+45 minutes' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+4 days 22 hours' '+%Y-%m-%dT%H:%M:%SZ') + usage="{\"five_hour\":{\"utilization\":20,\"resets_at\":\"$reset_5h\"},\"seven_day\":{\"utilization\":21,\"resets_at\":\"$reset_7d\"}}" + printf '{"computed_at":0,"days_history":20,"recent_24h":0,"recent_48h":0,"weekday_profile":{"0":-1,"1":-1,"2":-1,"3":-1,"4":-1,"5":-1,"6":-1}}' > "$tmpdir/forecast.cache" + # Unlearned, nothing moves: the ration is surplus over every window. + long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") + [[ "$long" =~ ^budget\ ~24✕5h\ left\ ·\ even\ 3\.3%/win\ ·\ lands\ ~[0-9]+%$ ]] + # Eight hours of sleep a night, starting two hours from now, so the rest + # band sits inside the span wherever the clock happens to be. + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_from_now 2 8)" + long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") + [[ "$long" =~ ^budget\ ~24✕5h\ left\ ·\ ~([0-9]+)\ awake\ ·\ even\ ([0-9.]+)%/win\ ·\ lands\ ~[0-9]+%$ ]] + awake="${BASH_REMATCH[1]}" even="${BASH_REMATCH[2]}" + # The clause names the denominator beside it, which is what makes the + # line self-describing: a third of the week is asleep, so the honest + # ration per window you will actually spend is higher. + [ "$awake" -lt 24 ] && [ "$awake" -gt 0 ] + [ "$even" = "$(awk -v h=79 -v w="$awake" 'BEGIN{printf "%.1f", h/w}')" ] + # Row 2 is unchanged — the landing is still the one clause it carries. + plain=$(strip_ansi "$(build_advisor_line "$usage" always)") + [[ "$plain" =~ ^24✕5h\ left\ ·\ lands\ ~[0-9]+%$ ]] + rm -rf "$tmpdir" +} + +@test "build_advisor_line: a window you sleep through takes the ration with it" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + # The week ends in 3 hours and every one of them is a rest hour: one + # window ahead on the calendar, none you will be at the keyboard for. + reset_5h=$(date -u -d '+45 minutes' '+%Y-%m-%dT%H:%M:%SZ') + 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\"}}" + printf '{"computed_at":0,"days_history":20,"recent_24h":0,"recent_48h":0,"weekday_profile":{"0":-1,"1":-1,"2":-1,"3":-1,"4":-1,"5":-1,"6":-1}}' > "$tmpdir/forecast.cache" + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_from_now -1 6)" + long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") + # No `~0 awake` and no ration: zero is not a denominator, and a clause + # about the next three hours would be pretending to be one about the + # week. Where the week lands still speaks. + [[ "$long" =~ ^budget\ ~1✕5h\ left\ ·\ lands\ ~[0-9]+%$ ]] + [[ "$long" != *awake* ]] + [[ "$long" != *"/win"* ]] + plain=$(strip_ansi "$(build_advisor_line "$usage" always)") + [[ "$plain" =~ ^1✕5h\ left\ ·\ lands\ ~[0-9]+%$ ]] + rm -rf "$tmpdir" +} + @test "build_advisor_line: hot 5h pace projects the cap wall-clock" { # 85% only 2h into the window (3h left): pace ~2.1x, caps well before reset reset_5h=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ') @@ -4066,6 +4346,27 @@ _write_ppw_fixture() { # dir ppw rm -rf "$tmpdir" } +@test "build_advisor_line: feasibility counts awake hours, not wall hours" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + export TZ=UTC + # ppw=30, a fresh 5h window and 20h of week left: on the clock alone the + # 35% surplus is reachable and the line says spend it. + _write_ppw_fixture "$tmpdir" 30 + reset_5h=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+20 hours' '+%Y-%m-%dT%H:%M:%SZ') + usage="{\"five_hour\":{\"utilization\":10,\"resets_at\":\"$reset_5h\"},\"seven_day\":{\"utilization\":65,\"resets_at\":\"$reset_7d\"}}" + plain=$(strip_ansi "$(build_advisor_line "$usage" auto)") + [[ "$plain" =~ ^\+\ 35%\ unused\ ·\ spend\ it$ ]] + # Learn that 22 of the next 24 hours are rest and the same clock buys + # nothing: "spend it" at 23:00 was advice to burn a third of a week + # through eight hours of sleep. + _add_hour_profile "$tmpdir/forecast.cache" "$(_hour_profile_from_now -1 22)" + plain=$(strip_ansi "$(build_advisor_line "$usage" auto)") + [[ "$plain" =~ ^\+\ 35%\ unused\ ·\ ~35%\ expires\ even\ at\ full\ burn$ ]] + rm -rf "$tmpdir" +} + @test "notice_collect: hot 5h and expiring surplus are two records, not one crammed row" { # The utilization-gap case: 5h nearly spent, 7d resets an hour later with # half the week unused. Two scopes, two stories: the wall pins row 2