diff --git a/CHANGELOG.md b/CHANGELOG.md index 6585138..e7596cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## v0.38.0 — 2026-09-01 — the future has a shape + +**The fold's own argument expired.** "History is information; the future +is one fact — the count" was true while every hollow cell was identical. +v0.36.0 taught the cells when you sleep, and a live frame made the cost +concrete: `▮▯▯...×(✕6)` spends a nine-column token to hide five cells +that would have said more drawn out. So the fold keeps only the job it +still does — column economy on the long early-week run — and the rule is +now break-even: a future of `WEEK_FUTURE_UNFOLD_MAX` (10) cells or fewer +draws in full, which is the last ~2 days of every week. Above that, +nothing changes: two kept cells, the counted token, the same number the +budget line prices. The old measurement stands where it was made — 30 +hollow cells do read as "too much future". + +**A cell you sleep through draws dim.** With the learned shape (same +read validation and 14-day gate as the walk), a future `▯` whose 5h slot +has fewer than `REST_SLOT_AWAKE_MIN_SECS` (9000) awake seconds — under +half the cell — dims: capacity on the grid that is not really available. +The 5h strip applies the same half-the-cell rule to its 1h cells, so +`▃▮▯▯` with the last two dim says the window outlives your evening. `×` +outranks the tint (a cell the pool cannot cover is unusable for a +stronger reason), `▮` and history are untouched, and unlearned every `▯` +stays plain — the render is byte-identical to v0.37.0 apart from the +unfold rule. The tint is the one refinement this row carries by color +alone, and the one place that is honest about it: a dim `▯` is still a +window ahead, so a reader without the tint loses nothing actionable. The +dim count may sit one off the budget's `~N awake` — cells are grid +slots, the sentence is clocks, the same one-cell tolerance the fold +count has always carried. + +Ships with [ccpace](https://github.com/thevibeworks/ccpace) v0.6.0 ("the +night on the ledger"), which dims the same cells by the same rule; +`REST_SLOT_AWAKE_MIN_SECS` joins the shared reading rules in +`docs/api/state-dir.md`. No `forecast.cache` change of any kind. + ## v0.37.0 — 2026-09-01 — what a point buys **Two pools drain toward one wall and nothing said which one binds.** The diff --git a/DESIGN.md b/DESIGN.md index b57eeb9..35298f2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -99,11 +99,15 @@ the badge states the end (`5h[38%@23:00]`) and the notice names the wall (`5h caps ~14:20`). Cells carry the shape, badges carry state, notices do the warning. History draws in full; the live row folds the 7d future after two kept cells into `...▯(x14)` — the 5h windows *after* the one -you are in, all alike, `×` red when the tail projects dry (the `week` +you are in, `×` 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, and it folds as soon as folding hides two -cells: the future is one fact, and the ink belongs to history. Each strip -ends +priced from the same instant. The fold pays only while the tail is wider +than its own token: ten cells or fewer draw in full, because the future +stopped being one fact the day the cells learned your rhythm — a hollow +cell you sleep through most of draws *dim*, so the drawn tail is a shape +(tonight, the night, tomorrow morning), not a run of identical slots. +Unlearned, every `▯` stays plain and only the fold rule changes. 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. @@ -113,8 +117,11 @@ badges. ▁ baseline: ran, negligible — the shortest bar of the same block ░ unknown — no sample; never drawn as idle ▮ now -▯ ahead — the hollow of ▮; on 5h, the hours left in the window -...▯(x14) the folded future: 14 more 5h windows after this one (live 7d row) +▯ ahead — the hollow of ▮; on 5h, the hours left in the window; + dim when your learned hours say you sleep through most of it + (× outranks it: unreachable beats asleep) +...▯(x14) the folded future: 14 more 5h windows after this one (live 7d + row, only while the tail is wider than this token) × pace won't cover it (7d only: learned forecast, linear when cold) ``` diff --git a/README.md b/README.md index fe5da9b..3e5c846 100644 --- a/README.md +++ b/README.md @@ -318,14 +318,16 @@ 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 as soon as folding hides two cells: two hollow - cells after `▮`, then `...▯(✕28)` — 28 more 5h windows **after the one - you are in**, 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 future is one fact and the strip's ink belongs to - history; a closing week needs no help to draw to its edge, since four - cells or fewer leave nothing worth folding. The `week` subcommand's wide - ledger still draws every slot. + full; the future folds only while folding pays: with more than ten cells + ahead, two hollow cells after `▮`, then `...▯(✕28)` — 28 more 5h windows + **after the one you are in** (`×` 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. Ten cells or fewer — the last ~2 days + of every week — draw in full, because the cells stopped being all alike: + once the learned rest model knows your hours, a hollow cell you + sleep through most of draws **dim**, so the drawn tail is a shape + (tonight, the night, tomorrow morning), not a count. The `week` + subcommand's wide ledger still draws every slot. | Cell | Meaning | |------|---------| @@ -333,7 +335,7 @@ fire. | `▁` | the baseline: ran and cost under a point, or ran idle inside the log's coverage. The shortest bar of the same Block Elements run as `▂▃▄`, so the zero line shares their font, width and height — it used to be `ˍ` (U+02CD), a modifier *letter*, which terminals resolve through the text face and which broke the strip's metrics mid-row. Override with `LEDGER_BASE_GLYPH` | | `░` | unknown: the log has no sample for that cell (never drawn as idle — a gap in the record is not a quiet session) | | `▮` | the cell you are in now | -| `▯` | a cell still ahead of you — the hollow of `▮`, an empty slot waiting. On the 5h strip the run of them is the hours left in the window | +| `▯` | a cell still ahead of you — the hollow of `▮`, an empty slot waiting. On the 5h strip the run of them is the hours left in the window. **Dim** when your learned hours say you sleep through most of it (under half the cell awake): capacity on the grid that is not really available. `×` outranks it — unreachable beats asleep — and a reader who cannot see the tint loses nothing actionable: a dim `▯` is still a window ahead | | `×` | a cell the pool will not cover at the current pace — **7d only**: the learned forecast's dry point, linear when untrained. The 5h window's wall belongs to the `5h caps ~14:20` notice, which states the time instead of shading cells | | `...▯(✕28)` | the folded 7d future: 28 more 5h windows after the one you are in, one token instead of a run of hollow cells (`×` red when the tail ends dry). The count is 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) | diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index 2e69ae1..1065318 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.37.0 +- Synced with: statusline.sh v0.38.0 - Permissions: the script runs under `umask 077` — files are owner-only. Caches hold account PII (email, uuid, org names). @@ -251,6 +251,16 @@ 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. +A REST SLOT is a ledger cell you sleep through: fewer than +`REST_SLOT_AWAKE_MIN_SECS = 9000` awake seconds in its 5h span — under +half the cell, the break-even a reader can hold in their head. Both +ledgers (this statusline's week strip, ccpace's block) draw such a future +cell as a dim `▯`; the general rule is half the cell, which is how the 5h +strip applies it to 1h cells (1800). `×` outranks the tint — a cell the +pool cannot cover is unusable for a stronger reason — and the dim count +may sit one off the sentence's `~N awake`: cells are grid slots, the +sentence is clocks, the same one-cell tolerance the fold count carries. + `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/statusline.sh b/statusline.sh index a361fc8..7b13514 100755 --- a/statusline.sh +++ b/statusline.sh @@ -2109,6 +2109,13 @@ SCOPE_STRAND_MIN_PCT=10 # scoped points the mix strands before it is wo SCOPE_MIX_MIN_7D=60 # ...and only once the account's own end is in sight SCOPE_MIX_MIN_SCOPE=5 # a model untouched this week is the underuse voice's job SCOPE_SAME_WALL_SEC=120 # resets this close are one wall; further apart, no ratio +# A 5h ledger slot is a NIGHT when under half of it is waking time (awake +# seconds < 9000, hours with mult >= REST_MULT_MAX). Half is the break-even a +# reader can hold in their head: more asleep than awake and the slot is +# capacity you will not reach. Shared with ccpace (docs/api/state-dir.md) so +# both ledgers dim the same cells; the strip applies the same half-the-cell +# rule to its 1h cells, where the threshold is 1800. +REST_SLOT_AWAKE_MIN_SECS=9000 # Rotate usage.jsonl when it exceeds the cap. Single .1 backup; the profile # builder reads .1 + current, so rotation never costs learned history. @@ -2979,7 +2986,7 @@ run_session_summary() { # ▁ ran, burned under a point — the baseline # ░ unknown: no samples on record for that window # ▮ the window you are in now -# ▯ a window still ahead of you +# ▯ a window still ahead of you (dim: one you likely sleep through) # × a window the pool will not cover at the current pace # The report draws every slot, so what follows ▮ IS the budget line's # "~N✕5h left" laid out cell by cell — ▮ itself is where you are, not a @@ -3002,7 +3009,16 @@ run_session_summary() { # modifier letter used to sit here and broke the row's metrics. # ░ unknown — outside the sample log's coverage # ▮ the cell you are in now -# ▯ a cell still ahead of you (the hollow of ▮: an empty slot) +# ▯ a cell still ahead of you (the hollow of ▮: an empty slot); +# DIM when the learned rhythm says you sleep through most of it — +# capacity on the grid that is not really available. The one +# refinement this row carries by tint alone, and the one place +# that is honest: a dim ▯ is still a window ahead, so a reader +# who cannot see the tint loses nothing actionable. × outranks +# it — a cell the pool cannot cover is unusable for a stronger +# reason — and the dim count may sit one off the budget's +# "~N awake": these are grid slots, the sentence is clocks, the +# same one-cell tolerance the fold count documents below. # × a cell the pool will not cover at the current pace — 7d only # ...▯(✕11) the folded future: 11 more 5h windows AFTER the one you are in, # all alike (× red when the tail projects dry) — live 7d strip @@ -3035,15 +3051,20 @@ WEEK_CACHE_TTL_SECS=300 # keeps WEEK_FUTURE_KEEP hollow cells, then folds the rest into `...▯(✕11)` # (count = windows_ahead, the 5h windows after this one; × red when the tail # projects dry). -# History is information; the future is one fact, and the fact is the count. -# The threshold is 2, not a column break-even: eleven hollow cells were -# measured to read as "too much future" long before they were expensive, and -# the count is the part that has to survive mid-week, when a pressure notice -# owns the pin and this row is the only place the remaining windows appear. -# A closing week still draws to its edge without help — four cells or fewer -# leave nothing worth folding. +# "History is information; the future is one fact" was this fold's original +# argument, and the rest tint SUPERSEDED it: future cells now carry which +# windows the reader is awake for, so the future is a shape, not one fact. +# What remains of the fold is column economy. The token `...×(✕11)` is nine +# columns; hiding fewer cells than that pays nothing, so a future of +# WEEK_FUTURE_UNFOLD_MAX cells or fewer draws in full — the last ~2 days of +# every week — and only the long early-week run still folds. The old +# measurement stands where it was made: 30 hollow cells DO read as "too much +# future", and the count is still the part that has to survive mid-week, +# when a pressure notice owns the pin and this row is the only place the +# remaining windows appear. WEEK_FUTURE_KEEP=2 WEEK_FUTURE_MIN_HIDE=2 +WEEK_FUTURE_UNFOLD_MAX=10 # The period start on the same 5-min grid the window keys are rounded to. # resets_at is jittered by the API (15:59:59.76 one fetch, 16:00:00.47 the @@ -3196,13 +3217,15 @@ week_dry_slot() { # windows AHEAD of the current one, counted from real clocks by the caller; # the strip must not re-derive it, since a 34-cell grid spans 170h against a # 168h period and a count read off the drawing disagrees with the budget -# sentence beside it — and finally an optional now-cell (-1: derive it from -# the period start, which is what a grid-aligned strip wants). +# sentence beside it — an optional now-cell (-1: derive it from +# the period start, which is what a grid-aligned strip wants), and finally +# the learned hour multipliers ("" unlearned): with them, a future ▯ whose +# cell is mostly rest hours draws dim (see REST_SLOT_AWAKE_MIN_SECS). # 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}" - local nleft="${10:-0}" nowslot_in="${11:--1}" + local nleft="${10:-0}" nowslot_in="${11:--1}" mults="${12:-}" local span_lo="" span_hi="" cells="" [ -n "$hist" ] && read -r span_lo span_hi cells <<<"$hist" local fill_color tzoff_s @@ -3210,8 +3233,9 @@ 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 nleft="$nleft" -v mult="$MULT_GLYPH" \ - -v nsin="$nowslot_in" \ + -v minhide="$WEEK_FUTURE_MIN_HIDE" -v unfoldmax="$WEEK_FUTURE_UNFOLD_MAX" \ + -v nleft="$nleft" -v mult="$MULT_GLYPH" \ + -v nsin="$nowslot_in" -v mults="$mults" -v restmult="$REST_MULT_MAX" \ -v lo="${span_lo:--1}" -v hi="${span_hi:--1}" -v cells="$cells" \ -v C_FILL="$fill_color" -v C_DIM="$DIM" -v C_NOW="$BOLD" \ -v C_DRY="$RED" -v C_OFF="$RESET" -v BASE="$LEDGER_BASE_GLYPH" ' @@ -3225,15 +3249,33 @@ build_ledger_strip() { if (c <= 11) return "▅"; if (c <= 15) return "▆" if (c <= 20) return "▇"; return "█" } + # Awake seconds of the cell starting at t0: the same hour arithmetic + # awake_secs walks, inlined because a fork per cell is 34 forks per + # render. hm[] is 1-based (split); an empty mults string leaves + # have_rest 0 and no cell ever asks. + function cell_awake(t0, t2, end2, lt2, seg2, aw2) { + end2 = t0 + cs; aw2 = 0; t2 = t0 + while (t2 < end2) { + lt2 = t2 + tz + seg2 = t2 + 3600 - (lt2 % 3600) + if (seg2 > end2) seg2 = end2 + if (hm[int((lt2 % 86400) / 3600) + 1] + 0 >= restmult) aw2 += seg2 - t2 + t2 = seg2 + } + return aw2 + } BEGIN { n = split(cells, a, ",") for (i = 1; i <= n; i++) { split(a[i], kv, ":"); cost[kv[1]] = kv[2] } + have_rest = (split(mults, hm, " ") == 24) nowslot = (nsin >= 0 ? nsin : int((now - ps) / cs)) - # fold the future tail: everything past the kept cells is the - # same hollow slot, so name it once with a count instead of - # drawing it N times + # fold the future tail — but only when folding pays: the token is + # nine columns, and a future of unfoldmax cells or fewer costs no + # more drawn in full while carrying the rest shape the token + # cannot (see WEEK_FUTURE_UNFOLD_MAX) lim = w - if (fut > 0 && nleft > 0 && w - (nowslot + 1 + fut) >= minhide) + if (fut > 0 && nleft > 0 && w - (nowslot + 1) > unfoldmax \ + && w - (nowslot + 1 + fut) >= minhide) lim = nowslot + 1 + fut s = ""; prev = ""; pday = -1 for (i = 0; i < lim; i++) { @@ -3255,7 +3297,14 @@ build_ledger_strip() { else { g = "░"; c = C_DIM } } else if (i == nowslot) { g = "▮"; c = C_NOW } else if (dry >= 0 && i >= dry) { g = "×"; c = C_DRY } - else { g = "▯"; c = "" } + else { + # a window still ahead — dim when the learned rhythm says + # the reader sleeps through most of it (under half the + # cell awake). × above outranks this: unreachable beats + # asleep. Unlearned, every ▯ stays plain. + g = "▯"; c = "" + if (have_rest && 2 * cell_awake(start) < cs) c = C_DIM + } if (c != prev) { s = s C_OFF c; prev = c } s = s g } @@ -3277,9 +3326,13 @@ 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 and -# never folds); $7 is windows_ahead, the number the fold token prints. +# never folds); $7 is windows_ahead, the number the fold token prints. $8 is +# a test seam: the hour multipliers, read from the learned profile when the +# caller does not say (an UNSET $8, not an empty one — "" is how a test +# states "unlearned" against a fixture that would otherwise speak). build_week_strip() { - build_ledger_strip "$1" "$2" "$3" "$4" "$5" "$WEEK_CELLS" 18000 1 "${6:-0}" "${7:-0}" + local mults="${8-$(hour_profile_mults)}" + build_ledger_strip "$1" "$2" "$3" "$4" "$5" "$WEEK_CELLS" 18000 1 "${6:-0}" "${7:-0}" -1 "$mults" } # 5h strip: the five hours of the current window, always all five. $4 is @@ -3292,6 +3345,11 @@ build_week_strip() { # reflow every hour, which is the difference between an axis and a bar that # grows at you. # +# A dim hollow hour is NOT a forecast of the pool — it is the reader's own +# learned rhythm (an hour they are usually asleep for), the same rule the 7d +# cells apply at half-the-cell: for a 1h cell that is 1800 awake seconds. +# `▃▮▯▯▯` with the last two dim says the window outlives the evening. +# # ▮ rides the real clock, not the grid. five_period_start rounds to 5 minutes # so week_scan's cache key holds still across renders (a resets_at that jitters # by a second would re-run a whole-log jq pass every render), and that rounding @@ -3303,7 +3361,8 @@ build_five_strip() { local nowslot=$(( 4 - ${5:-0} / 3600 )) [ "$nowslot" -lt 0 ] && nowslot=0 [ "$nowslot" -gt $((FIVE_CELLS - 1)) ] && nowslot=$((FIVE_CELLS - 1)) - build_ledger_strip "$1" "$2" "$3" -1 "$4" "$FIVE_CELLS" "$FIVE_CELL_SECS" 0 0 0 "$nowslot" + local mults="${6-$(hour_profile_mults)}" + build_ledger_strip "$1" "$2" "$3" -1 "$4" "$FIVE_CELLS" "$FIVE_CELL_SECS" 0 0 0 "$nowslot" "$mults" } # Does a history line carry at least one cell BEFORE the now-cell? A single diff --git a/t/helpers.bash b/t/helpers.bash index 57902d9..03f854f 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -64,6 +64,8 @@ MULT_GLYPH="✕" LEDGER_BASE_GLYPH="▁" FORECAST_SCHEMA=2 REST_MULT_MAX=0.25 +REST_SLOT_AWAKE_MIN_SECS=9000 +WEEK_FUTURE_UNFOLD_MAX=10 SCOPE_STRAND_MIN_PCT=10 SCOPE_MIX_MIN_7D=60 SCOPE_MIX_MIN_SCOPE=5 diff --git a/t/statusline.bats b/t/statusline.bats index 95bfa83..2181eeb 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -1530,15 +1530,11 @@ _seed_week_store() { [[ "$plain" == "7d "* ]] strip=$(printf '%s' "${plain#7d }" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//') bar=$(printf '%s' "$strip" | tr -d ' ') - # the live row draws history + ▮ + WEEK_FUTURE_KEEP and folds the rest; - # the grid it draws them on is still 34 cells (`week` renders all of it) - [[ "$bar" =~ ^(.*)\.\.\.▯\(✕[0-9]+\)$ ]] - drawn="${BASH_REMATCH[1]}" - seven_secs=$(get_reset_seconds "$(jq -r .seven_day.resets_at "$tmpdir/usage.cache")") - ps=$(week_period_start "$(date +%s)" "$seven_secs") - nowslot=$(( ($(date +%s) - ps) / 18000 )) - [ "$(printf '%s' "$drawn" | grep -o . | wc -l)" -eq $((nowslot + 1 + WEEK_FUTURE_KEEP)) ] - [ $((nowslot + 1 + WEEK_FUTURE_KEEP)) -lt 34 ] + # 31h from the reset there are six cells ahead — ten or fewer draw in + # full (WEEK_FUTURE_UNFOLD_MAX), so the whole 34-cell grid shows; the + # mid-week fold has its own test on a five-day frame + [[ "$bar" =~ ▮▯+$ ]] + [ "$(printf '%s' "$bar" | grep -o . | wc -l)" -eq 34 ] [[ "$bar" =~ [▂▃▄▅▆▇█]▁[▂▃▄▅▆▇█] ]] [[ "$bar" == *▮* ]] # day gaps: single spaces inside the strip, several of them across a week @@ -1795,11 +1791,12 @@ _seed_week_store() { rm -rf "$tmpdir" } -@test "build_week_row: the future folds as soon as it hides two cells" { - # The threshold is not a column break-even. The future is one fact — the - # count — and mid-week, when a pressure notice owns the pin, this row is - # the only place the remaining windows appear. Eleven hollow cells read as - # "too much future" long before they were expensive. +@test "build_week_row: the future folds only when folding pays" { + # The fold's old rule ("as soon as it hides two cells") served "the + # future is one fact"; the rest tint superseded that — future cells now + # carry a shape — so what remains of the fold is column economy: a tail + # of WEEK_FUTURE_UNFOLD_MAX cells or fewer costs no more drawn in full + # than the nine-column token that would replace it. tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" now=$(date +%s) @@ -1809,18 +1806,107 @@ _seed_week_store() { local p; p=$(strip_ansi "$(build_week_row "$u" always)" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//' | tr -d ' ') printf '%s' "${p#*▮}" } - # ~6 windows left: KEEP=2 drawn, the rest folded and counted + # ~6 windows left: cheaper drawn than folded, so every cell shows six=$(_tail_of $((now + 6 * 18000 - 3600))) - [[ "$six" =~ ^▯▯\.\.\.▯\(✕([0-9]+)\)$ ]] - [ "${BASH_REMATCH[1]}" -eq 6 ] - # the last cells of the week: with one cell to hide the fold would spend - # more ink than it saves, so the strip draws to its edge + [[ "$six" != *"..."* ]] + [[ "$six" =~ ^▯{5,7}$ ]] + # ~13 windows left: the tail is wider than the token, so it still folds + # to KEEP=2 cells and the count + mid=$(_tail_of $((now + 13 * 18000 - 3600))) + [[ "$mid" =~ ^▯▯\.\.\.▯\(✕([0-9]+)\)$ ]] + [ "${BASH_REMATCH[1]}" -eq 13 ] + # the last cells of the week: nothing to fold at all close=$(_tail_of $((now + 18000 + 600))) [[ "$close" != *"..."* ]] [[ "$close" =~ ^▯{0,3}$ ]] rm -rf "$tmpdir" } +# Mark dim runs so a test can assert WHERE the tint sits, not just that some +# escape exists: {D} opens dim, {R} is the reset that closes every run. +_mark_dim() { + printf '%s' "$1" | sed -e $'s/\x1b\\[2m/{D}/g' -e $'s/\x1b\\[0m/{R}/g' \ + -e $'s/\x1b\\[[0-9;]*m//g' +} + +@test "build_week_strip: the unfold boundary is the fold token's own width" { + # F = cells after ▮ on the 34-cell grid. Ten or fewer draw in full; + # eleven is the first tail wider than the token and folds. + local ps now10 now11 t10 t11 + ps=$(( ($(date +%s) / 86400 - 6) * 86400 )) + now10=$((ps + 23 * 18000 + 100)) # nowslot 23 -> F = 10 + now11=$((ps + 22 * 18000 + 100)) # nowslot 22 -> F = 11 + t10=$(strip_ansi "$(build_week_strip 50 "$now10" "$ps" -1 "" 2 10 "")") + t11=$(strip_ansi "$(build_week_strip 50 "$now11" "$ps" -1 "" 2 11 "")") + [[ "$t10" != *"..."* ]] + [[ "$t11" == *"...▯(✕11)"* ]] +} + +@test "build_week_strip: a cell you sleep through draws dim" { + # ps at UTC midnight; rest hours 0-8 (mult 0.1), awake 9-23. nowslot 27 + # puts six cells ahead: 20:00 (4h awake, plain), 01:00 (rest, dim), + # 06:00 (2h awake < half the cell, dim), then three daytime cells plain. + local mults="0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54" + local ps now out + ps=$(( ($(date +%s) / 86400 - 6) * 86400 )) + now=$((ps + 27 * 18000 + 100)) + out=$(_mark_dim "$(TZ=UTC build_week_strip 50 "$now" "$ps" -1 "" 2 6 "$mults")") + [[ "${out#*▮}" == "{R}▯{R}{D}▯▯{R}▯▯▯{R}" ]] +} + +@test "build_week_strip: × outranks rest — unreachable beats asleep" { + local mults="0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54" + local ps now raw + ps=$(( ($(date +%s) / 86400 - 6) * 86400 )) + now=$((ps + 27 * 18000 + 100)) + raw=$(TZ=UTC build_week_strip 50 "$now" "$ps" 31 "" 2 6 "$mults") + # the dry wall keeps its glyph and its red: no dim × anywhere + [[ "$(strip_ansi "$raw")" == *"×××"* ]] + [[ "$raw" != *$'\033[2m×'* ]] + # and the rest cells before it still dim + [[ "$(_mark_dim "$raw")" == *"▯{R}{D}▯▯{R}×××"* ]] +} + +@test "build_week_strip: unlearned, every future cell stays plain" { + local ps now raw + ps=$(( ($(date +%s) / 86400 - 6) * 86400 )) + now=$((ps + 27 * 18000 + 100)) + raw=$(TZ=UTC build_week_strip 50 "$now" "$ps" -1 "" 2 6 "") + # no dim escape anywhere after the now-marker (history ░ cells are dim + # by design and sit before it) + [[ "${raw#*▮}" != *$'\033[2m'* ]] +} + +@test "build_five_strip: hollow hours you sleep through draw dim" { + # a 22:00-03:00 UTC window, two hours in: the hours ahead are 01:00 and + # 02:00, both rest — the window outlives the evening and the strip says so + local mults="0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54 1.54" + local fps fnow out + fps=$(( ($(date +%s) / 86400 - 1) * 86400 + 22 * 3600 )) + fnow=$((fps + 2 * 3600 + 60)) + out=$(_mark_dim "$(TZ=UTC build_five_strip 40 "$fnow" "$fps" "" 10740 "$mults")") + [[ "${out#*▮}" == "{R}{D}▯▯{R}" ]] +} + +@test "build_week_strip: the tint flows from the learned cache, gated on history" { + # No mults argument: the builder reads hour_profile_mults, which refuses + # a cache younger than 14 days — the same evidence gate every learned + # surface waits behind. + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + local hp ps now + hp='{"0":0.1,"1":0.1,"2":0.1,"3":0.1,"4":0.1,"5":0.1,"6":0.1,"7":0.1,"8":0.1,"9":1.54,"10":1.54,"11":1.54,"12":1.54,"13":1.54,"14":1.54,"15":1.54,"16":1.54,"17":1.54,"18":1.54,"19":1.54,"20":1.54,"21":1.54,"22":1.54,"23":1.54}' + ps=$(( ($(date +%s) / 86400 - 6) * 86400 )) + now=$((ps + 27 * 18000 + 100)) + printf '{"days_history":21,"hour_profile":%s}\n' "$hp" >"$tmpdir/forecast.cache" + learned=$(TZ=UTC build_week_strip 50 "$now" "$ps" -1 "" 2 6) + [[ "${learned#*▮}" == *$'\033[2m▯'* ]] + printf '{"days_history":5,"hour_profile":%s}\n' "$hp" >"$tmpdir/forecast.cache" + young=$(TZ=UTC build_week_strip 50 "$now" "$ps" -1 "" 2 6) + [[ "${young#*▮}" != *$'\033[2m'* ]] + 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