diff --git a/CHANGELOG.md b/CHANGELOG.md index e61b734..68bccdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ ## Unreleased +## v0.32.0 — 2026-08-23 — say it once, and only when you know it + +**Row 3 was restating row 2 at greater length.** The flash reader +carried the pinned notice's key and never compared against it, so at +session start the same sentence arrived twice, once short and once +long: `- 12✕5h left · 5.8%/win` over `- budget ~12✕5h left · even +5.8%/win · heading ~85%`. The function's own comment already promised +it "never echoes the pin's own sentence" — now it skips record one and +starts at record two, and row 3 shows the highest-ranked notice the pin +is *not* already carrying. A calm start is two rows, the way it was +supposed to be. + +**A 7d projection now needs a day of this window's own evidence.** +Minutes after a weekly rollover the learned walk was projecting last +week's Tuesday onto a pool 2% spent — measured: a 2.4h-old window with +2% used came back `red, dry in 95h`, painted `×` across slot 14 +onward, and put `heading ~100%` on the budget line. Every input for +that verdict predates the reset, including the trailing-24h blend the +walk opens with, which describes a day lying on the far side of it. +`SEVEN_DAY_YOUNG_SECS` (86400) silences the learned walk, the linear +at-risk pace, and the dry-cell fallback until the window is a day old +— one gate, so the forecast notices, the `×` cells, the heading, the +accuracy logger and both subcommands go quiet together. Badges still +report real percentages, and a young window that is genuinely spent +still goes red on its own 85%+: the guard mutes pace, never facts. The +5h window is untouched. + +**Pace waits for a fraction of its window, not a fixed 15 minutes.** +One gate served both strips: 900 seconds, which is 5% of a 5h window +and 0.1% of a week. An hour into a fresh week, 2% of the pool over +0.6% of the time rendered `3.0✕` in red. The gate is now length/20 — +5h still waits exactly 15 minutes, 7d waits ~8.4h, the same fraction +`seven_day_elapsed` has always called the noise floor. + +**The fold token counts windows, not cells: `...▯(✕12)`.** The old +`...▯5h✕10` counted hollow cells the fold hid, while the budget line +beside it priced `12✕5h left` — two arithmetics on one row, and the +reader was left to arbitrate. The 34-cell grid spans 170h against a +168h period, which is where the two differed. The token is now +parenthesized, drops the redundant `5h` unit, and counts the 5h +windows remaining to the true reset: the same number the budget line +prices, from the same instant. Dry tails still read `...×(✕12)` — `×` +the cell, `✕` the operator, one column apart and never the same mark. + +**The budget voice drops its sigil.** `!` and `+` mark a notice that +interrupts — you are about to hit something, or there is capacity to +take. The week's resting reading interrupts nothing, and a leading `-` +on a dim line reads as a bullet, which made rows 2 and 3 look like a +two-item list. The pin now reads `12✕5h left · 5.8%/win`. Dim is the +mark; the sentence carries itself. `--check` and `--week` still label +their long form with the word `budget`. + ## v0.31.0 — 2026-08-20 — the operator is not a reading **The ledger row was printing two different X's and calling them both diff --git a/DESIGN.md b/DESIGN.md index 6212ed8..bd3af9f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -82,17 +82,19 @@ own signal. ## The ledgers (row 2) ``` -5h ▃▄▮▯▯ 0.6x @04:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▮▯▯...▯5hx14 0.7x @Wed 09:00 +5h ▃▄▮▯▯ 0.6x @04:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▮▯▯...▯(x14) 0.7x @Wed 09:00 ``` One grammar, two scales. `5h` = this window as 5 hours; `7d` = the period as 34 five-hour slots, oldest left, a gap at each local midnight *in history only* — the run from `▮` on is contiguous. History draws in full; the live row folds the 7d future after two kept cells into -`...▯5hx14` — the slots left to the reset, all alike, `×` red when the -tail projects dry (the `week` report still draws every slot). Each strip ends -with its pace (used ÷ elapsed; dim <1x, pressure ≥1x, hidden under 15 min) -and the reset its right edge is — axis labels, not restated badges. +`...▯(x14)` — the 5h windows left before the reset, all alike, `×` red +when the tail projects dry (the `week` report still draws every slot). The +count is the budget line's own, priced from the same instant. Each strip ends +with its pace (used ÷ elapsed; dim <1x, pressure ≥1x, hidden until 5% of the +window has run) and the reset its right edge is — axis labels, not restated +badges. ``` ▁▂▃▄▅▆▇█ burned; height = points that cell cost (▁ ≤2 … ▅ ≤11 … █ >20) @@ -100,7 +102,7 @@ and the reset its right edge is — axis labels, not restated badges. ░ unknown — no sample; never drawn as idle ▮ now ▯ ahead — the hollow of ▮ -...▯5hx14 the folded future: 14 more 5h slots to the reset (live 7d row) +...▯(x14) the folded future: 14 more 5h windows before the reset (live 7d row) × pace won't cover it (7d: learned forecast, linear when cold; 5h: linear) ``` diff --git a/README.md b/README.md index 0514085..8b47237 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Every component earns its place: | User tier | Neutral white-weight (MAX bold, PRO normal, dim otherwise) — identity, never a status color. Truncated display name. | | Quota | Integer percentages. The 5h badge always carries its reset time while a window is live — `5h[42%@14:30]` reads "42% used, resets at 14:30" — because on a 5h horizon the reset is the number you plan the current sitting around. Wall-clock, not a countdown, on purpose: Claude Code only re-renders the statusline on activity, so a relative "@1h38m" silently decays into a lie during idle gaps, while "@14:30" stays true in a frozen frame. (The 7d badge is hybrid: day-relative `@5d` while the reset is >= 24h out — decays one day per day, mild and narrow — switching to the same wall-clock `@04:00` inside the last day, where an `@6h`/`@<1h` countdown decayed by the hour exactly when pressure keeps the suffix visible.) When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@14:32]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows when relief arrives: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now"; inside the last day it reads `7d[92%@04:00]` — resets at 04:00. Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. **Model-scoped weekly quota**: when the usage API carries a per-model weekly limit (`limits[]`, `kind=weekly_scoped`) for the model your session is running, it renders right after the model+context block — `fabl5[1m][12%] fb[67%]` on a Fable 5 session, `op[33%]` on Opus — because the quota is a property of the model you're running, not of the account-wide 5h/7d cluster. It's a weekly number (same reset as the 7d badge), scoped to one model. Other models' scoped quotas stay hidden: only the limit constraining *this* session is signal. Supersedes the legacy `seven_day_opus`/`seven_day_sonnet` fields, which the API now sends as null. | | Extra usage | Monthly spend, limit, prepaid balance. `--extra auto` shows when quota runs out. | -| Week row | **A row of its own, under the badges**: `5h ▅█▃▮▯ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ …▮▯▯...▯5h✕19 0.7✕ @Wed 09:00` — this sitting by the hour, the week as its 5h windows (day-gapped, the far future folded to a counted `...▯5h✕19`), height = what each cell burned, `▮` now, `×` where the pool runs dry, each strip ending with its pace and reset. Reconstructed from your own usage log; `auto` shows it once there is history to show. See [Week row](#week-row). | +| Week row | **A row of its own, under the badges**: `5h ▅█▃▮▯ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ …▮▯▯...▯(✕19) 0.7✕ @Wed 09:00` — this sitting by the hour, the week as its 5h windows (day-gapped, the far future folded to a counted `...▯(✕19)`), height = what each cell burned, `▮` now, `×` where the pool runs dry, each strip ending with its pace and reset. Reconstructed from your own usage log; `auto` shows it once there is history to show. See [Week row](#week-row). | | Deadman | **Invisible until a switch is armed.** Surfaces [deadman](https://github.com/thevibeworks/deadman) — a dead man's switch that hands the session off when you stop responding. `[☠ armed 42m]` (dim) counts down to the auto-handoff; `[☠ warned 3m]` (yellow) means the phone warning went out; `[☠ due]` means the handoff fires imminently. Sits on the left lane next to the path — it describes this session's lifecycle, not a quota. One `command -v` when the tool is absent, one fast file read when present; nothing armed renders nothing. `--deadman off` disables it. | | Cache health | **Quiet until it bites.** Claude Code never re-renders an idle session, and while you work the prompt cache is always freshly ~1 TTL from expiry — so a proactive "expiring soon" isn't honestly observable, and `auto` spends no width on it. It speaks only when a rewrite actually happens: `≡!419k` the instant you resume onto a dead cache (idle longer than the TTL) or a mid-session prefix collapse — a 419k-token re-cache at ~20x the read rate (and the same burn on your 5h/7d quota on subscriptions). **Bold red past 200k** — the premium-band miss. `≡~` while a large prefix rebuilds. `--cache always` additionally keeps the freeze-safe deadline `≡@15:20` (last request + TTL; a past time in a frozen frame reads "expired at 15:20"). TTL defaults to 1h (claude.ai subscriber sessions) or 5m (API-key auth); an observed usage breakdown overrides it. The `≡` glyph (U+2261) reads as stacked cache layers — one terminal column, quiet and distinct. | @@ -255,7 +255,7 @@ right edge shared with line 1: ```text proj (main*) +84/-14 8m $6.72 fabl5[1m][██░░42%] fb[66%] [MAX|@work] 5h[38%@23:00] 7d[39%] -- 3✕5h left · 20%/win 5h ▅█▃▮▯ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 +3✕5h left · 20%/win 5h ▅█▃▮▯ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 ``` The 5h strip prints no reset: line 1's `5h[38%@23:00]` already carries @@ -273,12 +273,12 @@ edge and the full sentence sits flush-left beneath them: ```text proj (main*) fabl5[1m][██░░42%] fb[66%] [MAX|@work] 5h[38%@23:00] 7d[39%] 5h ▅█▃▮▯ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 - - budget ~3✕5h left · even 20%/win · heading ~52% + budget ~3✕5h left · even 20%/win · heading ~52% ``` Each strip ends with its **pace** (used ÷ elapsed: `0.7✕` is on track, -`1.6✕` caps early — dim below 1✕, pressure-tinted from 1✕, hidden for -the first 15 min of a window) and the **reset** its right edge stands +`1.6✕` caps early — dim below 1✕, pressure-tinted from 1✕, hidden until +5% of the window has run: 15 min for 5h, ~8h for the week) and the **reset** its right edge stands for (`@23:00` inside 24h, `@Wed 09:00` beyond) — axis labels for a timeline, not badges restated. When the row shows, the advisor's calm budget line shows with it (windows left, what even looks like, where @@ -292,9 +292,11 @@ fire. left, the last a 3h stub), height = the 7d points that window burned, with a thin gap at each local midnight so days read as clusters — and a day that held five windows shows it — without a ruler. History draws in - full; the future folds: two hollow cells after `▮`, then `...▯5h✕28` — - 28 more 5h slots to the reset, all alike (`×` red when the tail projects - dry). The `week` subcommand's wide ledger still draws every slot. + full; the future folds: two hollow cells after `▮`, then `...▯(✕28)` — + 28 more 5h windows before the reset, all alike (`×` red when the tail + projects dry). That count is the budget line's own `~28✕5h left`, priced + from the same instant: one row, one arithmetic. The `week` subcommand's + wide ledger still draws every slot. | Cell | Meaning | |------|---------| @@ -304,8 +306,8 @@ fire. | `▮` | the cell you are in now | | `▯` | a cell still ahead of you — the hollow of `▮`, an empty slot waiting | | `×` | a cell the pool will not cover at the current pace (7d: the learned forecast's dry point, linear when untrained; 5h: linear, the same projection as the badge) | -| `...▯5h✕28` | the folded 7d future: 28 more 5h slots to the reset, one token instead of 28 hollow cells (`×` red when the tail ends dry) | -| `✕` | not a cell — the multiplication sign, the row's one operator (`...▯5h✕28`, `0.7✕`, `- 19✕5h left`). Deliberately not `×` (U+00D7), which is already a reading: cells are the ink, the operator is punctuation, and `...×5h✕28` has to say both at once. One terminal column and no emoji fallback, so the row still meets line 1's edge; override with `MULT_GLYPH` (`╳` and `✖` look stronger but are ambiguous-width and emoji-presentation respectively) | +| `...▯(✕28)` | the folded 7d future: 28 more 5h windows before the reset, one token instead of a run of hollow cells (`×` red when the tail ends dry). The count is windows-to-reset — what the budget line prices — not the number of cells the fold happened to hide | +| `✕` | not a cell — the multiplication sign, the row's one operator (`...▯(✕28)`, `0.7✕`, `19✕5h left`). Deliberately not `×` (U+00D7), which is already a reading: cells are the ink, the operator is punctuation, and `...×(×28)` has to say both at once. One terminal column and no emoji fallback, so the row still meets line 1's edge; override with `MULT_GLYPH` (`╳` and `✖` look stronger but are ambiguous-width and emoji-presentation respectively) | Burn cells take their badge's pressure color; everything else is neutral, so the row never adds an alarm channel of its own. Both strips are @@ -365,7 +367,7 @@ clauses about one window can never disagree. | `+ 5h ~40m left · 70% unused` | Said only when the *week* is stranding capacity — an unspent 5h window is otherwise headroom, not waste, since the 5h window is a rate limit and not a budget. | | `+ ~62% will expire · go heavier` | On pace to strand a large chunk of the subscription. Speaks only to an engaged, unsqueezed session. | | `+ work 5h[8%] free` | A sibling account in the same shared home is idle while this one is pinned. | -| `- 19✕5h left · 1.1%/win` | The calm budget: runway, what even looks like, where you land (long form). | +| `19✕5h left · 1.1%/win` | The calm budget: runway, what even looks like, where you land (long form). 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 @@ -509,7 +511,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior. npm exec --yes bats -- t/ ``` -418 tests across `t/statusline.bats` (406 statusline + integration) and +423 tests across `t/statusline.bats` (411 statusline + integration) and `t/install.bats` (12 installer). CI runs on push and PR to `main`. ## Project Structure diff --git a/docs/index.html b/docs/index.html index e2ce771..96dd473 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ Claude Code Statusline — a meter for your Claude Code plan - + @@ -204,7 +204,7 @@

claude-code-statusline — a meter for your Claude Code plan

-
418 tests · jq + curl · no daemon · no telemetry
+
423 tests · jq + curl · no daemon · no telemetry
@@ -251,8 +251,8 @@

Read the meter

The 5h ledger — this sitting by the hour. Height is what each hour cost; now, ahead, ˍ ran-but-negligible, no sample. Ends with its pace: above 1✕ you cap before the reset.
-
7d ▃▅ˍ▃▅ ▃▃▁▂▁▯▯...▯5h✕14 0.7✕№ 0001
-
The week ledger — history in full, the future folded. Each cell one 5h window, day-gapped; after the now-marker the empty run folds to a counted tail: ...▯5h✕14 answers "14 slots to the reset", and × red answers "the tail runs dry" — is the operator, × is a reading.
+
7d ▃▅ˍ▃▅ ▃▃▁▂▁▯▯...▯(✕14) 0.7✕№ 0001
+
The week ledger — history in full, the future folded. Each cell one 5h window, day-gapped; after the now-marker the empty run folds to a counted tail: ...▯(✕14) answers "14 windows before the reset" — the same 14 the budget line prices — and × red answers "the tail runs dry"; is the operator, × is a reading.
! 7d dry ~Tue 04:00 · hard stop№ 0008
@@ -277,7 +277,7 @@

Built like a meter should be

Numbers that don't lie

Wall-clock resets instead of decaying countdowns. A visible [░░░░░░0%] after /compact instead of a hidden bar. Stale data is badged, never passed off as fresh. Burn is a monotone envelope, never a re-earned dip.

Nothing running in the background

-

No daemon, no telemetry, no npm. It runs when Claude Code renders the prompt, reads stdin plus one shared cached API call, prints its rows, exits. 418 bats tests on the one file.

+

No daemon, no telemetry, no npm. It runs when Claude Code renders the prompt, reads stdin plus one shared cached API call, prints its rows, exits. 423 bats tests on the one file.

@@ -329,12 +329,12 @@

What it is not

// 5h ledger: 5 hour cells. 7d ledger: history in full, future folded. var L5 = s('d','5h ') + s('g','▂▅█') + s('w','▮') + s('d2','▯') + s('d',' 0.8✕'); var L5b = s('d','5h ') + s('g','▂▅█▄') + s('w','▮') + s('d',' 0.9✕'); - var L7 = s('d','7d ') + s('y','▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ') + s('w','▮') + s('d2','▯▯...▯5h✕14') + s('d',' 0.7✕ @Wed 09:00'); - var L7x = s('d','7d ') + s('y','▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ') + s('w','▮') + s('d2','▯▯') + s('r','...×5h✕14') + s('d',' 0.9✕ @Wed 09:00'); + var L7 = s('d','7d ') + s('y','▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ') + s('w','▮') + s('d2','▯▯...▯(✕14)') + s('d',' 0.7✕ @Wed 09:00'); + var L7x = s('d','7d ') + s('y','▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ') + s('w','▮') + s('d2','▯▯') + s('r','...×(✕14)') + s('d',' 0.9✕ @Wed 09:00'); // the 5h strip prints no reset: line 1's 5h badge already carries it var LEDGER = L5 + ' ' + L7, LEDGER_B = L5b + ' ' + L7, LEDGER_X = L5b + ' ' + L7x; - var BUDGET = s('d2','- 10✕5h left · 4.5%/win'); - var BUDGET_B = s('d2','- 10✕5h left · 4.3%/win'); + var BUDGET = s('d2','10✕5h left · 4.5%/win'); + var BUDGET_B = s('d2','10✕5h left · 4.3%/win'); var PIN = s('c','+ ') + s('c','fb 91%') + s('c',' vs 7d 55% · go op'); var FLASH = s('c','+ fb weekly 91% against 7d 55% · the model caps first, not the account'); var PIN_DRY = s('r','! ') + s('r','7d dry ~Tue 04:00') + s('r',' · hard stop'); diff --git a/llms.txt b/llms.txt index d808009..772341d 100644 --- a/llms.txt +++ b/llms.txt @@ -4,11 +4,11 @@ > per-model weekly limits), context pressure, session cost, prompt-cache > health, and git activity in Claude Code's statusLine hook — plus a > week row (this 5h window by the hour, the 7d period as its 5h -> windows with the future folded to a counted `...▯5h✕28` tail: where +> windows with the future folded to a counted `...▯(✕28)` tail: where > the points went), an advisor row that interprets the > numbers (cap projections, expiring-surplus and underuse advice, fleet > relief), and a `report` subcommand that ledgers what each closed window -> expired unused. 418 bats tests, deps are jq + curl, no daemon, no +> expired unused. 423 bats tests, deps are jq + curl, no daemon, no > telemetry. Key facts for agents: diff --git a/statusline.sh b/statusline.sh index 39ee338..efeea21 100755 --- a/statusline.sh +++ b/statusline.sh @@ -52,6 +52,12 @@ FIVE_HOUR_RECOVERY_SECS=1800 # recovery color when reset <= 30min SEVEN_DAY_RECOVERY_SECS=43200 # recovery color when reset <= 12h QUOTA_BUMP_NOTICE_SECS=60 # how long a quota "+N" bump flash stays up SEVEN_DAY_WINDOW_SECS=604800 # weekly quota window (fixed 7d) for pace math +SEVEN_DAY_YOUNG_SECS=86400 # a 7d projection needs a day of THIS window's + # own evidence. Learned or linear, every pace + # read before that is last week's story told + # about a window that has barely started — + # and the trailing-24h blend is measuring a + # day that lies outside the window entirely EXTRA_AUTO_UTIL_PCT=50 # show extra when its own utilization >= 50% CACHE_BREAK_MIN_TOKENS=2000 # ignore cache drops below this (noise) CACHE_BREAK_DROP_PCT=5 # cache read must drop >5% to count as break @@ -79,9 +85,9 @@ ADVISOR_UNDERUSE_MIN_5H=25 # underuse advice only in an engaged session (5 CACHE_GLYPH="${CACHE_GLYPH:-≡}" # Multiplication sign for the ledger rows' two factors: the folded-future -# count (`...▯5h✕28`) and the pace (`0.6✕`). NOT × (U+00D7) — that glyph is +# count (`...▯(✕12)`) and the pace (`0.6✕`). NOT × (U+00D7) — that glyph is # already a strip cell, the one the pool will not cover, so reusing it would -# print `...×5h×28` and make an operator look like a reading. U+2715 is the +# print `...×(×12)` and make an operator look like a reading. U+2715 is the # lighter mark of the pair by design: cells are the ink, the operator is # punctuation. eaw=N and not an emoji, so it stays one column and the padding # math holds — ╳ (U+2573) is ambiguous-width (double under a CJK locale) and @@ -1945,13 +1951,20 @@ weekend_secs_ahead() { # level: green|yellow|red runway_days: int, -1 when unknown hint: 0|1 # A 10% buffer keeps marginal overshoots quiet (warn only when you'll fall # >~10% short). Falls back to level thresholds when pace can't be judged -# (no deadline, or the noisy first ~8h). $2 drives elapsed; $3 the deadline. +# (no deadline, the noisy first ~8h, or a window younger than +# SEVEN_DAY_YOUNG_SECS — two hours into a fresh week, 2% used extrapolates to +# a 98h runway and calls the week at risk on nothing). A genuinely burned +# young window still goes red on its own percentage; only the pace math waits. +# $2 drives elapsed; $3 the deadline. # $1 used% $2 seconds_left $3 effective deadline secs (optional) seven_day_pace() { local used; used=$(printf '%.0f' "$1" 2>/dev/null || echo 0) local secs_left="${2:-}" deadline="${3:-}" [ -z "$deadline" ] && deadline="$secs_left" local elapsed; elapsed=$(seven_day_elapsed "$secs_left") + if [ -n "$elapsed" ] && [ "$elapsed" -lt "$SEVEN_DAY_YOUNG_SECS" ] 2>/dev/null; then + elapsed="" + fi if [ -z "$elapsed" ] || [ "$used" -le 0 ] 2>/dev/null; then if [ "$used" -ge 85 ] 2>/dev/null; then echo "red -1 0" elif [ "$used" -ge 70 ] 2>/dev/null; then echo "yellow -1 0" @@ -2255,6 +2268,14 @@ _profile_walk() { local fc="$CLAUDE_ACCOUNT_DIR/forecast.cache" [ -f "$fc" ] || return 0 [ -n "$secs_left" ] && [ "$secs_left" -gt 0 ] 2>/dev/null || return 0 + # A young window has no evidence of its own. The profile is learned from + # the windows BEFORE this one, so minutes after a rollover the walk + # projects last week's Tuesday onto a pool that is 2% spent and calls it + # dry — and the L1 recent-24h blend it opens with describes a trailing day + # that sits on the far side of the reset. Silence until the window can + # speak for itself; the same silence a cold start earns. + local since_reset=$(( SEVEN_DAY_WINDOW_SECS - secs_left )) + [ "$since_reset" -ge "$SEVEN_DAY_YOUNG_SECS" ] 2>/dev/null || return 0 local used_int used_int=$(printf '%.0f' "$used" 2>/dev/null || echo 0) [ "$used_int" -gt 0 ] 2>/dev/null || return 0 @@ -2633,10 +2654,11 @@ run_session_summary() { # ▮ the window you are in now # ▯ a window still ahead of you # × a window the pool will not cover at the current pace -# Count ▮ and what follows for the budget line's own "~N✕5h left". Past -# cells come from usage.jsonl; unknown and idle stay different glyphs -# because drawing a gap in the record as an idle session is the one lie -# this row must not tell. The prospective glance beside report's +# The report draws every slot, so ▮ and what follows IS the budget line's +# "~N✕5h left" laid out cell by cell; the live row folds that same tail and +# prints the count instead. Past cells come from usage.jsonl; unknown and +# idle stay different glyphs because drawing a gap in the record as an idle +# session is the one lie this row must not tell. The prospective glance beside report's # retrospective ledger; the same strip claude.py renders, so both # surfaces tell one story. Reads usage.cache; stale data renders but says so. # --- week row (the two windows as ledgers) ------------------------------------ @@ -2652,8 +2674,13 @@ run_session_summary() { # ▮ the cell you are in now # ▯ a cell still ahead of you (the hollow of ▮: an empty slot) # × a cell the pool will not cover at the current pace -# ...▯5h✕28 the folded future: 28 more 5h slots to the reset, all alike -# (× red when the tail projects dry) — live 7d strip only. +# ...▯(✕12) the folded future: 12 more 5h windows before the reset, all +# alike (× red when the tail projects dry) — live 7d strip only. +# The count is windows-to-reset, the same number the budget line +# prices as "~12✕5h left" — one line, one arithmetic. (It is NOT +# the hidden-cell count: the 34-cell grid spans 170h and the +# period is 168h, and a row that says 10 beside a budget that +# says 12 makes the reader arbitrate between its own halves.) # ✕ is the operator, × is a cell: the two never mean the same. # Unknown and idle are deliberately different glyphs: drawing a gap in the # record as an idle session is the one lie this row must not tell. @@ -2665,10 +2692,10 @@ FIVE_CELLS=5 FIVE_CELL_SECS=3600 WEEK_CACHE_TTL_SECS=300 # The live row compresses the 7d strip's future run: after the now-marker it -# keeps WEEK_FUTURE_KEEP hollow cells, then folds the rest into `...▯5h✕28` -# (count = slots to the reset; × red when the tail projects dry). History is -# information, the future is all the same cell — but only fold when it hides -# enough to matter, so a closing week still draws to its edge. +# keeps WEEK_FUTURE_KEEP hollow cells, then folds the rest into `...▯(✕12)` +# (count = 5h windows before the reset; × red when the tail projects dry). +# History is information, the future is all the same cell — but only fold +# when it hides enough to matter, so a closing week still draws to its edge. WEEK_FUTURE_KEEP=2 WEEK_FUTURE_MIN_HIDE=10 @@ -2777,17 +2804,19 @@ five_history_cells() { week_scan "$1" "$2" | sed -n 2p; } # The slot where burn exhausts the pool before reset (-1 = none). The learned # walk speaks first (gap in HOURS before reset); with no trained forecast fall # back to the linear projection claude.py's cap_eta uses, so a wall visible on -# one surface is visible on the other. +# one surface is visible on the other. Both wait out SEVEN_DAY_YOUNG_SECS: a +# row of × drawn across a window that opened an hour ago is drawn from last +# week's burn, and this row does not draw what it cannot see. week_dry_slot() { local seven_int="$1" seven_secs="$2" now="$3" period_start="$4" local dry_epoch="" walk_gap walk_end elapsed7 read -r walk_gap walk_end <<<"$(_seven_day_walk "$seven_int" "$seven_secs")" + elapsed7=$(( SEVEN_DAY_WINDOW_SECS - seven_secs )) if [ -n "$walk_gap" ] && [ "$walk_gap" != "-1" ] && [ "$walk_gap" -gt 0 ] 2>/dev/null; then dry_epoch=$(( now + seven_secs - walk_gap * 3600 )) - elif [ "$seven_int" -gt 0 ] 2>/dev/null; then - elapsed7=$(( SEVEN_DAY_WINDOW_SECS - seven_secs )) - [ "$elapsed7" -gt 0 ] && \ - dry_epoch=$(( now + elapsed7 * (100 - seven_int) / seven_int )) + elif [ "$seven_int" -gt 0 ] 2>/dev/null \ + && [ "$elapsed7" -ge "$SEVEN_DAY_YOUNG_SECS" ] 2>/dev/null; then + dry_epoch=$(( now + elapsed7 * (100 - seven_int) / seven_int )) fi if [ -n "$dry_epoch" ] && [ "$dry_epoch" -lt $(( now + seven_secs )) ] 2>/dev/null; then echo $(( (dry_epoch - period_start) / 18000 )) @@ -2799,10 +2828,14 @@ week_dry_slot() { # The colored strip. Args: fill percent (for the pressure tint), now, # period start, dry cell (-1 none), history line ("lo hi slot:cost,..."), # cell count, cell seconds, day-gaps flag, future cells kept before folding -# (0 = draw all), cell-unit label for the fold token. Cells and gaps come out -# of one awk so the row is one string with a color run per role. +# (0 = draw all), and the TRUE period length in seconds — the fold token +# counts windows to the real reset, not cells on the grid, so it needs the +# period, not the drawing. Defaults to the grid when a caller has nothing +# truer to offer. Cells and gaps come out of one awk so the row is one string +# with a color run per role. build_ledger_strip() { - local pct="$1" now="$2" ps="$3" dry="$4" hist="$5" cells_n="$6" cell_secs="$7" gaps="${8:-0}" fut="${9:-0}" unit="${10:-}" + local pct="$1" now="$2" ps="$3" dry="$4" hist="$5" cells_n="$6" cell_secs="$7" gaps="${8:-0}" fut="${9:-0}" + local plen="${10:-$(( cells_n * cell_secs ))}" local span_lo="" span_hi="" cells="" [ -n "$hist" ] && read -r span_lo span_hi cells <<<"$hist" local fill_color tzoff_s @@ -2810,7 +2843,7 @@ build_ledger_strip() { 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 w="$cells_n" -v cs="$cell_secs" -v ps="$ps" -v now="$now" -v dry="$dry" \ -v gaps="$gaps" -v tz="$tzoff_s" -v fut="$fut" \ - -v minhide="$WEEK_FUTURE_MIN_HIDE" -v unit="$unit" -v mult="$MULT_GLYPH" \ + -v minhide="$WEEK_FUTURE_MIN_HIDE" -v plen="$plen" -v mult="$MULT_GLYPH" \ -v lo="${span_lo:--1}" -v hi="${span_hi:--1}" -v cells="$cells" \ -v C_FILL="$fill_color" -v C_DIM="$DIM" -v C_NOW="$BOLD" \ -v C_DRY="$RED" -v C_OFF="$RESET" ' @@ -2856,10 +2889,15 @@ build_ledger_strip() { } if (lim < w) { # the fold: glyph = how the tail ends (× red when the pool - # dries before the reset), count = slots left to the reset + # dries before the reset), count = 5h windows still to come + # before the reset — the same number the budget line prices, + # not the number of cells this fold happens to hide. + # No apostrophes in here: this comment lives inside a + # single-quoted awk program. if (dry >= 0 && dry < w) { tg = "×"; tc = C_DRY } else { tg = "▯"; tc = C_DIM } - s = s C_OFF tc "..." tg unit mult (w - lim) + nleft = int((ps + plen - now + cs - 1) / cs) + s = s C_OFF tc "..." tg "(" mult nleft ")" } print s C_OFF }' @@ -2868,8 +2906,11 @@ build_ledger_strip() { # 7d strip: 34 ✕ 5h cells from the period start, day-gapped. $5 is # week_history_cells' line; $6 folds the future tail after that many kept # cells (the live row passes WEEK_FUTURE_KEEP; the `week` report draws all). +# The grid overshoots the period by design (34 ✕ 5h = 170h against a 168h +# week), so the fold is told the period itself — its count ends at the reset, +# where the week ends, not at the last cell it drew. build_week_strip() { - build_ledger_strip "$1" "$2" "$3" "$4" "$5" "$WEEK_CELLS" 18000 1 "${6:-0}" 5h + build_ledger_strip "$1" "$2" "$3" "$4" "$5" "$WEEK_CELLS" 18000 1 "${6:-0}" "$SEVEN_DAY_WINDOW_SECS" } # 5h strip: 5 ✕ 1h cells of the current window. $5 is @@ -2920,15 +2961,19 @@ five_period_start() { # Tail of a strip: pace and the axis label of its right end (the reset). # pace = used / elapsed-fraction; >1✕ means the pool caps before the reset. # Dim below 1✕, pressure-tinted from 1✕ (status lane), hidden while the -# window is too young to judge (ADVISOR_PACE_MIN_ELAPSED). Reset is wall +# window is too young to judge — and young is a FRACTION of the window, not a +# clock reading: 5% of its own length, so 5h still waits 15m and 7d waits +# ~8.4h (the same fraction seven_day_elapsed calls noisy). A flat 15m was +# right for 5h and nonsense for a week: an hour into a fresh window, 2% of +# the pool divided by 0.6% of the time printed 3✕ in red. Reset is wall # clock: `@04:00` inside 24h, `@Wed 09:00` beyond — an axis label for a # timeline that ends there. $5 = "hide" drops it: when the badge above # already carries that reset, printing it again spends columns to say # nothing (one badge per fact, in both directions). strip_tail() { local pct="$1" secs_left="$2" length="$3" now="$4" reset_mode="${5:-show}" - local out="" elapsed=$(( length - secs_left )) - if [ "$elapsed" -ge "$ADVISOR_PACE_MIN_ELAPSED" ] && [ "$pct" -gt 0 ] 2>/dev/null; then + local out="" elapsed=$(( length - secs_left )) min_elapsed=$(( length / 20 )) + if [ "$elapsed" -ge "$min_elapsed" ] && [ "$pct" -gt 0 ] 2>/dev/null; then local pace tint band pace=$(awk -v u="$pct" -v e="$elapsed" -v l="$length" 'BEGIN{ printf "%.1f", (u/100)/(e/l) }') band=$(awk -v p="$pace" 'BEGIN{ print (p>=1.5?2:(p>=1.0?1:0)) }') @@ -3476,6 +3521,8 @@ build_extra_usage_display() { # opportunity "+ ..." cyan paid capacity is about to expire unused, # or a sibling account is free while you're # pinned — spend, don't conserve +# budget no sigil, dim the week's resting reading; it interrupts +# nothing, so it wears no mark # Cyan is reserved for opportunity: it can never mean pressure, so the color # alone carries the stance. Quiet = no row at all (Claude Code renders each # stdout line as its own row; printing nothing costs nothing). All times are @@ -3561,7 +3608,7 @@ build_advisor_fleet_hint() { # rank voice scope key hl short long # # rank value order; the top surviving record owns row 2 -# voice !red / !yellow pressure · + opportunity · - budget (dim) +# voice !red / !yellow pressure · + opportunity · - budget (dim, no sigil) # scope 5h / 7d / fb / acct — one voice per scope per frame, so two # clauses about the same window can never disagree # key identity of the CONDITION, not of the text: row 3 shows a notice @@ -3601,11 +3648,16 @@ notice_highlight() { printf '%s%s%s%s%s%s' "${text%%"$tok"*}" "$BOLD" "$tok" "$NO_BOLD" "$color" "${text#*"$tok"}" } +# A sigil marks a voice that INTERRUPTS: `!` you are about to hit something, +# `+` there is capacity to take. The budget voice interrupts nothing — it is +# the week's resting reading — and a leading `-` on a dim line reads as a +# bullet, which turned the pin into the first item of a list that had no +# second item. Dim is the mark; the sentence carries itself. notice_render() { - local voice="$1" hl="$2" text="$3" color sigil + local voice="$1" hl="$2" text="$3" color sigil='' color=$(notice_voice_color "$voice") - case "$voice" in '!'*) sigil='!' ;; '+') sigil='+' ;; *) sigil='-' ;; esac - printf '%s%s %s%s' "$color" "$sigil" "$(notice_highlight "$text" "$hl" "$color")" "$RESET" + case "$voice" in '!'*) sigil='! ' ;; '+') sigil='+ ' ;; esac + printf '%s%s%s%s' "$color" "$sigil" "$(notice_highlight "$text" "$hl" "$color")" "$RESET" } # First time THIS session saw a condition (stamping it if new). Wall-clock @@ -3657,7 +3709,10 @@ notice_long_line() { # Row 3: the same engine's long form, but only while the condition is new to # this session. It says the part that does not fit beside the ledgers, then -# disappears and leaves the pin. Never echoes the pin's own sentence. +# disappears and leaves the pin. Never echoes the pin's own sentence: record +# one IS the pin, so the flash starts at record two. A calm start has one +# thing to say and says it once — two rows, not the same sentence twice at +# two lengths. notice_flash_line() { local records="$1" state="$2" now="${3:-$(date +%s)}" [ -n "$records" ] || return 0 @@ -3665,6 +3720,7 @@ notice_flash_line() { while IFS="$NOTICE_FS" read -r rank voice scope key hl short long; do [ -n "$key" ] || continue if [ "$first" = 1 ]; then pin_key="$key"; first=0; fi + [ "$key" = "$pin_key" ] && continue [ -n "$long" ] || continue local seen age seen=$(notice_first_seen "$key" "$state" "$now") diff --git a/t/helpers.bash b/t/helpers.bash index 0400cfc..66e8198 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -53,6 +53,7 @@ FIVE_HOUR_RECOVERY_SECS=1800 SEVEN_DAY_RECOVERY_SECS=43200 QUOTA_BUMP_NOTICE_SECS=60 SEVEN_DAY_WINDOW_SECS=604800 +SEVEN_DAY_YOUNG_SECS=86400 EXTRA_AUTO_UTIL_PCT=50 CACHE_BREAK_MIN_TOKENS=2000 CACHE_BREAK_DROP_PCT=5 diff --git a/t/statusline.bats b/t/statusline.bats index 3ab6c56..65bb0ef 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -1259,6 +1259,19 @@ _seed_week_store() { [[ "$plain" =~ @[A-Z][a-z][a-z]\ [0-9]{2}:[0-9]{2}$ ]] } +@test "strip_tail: young is a fraction of the window, not a clock reading" { + now=$(date +%s) + # 5h is unchanged: 15m is 5% of it, and the pace is legible from there + [[ "$(strip_ansi "$(strip_tail 5 $((18000 - 900)) 18000 "$now" hide)")" == *"✕"* ]] + [ -z "$(strip_tail 5 $((18000 - 899)) 18000 "$now" hide)" ] + # the same 15m is 0.1% of a week: 2% of the pool over 0.1% of the time + # printed 3.0✕ in red on an hour-old window. 7d now waits ~8.4h, the + # fraction seven_day_elapsed already calls the noise floor. + [ -z "$(strip_tail 2 $((604800 - 900)) 604800 "$now" hide)" ] + [ -z "$(strip_tail 2 $((604800 - 30239)) 604800 "$now" hide)" ] + [[ "$(strip_ansi "$(strip_tail 2 $((604800 - 30240)) 604800 "$now" hide)")" == *"✕"* ]] +} + @test "build_week_row: each strip ends with its pace and reset" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" @@ -1342,22 +1355,48 @@ _seed_week_store() { plain=$(strip_ansi "$(build_week_row "$usage" always)" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//') # everything from ▮ to the end is one contiguous run: kept hollow cells, # then the folded tail (no gap ever lands right after the now-marker) - [[ "${plain#*▮}" =~ ^▯+\.\.\.▯5h✕[0-9]+$ ]] + [[ "${plain#*▮}" =~ ^▯+\.\.\.▯\(✕[0-9]+\)$ ]] rm -rf "$tmpdir" } -@test "build_week_row: the folded 7d tail still accounts for all 34 slots" { +@test "build_week_row: the folded 7d tail counts windows to the reset, not cells hidden" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" - reset_7d=$(date -u -d '+5 days' '+%Y-%m-%dT%H:%M:%SZ') - usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":9},"seven_day":{"utilization":20,"resets_at":"%s"}}' "$(date +%s)" "$reset_7d") + now=$(date +%s) + # not a whole number of 5h windows out: the grid rounds the period start + # to 5 min, and an exact multiple would sit on the ceil boundary + reset_epoch=$((now + 5 * 86400 + 3600)) + reset_7d=$(date -u -d "@$reset_epoch" '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":9},"seven_day":{"utilization":20,"resets_at":"%s"}}' "$now" "$reset_7d") plain=$(strip_ansi "$(build_week_row "$usage" always)" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//' | tr -d ' ') strip="${plain#7d}" - [[ "$strip" =~ ^(.*)\.\.\.▯5h✕([0-9]+)$ ]] + [[ "$strip" =~ ^(.*)\.\.\.▯\(✕([0-9]+)\)$ ]] drawn=$(printf '%s' "${BASH_REMATCH[1]}" | grep -o . | wc -l) - [ $((drawn + BASH_REMATCH[2])) -eq 34 ] # exactly WEEK_FUTURE_KEEP hollow cells stay visible before the fold [[ "${BASH_REMATCH[1]}" == *"▮▯▯" ]] + # the count is 5h windows before the reset — the number the budget line + # prices — and NOT the cells the fold hid: the 34-cell grid spans 170h + # against a 168h period, so the two disagree by design + [ "${BASH_REMATCH[2]}" -eq $(( (reset_epoch - now + 17999) / 18000 )) ] + [ "${BASH_REMATCH[2]}" -ne $((34 - drawn)) ] + rm -rf "$tmpdir" +} + +@test "build_week_row: the fold token and the budget line count the same windows" { + # one line, one arithmetic: the row folded `...▯(✕N)` and the budget + # sentence beside it priced `~N✕5h left` from the same instant, and a + # reader must never have to arbitrate between the halves of one row + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + now=$(date +%s) + reset_7d=$(date -u -d "@$((now + 3 * 86400 + 7000))" '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":9},"seven_day":{"utilization":30,"resets_at":"%s"}}' "$now" "$reset_7d") + row=$(strip_ansi "$(build_week_row "$usage" always)") + [[ "$row" =~ \(✕([0-9]+)\) ]] + fold="${BASH_REMATCH[1]}" + pin=$(strip_ansi "$(build_advisor_line "$usage" always)") + [[ "$pin" =~ ^([0-9]+)✕5h\ left ]] + [ "$fold" -eq "${BASH_REMATCH[1]}" ] rm -rf "$tmpdir" } @@ -1368,10 +1407,10 @@ _seed_week_store() { reset_7d=$(date -u -d '+5 days' '+%Y-%m-%dT%H:%M:%SZ') usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":9},"seven_day":{"utilization":50,"resets_at":"%s"}}' "$(date +%s)" "$reset_7d") plain=$(strip_ansi "$(build_week_row "$usage" always)" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//' | tr -d ' ') - [[ "$plain" =~ \.\.\.×5h✕[0-9]+$ ]] + [[ "$plain" =~ \.\.\.×\(✕[0-9]+\)$ ]] # the dry cell and the operator sit side by side in this token and must # never be the same mark: × is a reading, ✕ is punctuation - [[ "$plain" == *"×5h✕"* ]] + [[ "$plain" == *"×(✕"* ]] rm -rf "$tmpdir" } @@ -1419,7 +1458,7 @@ _seed_week_store() { usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":9},"seven_day":{"utilization":20,"resets_at":"%s"}}' "$(date +%s)" "$reset_7d") [ -z "$(build_week_row "$usage" auto)" ] plain=$(strip_ansi "$(build_week_row "$usage" always)" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//' | tr -d ' ') - [[ "$plain" =~ ^7d░+▮▯+\.\.\.▯5h✕[0-9]+$ ]] + [[ "$plain" =~ ^7d░+▮▯+\.\.\.▯\(✕[0-9]+\)$ ]] [ -z "$(build_week_row "$usage" off)" ] # no live 7d window: nothing, even in always mode [ -z "$(build_week_row '{"seven_day":{"utilization":20}}' always)" ] @@ -1464,11 +1503,11 @@ _seed_week_store() { [ "$(compact_text "$t" 200)" = "$t" ] [ "$(compact_text "$t" 40)" = "! 5h caps ~05:18, 52m before reset" ] [ "$(compact_text "$t" 20)" = "! 5h caps ~05:18" ] - b="- budget ~3✕5h left · even 20%/win · heading ~52%" - [ "$(compact_text "$b" 36)" = "- budget ~3✕5h left · even 20%/win" ] - [ "$(compact_text "$b" 19)" = "- budget ~3✕5h left" ] + b="budget ~3✕5h left · even 20%/win · heading ~52%" + [ "$(compact_text "$b" 34)" = "budget ~3✕5h left · even 20%/win" ] + [ "$(compact_text "$b" 17)" = "budget ~3✕5h left" ] # no joint left: hard cut with an ellipsis, never a mid-word lie - [ "$(compact_text "- budget last window is long" 16)" = "- budget last w…" ] + [ "$(compact_text "budget last window is long" 16)" = "budget last win…" ] # under 16 columns nothing honest fits run compact_text "$b" 15 [ "$status" -eq 1 ] @@ -1705,6 +1744,67 @@ EOF rm -rf "$tmpdir" } +# --- the young-window guard: a 7d projection needs this window's evidence --- + +@test "young 7d window: nothing projects from last week onto a window hours old" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + now=$(date +%s) + # A heavy trained profile (30%/day, 40% in the trailing 24h) and a window + # that opened 2.4h ago with 2% on it. Every point of that profile — and + # every hour of that trailing day — belongs to the window BEFORE this one. + _write_profile_cache "$tmpdir" 21 30 40 + young=$((604800 - 8640)) + [ -z "$(seven_day_forecast 2 $young)" ] + [ -z "$(_seven_day_walk 2 $young)" ] + ps=$(week_period_start "$now" "$young") + [ "$(week_dry_slot 2 "$young" "$now" "$ps")" = "-1" ] + reset_7d=$(date -u -d "@$((now + young))" '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":9},"seven_day":{"utilization":2,"resets_at":"%s"}}' "$now" "$reset_7d") + row=$(strip_ansi "$(build_week_row "$usage" always)") + [[ "$row" != *×* ]] # no wall of dry cells drawn from history + [[ "$row" != *"✕ @"* ]] # and no pace on a 2.4h-old week + records=$(notice_collect "$usage" always) + [ -z "$(printf '%s\n' "$records" | grep -E '7d\.(dry|caps)')" ] + long=$(strip_ansi "$(notice_long_line "$records")") + [[ "$long" == budget* ]] + [[ "$long" != *heading* ]] # nowhere to head yet + rm -rf "$tmpdir" +} + +@test "young 7d window: a day in, the projections come back" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + now=$(date +%s) + _write_profile_cache "$tmpdir" 21 30 40 + aged=$((604800 - 90000)) # 25h elapsed: the window can speak + [ -n "$(seven_day_forecast 2 $aged)" ] + ps=$(week_period_start "$now" "$aged") + [ "$(week_dry_slot 2 "$aged" "$now" "$ps")" != "-1" ] + reset_7d=$(date -u -d "@$((now + aged))" '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":9},"seven_day":{"utilization":2,"resets_at":"%s"}}' "$now" "$reset_7d") + row=$(strip_ansi "$(build_week_row "$usage" always)") + [[ "$row" == *×* ]] + records=$(notice_collect "$usage" always) + [ -n "$(printf '%s\n' "$records" | grep -E '7d\.(dry|caps)')" ] + [[ "$(strip_ansi "$(notice_long_line "$records")")" == *"7d dry"* ]] + rm -rf "$tmpdir" +} + +@test "seven_day_pace: a window younger than a day has no pace of its own" { + # 8% burned 12h into a fresh week extrapolates to 16%/day and calls the + # week at risk — on half a day of evidence, most of it one session. + read -r level runway hint <<<"$(seven_day_pace 8 $((604800 - 43200)))" + [ "$level" = "green" ]; [ "$hint" = "0" ]; [ "$runway" = "-1" ] + # a day in, the same arithmetic is allowed to speak + read -r level runway hint <<<"$(seven_day_pace 20 $((604800 - 90000)))" + [ "$level" = "yellow" ]; [ "$hint" = "1" ] + # ...and a young window that is genuinely spent still goes red on its own + # percentage: the guard mutes pace, never facts + read -r level runway hint <<<"$(seven_day_pace 88 $((604800 - 43200)))" + [ "$level" = "red" ] +} + # --- is_1m_model ------------------------------------------------------------ @test "is_1m_model: ctx_size > 200k is sufficient regardless of exceeds_200k" { @@ -3421,10 +3521,12 @@ JSON 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\"}}" plain=$(strip_ansi "$(build_advisor_line "$usage" always)") - [[ "$plain" =~ ^-\ 24✕5h\ left\ ·\ 3\.3%/win$ ]] + # the budget voice wears no sigil: it interrupts nothing, and a leading + # '-' on a dim line reads as a bullet in a list of one + [[ "$plain" =~ ^24✕5h\ left\ ·\ 3\.3%/win$ ]] # the whole sentence is still there for surfaces with a line to spend long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") - [[ "$long" =~ ^-\ budget\ ~24✕5h\ left\ ·\ even\ 3\.3%/win\ ·\ heading\ ~70%$ ]] + [[ "$long" =~ ^budget\ ~24✕5h\ left\ ·\ even\ 3\.3%/win\ ·\ heading\ ~70%$ ]] } @test "build_advisor_line: budget degrades to plain headroom in the last window" { @@ -3438,10 +3540,10 @@ JSON # 25% unused is under ADVISOR_SURPLUS_MIN_PCT, so the end-of-week voice # stays quiet and the budget line owns the row plain=$(strip_ansi "$(build_advisor_line "$usage" always)") - [[ "$plain" =~ ^-\ last\ window\ ·\ 25%\ left$ ]] + [[ "$plain" =~ ^last\ window\ ·\ 25%\ left$ ]] [[ "$plain" != *"/win"* ]] long=$(strip_ansi "$(notice_long_line "$(notice_collect "$usage" always)")") - [[ "$long" =~ ^-\ budget\ last\ window\ ·\ 25%\ left\ ·\ heading\ ~[0-9]+%$ ]] + [[ "$long" =~ ^budget\ last\ window\ ·\ 25%\ left\ ·\ heading\ ~[0-9]+%$ ]] } @test "build_advisor_line: hot 5h pace projects the cap wall-clock" { @@ -3531,19 +3633,24 @@ JSON NOTICE_RECS=() } -@test "notice_flash_line: explains a new condition, then fades and leaves the pin" { +@test "notice_flash_line: the pin never flashes its own sentence; a second condition does" { state=$(mktemp -d)/notice_seen NOTICE_RECS=() notice_add 90 '!yellow' 5h '5h.caps.05:18' '~05:18' '5h caps ~05:18' '5h caps ~05:18, 42m before reset' records=$(notice_ranked) now=$(date +%s) + # one condition, one row: row 3 would only restate row 2 at more length + [ -z "$(notice_flash_line "$records" "$state" "$now")" ] + [ "$(strip_ansi "$(notice_pin_line "$records")")" = "! 5h caps ~05:18" ] + # a second, lower-ranked condition has something the pin does not say + notice_add 65 '+' 7d '7d.surplus.9' '47%' '47% unused' '7d resets @07:00, 47% unused · spend it' + records=$(notice_ranked) first=$(strip_ansi "$(notice_flash_line "$records" "$state" "$now")") - [ "$first" = "! 5h caps ~05:18, 42m before reset" ] + [ "$first" = "+ 7d resets @07:00, 47% unused · spend it" ] # same condition a minute later: still inside the flash window [ -n "$(notice_flash_line "$records" "$state" $((now + 60)))" ] # ...and past it the row is gone; row 2 keeps the pin [ -z "$(notice_flash_line "$records" "$state" $((now + NOTICE_FLASH_SECS + 1)))" ] - [ "$(strip_ansi "$(notice_pin_line "$records")")" = "! 5h caps ~05:18" ] NOTICE_RECS=() rm -rf "$(dirname "$state")" } @@ -3840,17 +3947,23 @@ _write_ppw_fixture() { # dir ppw reset_5h=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ') reset_7d=$(date -u -d '+5 days' '+%Y-%m-%dT%H:%M:%SZ') _mk_input() { - printf '{"session_id":"adv","model":{"id":"claude-opus-4-8","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"cost":{"total_cost_usd":0},"context_window":{"used_percentage":10,"context_window_size":200000},"rate_limits":{"five_hour":{"used_percentage":%s,"resets_at":"%s"},"seven_day":{"used_percentage":10,"resets_at":"%s"}}}' "$1" "$reset_5h" "$reset_7d" + printf '{"session_id":"adv","model":{"id":"claude-opus-4-8","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"cost":{"total_cost_usd":0},"context_window":{"used_percentage":10,"context_window_size":200000},"rate_limits":{"five_hour":{"used_percentage":%s,"resets_at":"%s"},"seven_day":{"used_percentage":%s,"resets_at":"%s"}}}' "$1" "$reset_5h" "${2:-10}" "$reset_7d" } hot=$(_mk_input 85 | HOME="$tmpdir" bash "$SCRIPT_DIR/statusline.sh" --notice off) [ "$(printf '%s\n' "$hot" | wc -l)" -eq 2 ] [[ "$(strip_ansi "$hot")" == *"5h caps ~"* ]] - # a condition this session has not seen yet also explains itself on row 3 - fresh=$(_mk_input 85 | HOME="$tmpdir" bash "$SCRIPT_DIR/statusline.sh") - [ "$(printf '%s\n' "$fresh" | wc -l)" -eq 3 ] - [[ "$(strip_ansi "$fresh")" == *"before reset"* ]] + # one condition is one row: row 3 does not restate the pin above it + lone=$(_mk_input 85 | HOME="$tmpdir" bash "$SCRIPT_DIR/statusline.sh") + [ "$(printf '%s\n' "$lone" | wc -l)" -eq 2 ] calm=$(_mk_input 20 | HOME="$tmpdir" bash "$SCRIPT_DIR/statusline.sh") [ "$(printf '%s\n' "$calm" | wc -l)" -eq 1 ] + # a SECOND condition this session has not seen earns row 3 with its own + # sentence — the 7d wall the 5h pin says nothing about. Last in the + # sequence on purpose: a later render at a lower 7d would read this + # window as re-based and speak about that instead. + fresh=$(_mk_input 85 95 | HOME="$tmpdir" bash "$SCRIPT_DIR/statusline.sh") + [ "$(printf '%s\n' "$fresh" | wc -l)" -eq 3 ] + [[ "$(strip_ansi "$fresh")" == *"before reset"* ]] rm -rf "$tmpdir" }