@ucsandman/legcli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
@@ -0,0 +1,477 @@
1
+ # Leg board and routing redesign: the build spec
2
+
3
+ Status: spec, 2026-09-17. Built from the tournament winner (Downshift Cockpit) with grafts from Bucket Ladder (classifier fix, wall attribution, rung cost), The work you left running (cards), The Sill (verdict budget), CEILINGS (double-wait, discarded facts), Annunciator (terminal title) and Runway (forecast sample counts, money labelling). Every claim below is either VERIFIED with the file, command or doc line that produced it, or marked ASSUMED with the probe that settles it.
4
+
5
+ ## What I verified myself before writing (2026-09-17, 20:16Z)
6
+
7
+ - The live OAuth usage payload, fetched through Leg's own token path (`src/taps/claude-usage.mjs` `USAGE_URL`, headers as in `fetchClaudeUsage`), HTTP 200, 23 keys. The `limits[]` array: `session` 29% (resets 20:30Z), `weekly_all` 47% (resets 09-23 19:00Z), `weekly_scoped` 63% with `scope.model.display_name: "Fable"` and `is_active: true`. `seven_day_opus` and `seven_day_sonnet` are `null`. `extra_usage.is_enabled: false`, `disabled_reason: "out_of_credits"`, `monthly_limit: 12500` (minor units, $125.00). `spend.can_toggle: false`, `can_purchase_credits: false`. `seven_day_breakdown`: Claude Code 100%, Chats 0, Cowork 0. The two judges who got 429 were right to trust the two candidates: the array is there.
8
+ - Leg discards it: `src/taps/claude-usage.mjs` returns `{ five_hour: window(j.five_hour), seven_day: window(j.seven_day) }` and nothing else; `worstWindow()` at `src/board/sessions.js:184` sorts only those two. The board is printing 47% for a login whose binding bucket is at 63%.
9
+ - The classifier bug: `classify({adapter:'claude', exitCode:0, stdout:"You've reached your Fable limit. Run /usage-credits to continue or switch models with /model."})` returns `{"outcome":"no_progress","signal":"none"}`; the docs wording `"You've hit your Opus limit"` returns `{"outcome":"limit","signal":"claude-model-limit"}`. Cause: `fixtures/limits/claude/claude-model-limit.json` pattern `You.ve hit your (?!session |weekly |usage )[A-Za-z0-9.-]+ limit`. The live message in `fixtures/live/claude/limit-rate_limit.json` says `reached`.
10
+ - The four usage records on this machine (`~/.baton/usage/`): claude 5h 29 / 7d 47, no wall; codex 7d 100, `limited_until` 1789870295 (about 54h out), `usage_limit_exceeded`; agy `limited_until` 1789588367, which is already in the past (2026-09-16 19:52Z), so `isAvailable()` reads it as open, and nothing clears the stale record because agy publishes no reading; grok 7d 100 from `grok billing proxy`, observed 2026-09-16 09:55Z, no wall recorded. The winner's "agy walled 71h" was wrong; the board should say "no figure".
11
+ - Flags, from `fixtures/help/claude.txt`: `--model <model>` (aliases `fable`, `opus`, `sonnet`) line 127; `-n, --name <name>` "shown in the prompt box, /resume picker, and terminal title" line 132; `--fallback-model` lines 87 to 92; `--resume`, `--fork-session` lines 36, 95, 203. `codex --help`: `-m, --model <MODEL>` line 76 and a `resume` subcommand line 27. `grok -m` line 69, `agy --model` line 15. Live: `claude --version` 2.1.274, `codex --version` 0.154.0.
12
+ - All four adapters already push a model flag for the headless path: `src/adapters/claude.mjs:29`, `codex.mjs:42`, `agy.mjs:34`, `grok.mjs:45`. The interactive `spawnSpec` in `src/attach.mjs` (lines 230 to 262) does not.
13
+ - Claude docs, via `declick web text`: costs doc line 132, session and weekly limits are "shared across all models, so the developer can't restore access by switching models"; Opus and Sonnet limits are family scoped and "switching to a model outside that family with /model does keep the developer working". model-config line 287: "Authentication, billing, rate-limit, request-size, and transport errors ... never trigger a switch" for `--fallback-model`. Line 68: interactive Fable-on-credits shows a consent prompt. Line 72: with nobody at the terminal the prompt is held five minutes, then the turn ends. Line 77: in `-p` mode "Claude Code never shows the consent prompt ... bills it without asking". Lines 87 and 89: `/model <name>` saves as the default. Line 92: run different models in different terminals with per-launch `--model`.
14
+ - Hooks doc: `Notification` types at line 173 (permission_prompt, idle_prompt, agent_needs_input, quota_auto_resume_fired and eight more); `terminalSequence` at 608, OSC 0/1/2/9/99/777 allowlist; 614 works on Windows with no /dev/tty; 617 OSC 9 is Windows Terminal notifications; 559 StopFailure still emits `terminalSequence`; 1161 permission_prompt fires after about six seconds.
15
+ - Leg wires SessionStart, UserPromptSubmit, PostToolUse, Stop, StopFailure, SessionEnd and no Notification (`src/taps/claude.mjs` `settingsFor`), and sets `autoContinueAtUsageLimit: false`.
16
+ - The interactive child runs `stdio: 'inherit'` (`src/attach.mjs:292`); the board's hand-off already carries `ctl.target {agent, account}` into `claimHandoffChoice` (`src/attach.mjs:480`); `sessionsView()` already ships `handoff_targets[] {agent, account, available, reason, resets_at}` per row (`src/server.mjs:373`) and the picker renders it (`src/board/sessions.js:704`).
17
+ - `createSession()` in `src/sessions.mjs` has no `model` field. The newest Claude transcript for this repo carries `"model":"claude-fable-5-1"` on 29 assistant lines and zero cost fields. The newest codex rollout (2026-09-14) carries `plan_type: "prolite"`, `credits.balance: "0"`, `limit_id: "codex"` and models `gpt-5.6-sol` and `gpt-6-astra`.
18
+ - `playwright` is not installed (`Cannot find package`), so `scripts/board-shots.mjs` cannot run today. Every pixel figure in this document is CSS arithmetic from `src/board/board.css` and must be measured before it is believed. DESIGN.md says this exact class of estimate cost six rounds.
19
+
20
+ ---
21
+
22
+ ## A. The redesigned board
23
+
24
+ ### A.1 What moves and why
25
+
26
+ Today's vertical order is verdict (56px padding top, a 52px headline at `max-width: 26ch` that wraps to two or three lines, a 21px sub, 56px padding bottom), then the lit login panel (32px padding, two gauges), then a `logins-pair` grid of half panels, then Terminals. The terminals are what Wes works from, and roughly 1000px pass before they start.
27
+
28
+ The change: usage becomes a property of a row and a 44px strip, not a region. The login panels are not rewritten; they move intact behind one disclosure so every gauge, notch, `aria-valuetext` and degrade path survives. The verdict stays the largest thing on the page (DESIGN.md rule 1) but gets a character budget enforced by a test, so 52px never costs 168px.
29
+
30
+ ### A.2 1280px
31
+
32
+ ```
33
+ +------------------------------------------------------------------------------+
34
+ | (leg) Leg live * scheduler running, 2 max Floor | masthead, 28/24 pad, ~80px
35
+ +------------------------------------------------------------------------------+
36
+ | | pad-top 40
37
+ | Fable is at 63% of its week, the only login open. | h1 52px, <= 2 lines (112px)
38
+ | Measured 41s ago. 3 terminals run on it, so the real figure is higher, | sub 17px, <= 2 lines
39
+ | never lower. codex is at its limit until Sat 2:11 AM. |
40
+ | | pad-bottom 24
41
+ | * claude |=======---| 63% fable week * codex |==========| back Sat 2:11 AM| capacity strip, 44px
42
+ | * agy no figure * grok |==========| 100% Tue 9:55 AM| (two rows at 1280 if 4 logins
43
+ | [Capacity and models >] | do not fit one; else one)
44
+ +------------------------------------------------------------------------------+
45
+ | Terminals 3 running, 1 waiting on you, 3 share the claude login | section head 32/16
46
+ | +--------------------------------------------------------------------------+ |
47
+ | | * waiting on you leg on main dirty 3, ahead 2 claude/fable | | register 13px
48
+ | | permission: Bash(git push origin HEAD), asked 40s ago | | the one sentence
49
+ | | Rework the board layout so the usage gauges stop eating the first fold | | prompt 17px, 1 line
50
+ | | sessions.js, board.css, usage.mjs, and 3 more 63% of the fable week| | files + capacity phrase
51
+ | | 4h 24m 7f3a | |
52
+ | | [Land] [Hand off >] [Details] [End] | | 2x2, 272px, unchanged
53
+ | +--------------------------------------------------------------------------+ |
54
+ | | * running costclaw on leg/9a1c dirty 1 claude/sonnet quiet 4m | |
55
+ | | Port the invoice parser to the new schema | |
56
+ | | parser.mjs, schema.sql sonnet week: no figure | |
57
+ | | 1h 07m 9a1c | |
58
+ | | [Land] [Hand off >] [Details] [End] | |
59
+ | +--------------------------------------------------------------------------+ |
60
+ | | * running declick on main clean codex/gpt-5.6-sol quiet 12m | |
61
+ | ... |
62
+ +------------------------------------------------------------------------------+
63
+ | Background 1 running, 1 waiting on you | live cards only
64
+ | +--------------------------------------------------------------------------+ |
65
+ | | * waiting on you review station leg on leg/card-3e1 claude/opus | |
66
+ | | Add the audit CSV export | |
67
+ | | 4 files, +212 -18, tests green 6m ago | |
68
+ | | 38m 3e1c | |
69
+ | | [Approve] [Hand off >] [Details] [Kill] | |
70
+ | +--------------------------------------------------------------------------+ |
71
+ | Run in the background: [ Describe the task ______________________ ] [Start] |
72
+ | in leg on main, with claude/opus then codex, build only. More settings |
73
+ +------------------------------------------------------------------------------+
74
+ | 4 finished terminals | 2 landed today | 799 conversations | 3 finished cards, 2 done 1 failed | ledger, on the ground
75
+ +------------------------------------------------------------------------------+
76
+ | Settings |
77
+ +------------------------------------------------------------------------------+
78
+ ```
79
+
80
+ Budget before the first terminal row, CSS arithmetic (masthead 80 + verdict 40+112+8+52+24 + strip 44 + gap 20 + section head 48): about 430px, against about 1010px today. Two terminal rows land above a 900px fold instead of none. To be measured in F, step 2.
81
+
82
+ ### A.3 400px
83
+
84
+ ```
85
+ +--------------------------------+
86
+ | (leg) Leg live * Floor |
87
+ +--------------------------------+
88
+ | Fable is at 63% of its | h1 at the narrow size, <= 3 lines
89
+ | week, the only login open. |
90
+ | Measured 41s ago. 3 terminals |
91
+ | run on it. codex is at its |
92
+ | limit until Sat 2:11 AM. |
93
+ | |
94
+ | * claude 63% fable week | strip stacks: 4 lines x 24px
95
+ | * codex back Sat 2:11 AM |
96
+ | * agy no figure |
97
+ | * grok 100%, Tue 9:55 AM |
98
+ | [Capacity and models >] |
99
+ +--------------------------------+
100
+ | Terminals |
101
+ | 3 running, 1 waiting on you |
102
+ | +----------------------------+ |
103
+ | | * waiting on you | |
104
+ | | leg on main, dirty 3 | |
105
+ | | claude/fable | |
106
+ | | permission: Bash(git | |
107
+ | | push), asked 40s ago | |
108
+ | | Rework the board layout | |
109
+ | | so the usage gauges... | |
110
+ | | 63% of the fable week | |
111
+ | | 4h 24m 7f3a | |
112
+ | | [Land] [Hand off >] | |
113
+ | | [Details] [End] | |
114
+ | +----------------------------+ |
115
+ ```
116
+
117
+ The strip's tracks are dropped under 760px (the board's one-column breakpoint, `board.css:566`); the percent and the state word carry it. The 2x2 button grid is the shipped shape and survives untouched. `--t--1` is already redefined to the body size inside the narrow breakpoint, so the 14px floor holds by token.
118
+
119
+ ### A.4 Every element, its data, its degradation
120
+
121
+ | # | element | reads | source | when the data is missing |
122
+ |---|---|---|---|---|
123
+ | 1 | Verdict headline | binding bucket across logins, live count, waiting count | `sessionsView().accounts[].buckets` (new), `sessions[]` | see A.5 branch table; there is always a sentence |
124
+ | 2 | Verdict sub | reading age, error direction, other walled logins, or the blocking question | `observed_at`, `limited_until`, `waiting` (new) | drops clauses, never the line; empty `<p>` is hidden today (`.verdict p:empty`) |
125
+ | 3 | Capacity strip token | login name in identity colour, 120x6 track, binding percent, one state word or clock | `accounts[]` | agy: no track, "no figure"; no reading: "no reading"; stale grok: percent plus the observed clock |
126
+ | 4 | `[Capacity and models >]` | opens a `.drawer` holding today's login panels verbatim plus the model rail on each panel head | existing `loginPanel()` moved | nothing to degrade: it is a move |
127
+ | 5 | Model rail (in the drawer) | one chip per model bucket: `fable 63%`, `opus`, `sonnet`, `haiku`; a walled chip says `out until 9:14 PM` | `buckets[]` (percent) and `walls{}` (wording) | a model with no bucket and no wall draws a plain chip with no number; never a guess |
128
+ | 6 | Terminals region head | "3 running, 1 waiting on you, 3 share the claude login, last landed 11:02 PM" | existing head plus a share clause | the share clause appears only when 2+ live rows are on one login (DESIGN.md rule 4) |
129
+ | 7 | Row register: model token | `claude/fable` | `session.model` (new), set at spawn from the resolved `--model`, updated from the transcript's `model` field for claude | agent name alone; never a default |
130
+ | 8 | Row register: `dirty 3, ahead 2` | `files_dirty.length`, one `git rev-list --count base..HEAD` | `src/attach.mjs` poll (dirty already computed near line 388) | not a repo: folder only |
131
+ | 9 | Row register: `quiet 4m` | `last_activity` | existing field | printed in the muted tone for every agent; it is an observation, not a demand |
132
+ | 10 | Waiting-on-you mark and the question | `Notification` hook `permission_prompt`, `idle_prompt`, `agent_needs_input` | new hook, claude only | codex, agy, grok rows never say "waiting on you"; they say `quiet Nm` |
133
+ | 11 | The one sentence | `rankedNotes` with two new ranks (A.6) | existing | unchanged fallback rank 10 |
134
+ | 12 | Capacity phrase on the row | "63% of the fable week" or "about 2h 40m of fable left, from 9 samples over 4h" | `session.capacity` (new, from `binding(u, model)`) | percent only, then nothing |
135
+ | 13 | Buttons | Land, Hand off >, Details, End | unchanged | unchanged |
136
+ | 14 | Hand off picker | destinations `{agent, account, model, available, reason, resets_at, keeps_conversation, cost}` | `handoff_targets` extended | `handoff_availability_known` false prints the existing caveat |
137
+ | 15 | Background region | live cards as rows | `/api/cards` filtered to non-terminal statuses | region hidden when there are none; the one-line entry stays |
138
+ | 16 | One-line task entry | task text; inferred repo, ladder, workflow printed as a sentence | last focused terminal's repo; `preferences.handoff_ladder`; preset `build` | "More settings" opens today's dialog |
139
+ | 17 | Finished cards ledger cell | count by outcome | `/api/cards` terminal statuses | "No finished cards" |
140
+ | 18 | Title badge | `(1) Leg` and a dot on the favicon when any row waits on a human | board state | none |
141
+
142
+ ### A.5 Verdict sentences, exact copy
143
+
144
+ Rule: the headline is at most `VERDICT_CH` characters, measured as the widest sentence that fits two lines of 52px Atkinson at 1120px (set by measurement in F step 2, expected near 56), and every branch of `verdictLines()` is asserted under it by a test. The sub is at most two lines of 17px, at most 120 characters. Precedence top to bottom.
145
+
146
+ | state | headline | sub |
147
+ |---|---|---|
148
+ | a human is blocked (outranks usage: attention is the scarce thing) | `leg#7f3a has waited on you for 3 minutes.` | `It asked to run Bash(git push origin HEAD). The other 2 terminals are still running.` |
149
+ | account-scoped bucket binds (session, weekly_all or spend), same-login model rungs are useless | `claude has 3% left, shared by every model.` | `Switching to opus buys nothing. Next off claude: codex.` |
150
+ | a model bucket is walled, the account window is open | `Fable is out until 9:14 PM; opus is open.` | `claude still has 53% of its week. Hand off > claude/opus keeps this terminal.` |
151
+ | burn rate known (3+ samples over 10+ minutes in one window) | `About 2h 40m of Fable left.` | `From 9 samples over 4h. Opus and Sonnet have their own buckets.` |
152
+ | one login carries every live terminal | `Fable is at 63% of its week, the only login open.` | `Measured 41s ago. 3 terminals run on it, so the real figure is higher, never lower. codex is at its limit until Sat 2:11 AM.` |
153
+ | several logins carry work | `codex has 12% left, and 3 terminals are working.` | `claude is at 63% of the Fable week.` |
154
+ | a model bucket came back and a terminal is still downshifted | `Fable is back; leg#7f3a is still on opus.` | `Leg climbs back at the next hand-off. Back to fable on the row does it now.` |
155
+ | nothing running, something walled | `Nothing is running. codex is back Sat 2:11 AM.` | `Fable is at 63% of its week.` |
156
+ | nothing running, nothing walled | `Nothing is running. Fable is at 63% of its week.` | (empty) |
157
+ | everything walled | `Every login is at its limit; codex is back first.` | `codex returns Sat 2:11 AM.` |
158
+ | no figure anywhere | `Nothing is running, and no login has a figure.` | (empty) |
159
+ | loading | `Reading the logins.` | (empty) |
160
+
161
+ A bucket whose state is unknown is never named. "Measured Ns ago" appears only when `usageIsStale()` is true, as today.
162
+
163
+ ### A.6 The one sentence on a row: new ranks in `rankedNotes`
164
+
165
+ Existing ranks stay character for character. Added:
166
+
167
+ - rank 3 (ties with overlap, sorts before it): `waiting on you: permission to run Bash(git push origin HEAD), asked 40s ago` (from the Notification hook; the message text verbatim, 160 chars max).
168
+ - rank 3: `waiting on you: idle since 11:04 PM` (idle_prompt).
169
+ - rank 8.5: `fable at 63% of its week; Hand off > claude/opus keeps this terminal` when the binding bucket is model-scoped and at or past `WARN_PCT`.
170
+ - rank 8.5: `claude at 97%, shared by every model; next off claude: codex` when the binding bucket is account-scoped.
171
+
172
+ `needsYou()` stays `notes[0].rank <= 4`, so the new rank 3 raises the row, sorts it and counts it in the region head without a second predicate.
173
+
174
+ ### A.7 Region head, Terminals
175
+
176
+ `3 running, 1 waiting on you, 3 share the claude login, last landed 11:02 PM.` The share clause is the only place per-login burn attribution is addressed: it is said once, at the region, and never per row. A per-row figure is per model, which is real; the reader who adds three rows' Fable figures together is stopped by this clause.
177
+
178
+ ---
179
+
180
+ ## B. Model-aware usage and routing
181
+
182
+ ### B.1 Data shape
183
+
184
+ `$LEG_HOME/usage/<agent>--<account>.json` keeps every field it has (that record is the account bucket) and gains three keys. An older Leg ignores them.
185
+
186
+ ```json
187
+ {
188
+ "agent": "claude", "account": "default",
189
+ "five_hour": { "pct": 29, "resets_at": 1789677000 },
190
+ "seven_day": { "pct": 47, "resets_at": 1790190000 },
191
+ "limited_until": null, "limited_reason": null, "limited_at": null,
192
+ "source": "claude usage endpoint", "observed_at": "2026-09-17T20:15:05Z",
193
+
194
+ "buckets": [
195
+ { "kind": "session", "group": "session", "model": null, "percent": 29, "resets_at": 1789677000, "is_active": false, "severity": "normal" },
196
+ { "kind": "weekly_all", "group": "weekly", "model": null, "percent": 47, "resets_at": 1790190000, "is_active": false, "severity": "normal" },
197
+ { "kind": "weekly_scoped", "group": "weekly", "model": "fable", "percent": 63, "resets_at": 1790190000, "is_active": true, "severity": "normal" }
198
+ ],
199
+ "walls": {
200
+ "fable": { "limited_until": 1789694000, "limited_reason": "model_limit", "limited_at": "...", "source": "claude StopFailure", "evidence": "You've reached your Fable limit." }
201
+ },
202
+ "history": { "weekly_scoped:fable": [ { "percent": 61, "at": 1789660000 }, { "percent": 63, "at": 1789676100 } ] },
203
+ "extra_usage": { "enabled": false, "reason": "out_of_credits", "can_toggle": false, "limit_minor": 12500, "used_minor": 0 }
204
+ }
205
+ ```
206
+
207
+ - `buckets[]` is measured (percentages). `walls{}` is attributed from wording (C3's rule table, B.4). They are separate because one is a number and the other is a word, and DESIGN.md rule 5 forbids printing one as the other.
208
+ - `history` is a ring per bucket key, max 24 entries, written only when the percent or the window changed. Never carries entries across a reset (`resets_at` changes clear the ring for that key).
209
+ - `extra_usage` is stored for two sentences in the drawer and nothing else. The eighteen codename keys (`tangelo`, `iguana_necktie`, `nimbus_quill`, ...) are never read.
210
+
211
+ `binding(u, model)`: the highest `percent` among `buckets` with `is_active`; else the bucket whose `model` equals the row's model; else `weekly_all`; else `session`; else the legacy `hottest(u)`. Returns `{ kind, model, percent, resets_at, scope: 'model' | 'account' }` where `scope` is `model` for `weekly_scoped` and `account` for everything else. `scope` is what decides whether a same-login model rung can help.
212
+
213
+ `session.json` gains `model` (string or null, set from the resolved argv, updated from the transcript's per-message `model` for claude so a silent fallback off Fable becomes visible), `capacity` (computed server side, not persisted), and `waiting` (`{ type, message, since }` from the Notification hook, cleared on the next UserPromptSubmit or Stop).
214
+
215
+ ### B.2 Sources per CLI
216
+
217
+ | agent | account windows | per-model bucket | wall signal | model on the row | status |
218
+ |---|---|---|---|---|---|
219
+ | claude | `five_hour`, `seven_day` from `api/oauth/usage` (VERIFIED live) | `limits[]` `weekly_scoped` with `scope.model.display_name` (VERIFIED live, Fable only; Opus and Sonnet rows are `null` today, so their shape when populated is ASSUMED to match) | StopFailure `error: rate_limit` with `last_assistant_message` naming the family (VERIFIED, live fixture) | argv `--model`; transcript `message.model` (VERIFIED in the newest jsonl) | private endpoint, no contract; degrades to the two windows out of the same response |
220
+ | codex | `account/rateLimits/read`, windows by `window_minutes` 300 and 10080 (VERIFIED, `src/taps/codex.mjs`) | none published; `limit_id: "codex"` is a single id (VERIFIED in rollout), `rateLimitsByLimitId` is read at `.codex` only (`codex.mjs:266`) | `task_complete.error` `usage_limit_exceeded`; wording "usage limit for {limit_name}" is docs-only (`docs/cli-contracts.md:464`) | argv `-m`; rollout `session_meta.payload.model` (VERIFIED: `gpt-5.6-sol`, `gpt-6-astra`) | `plan_type: "prolite"` and `credits.balance` are in the rollout (VERIFIED) and can be printed as measured facts |
221
+ | agy | none | none | log regexes `RESOURCE_EXHAUSTED`, `out of quota` (`src/taps/agy.mjs:18-21`) | argv `--model` only | prints "no figure" always; a wall prints its clock while it lasts |
222
+ | grok | `billing?format=credits` proxy, measured not live (`src/taps/grok.mjs:32`) | none | 402 from the proxy, observed once (ROADMAP item 4) | argv `-m` only | prints the percent with its observed clock; stale after 5 minutes like every other reading |
223
+
224
+ ### B.3 The fallback ladder
225
+
226
+ `preferences.json` keeps `handoff_order` (so `validHandoffOrder`, `requireHandoffOrder` and every older terminal keep working) and gains:
227
+
228
+ ```json
229
+ {
230
+ "handoff_order": ["claude", "codex", "agy"],
231
+ "handoff_ladder": [
232
+ { "agent": "claude", "account": "default", "model": "fable", "when": "always", "cost": "credits" },
233
+ { "agent": "claude", "account": "default", "model": "opus", "when": "always", "cost": "plan" },
234
+ { "agent": "claude", "account": "default", "model": "sonnet", "when": "always", "cost": "plan" },
235
+ { "agent": "codex", "account": "default", "model": null, "when": "always", "cost": "plan" },
236
+ { "agent": "agy", "account": "default", "model": null, "when": "always", "cost": "free" },
237
+ { "agent": "grok", "account": "default", "model": null, "when": "walled-only", "cost": "metered" }
238
+ ],
239
+ "climb_back": "next-handoff",
240
+ "may_spend": false,
241
+ "reserve": { "claude": 10 }
242
+ }
243
+ ```
244
+
245
+ Default ladder for a fresh install: `claude/fable`, `claude/opus`, `claude/sonnet`, then each remaining installed agent in `handoff_order` with `model: null`. `normalizeHandoffLadder(prefs)` expands a bare `handoff_order` into one `model: null, when: 'always', cost: 'plan'` rung per agent, so behaviour is bit-identical until the human edits a rung. `cost` for a claude rung is `credits` when `model === 'fable'` and `extra_usage.enabled` is true, else `plan`; agy is `free`; grok is `metered` (its proxy reports credits).
246
+
247
+ `when`: `always`; `below:N` (take the rung only when its account bucket's binding percent is under N; skipped with a ledger line when there is no reading, because a threshold on a login with no figure is a wrong number in disguise); `walled-only` (only when every rung above it is walled, never when they are merely slow).
248
+
249
+ `reserve`: per login, a floor. A rung on `claude` is skipped for automatic hand-offs when its binding percent is above `100 - reserve`, so a background card cannot eat the last 10% Wes wants for his own terminal. A human pressing Hand off > ignores the reserve, and the picker says `past your 10% reserve` on that row rather than hiding it.
250
+
251
+ Eligibility, replacing the body of `eligible()` in `chooseNext()`:
252
+
253
+ ```js
254
+ const eligible = (rung) => {
255
+ if (installed && installed[rung.agent] === false) return false
256
+ if (excluded(rung)) return false
257
+ if (!['free', 'plan'].includes(rung.cost) && !maySpend) return false
258
+ const u = readUsage(rung.agent, rung.account)
259
+ if (!isAvailable(u)) return false // account wall, unchanged
260
+ if (rung.model && wallActive(u.walls?.[rung.model])) return false // model wall
261
+ const b = binding(u, rung.model)
262
+ if (rung.agent === fromAgent && rung.account === fromAccount && b && b.scope === 'account') return false // the wasted switch
263
+ if (!automatic ? false : overReserve(u, rung)) return false
264
+ return whenSatisfied(rung, u)
265
+ }
266
+ ```
267
+
268
+ An unknown model bucket (no percent, no wall) is eligible: the only way to learn a bucket exists is to try it.
269
+
270
+ Ordering: `candidates()` keeps the absolute-priority rule and its comment. Same-agent rungs are simply where the human put them; the default ladder puts the claude models first because that keeps the conversation (B.5).
271
+
272
+ ### B.4 Wall attribution (C3's rule table, in `src/buckets.mjs`)
273
+
274
+ `bucketFromWall(agent, text)` in order:
275
+
276
+ 1. `/(session|weekly) limit/i` → `{ scope: 'account' }`. Docs: shared across models.
277
+ 2. `/You.ve (hit|reached) your (Fable|Opus|Sonnet|Haiku) limit/i` → `{ scope: 'model', model: <lowercased> }`. Covers both observed wordings.
278
+ 3. `/spend limit/i` → `{ scope: 'account', bucket: 'spend' }`.
279
+ 4. codex `/usage limit for ([\w .-]+)/i` → `{ scope: 'model', model: slug }` (docs-only; ASSUMED until a live codex wall is captured into `fixtures/live/codex/`).
280
+ 5. anything else → `{ scope: 'account' }`. Unrecognised wording walls the whole login. This is the direction that fails safe when Anthropic rewords again, which they already did once (`hit` to `reached`).
281
+
282
+ `markLimited()` gains `{ scope, model }`: `model` scope writes `walls[model]` and leaves the top-level `limited_until` alone, so a Fable wall does not stop `claude/sonnet` work; `account` scope behaves exactly as today. A `scope: 'account'` wall whose text Leg could not parse prints, on the row: `claude is out. Leg could not tell which model from "<first 80 chars>", so nothing on claude is offered.` (L2: the verdict carries the evidence it failed on.)
283
+
284
+ `src/limits.mjs` fixture fix, shipped first and alone: `fixtures/limits/claude/claude-model-limit.json` pattern becomes `You.ve (hit|reached) your (?!session |weekly |usage )[A-Za-z0-9.-]+ limit`, and `fixtures/limits/claude/claude-fable-limit.json` is added with `source: observed-live`, `produced_by: fixtures/live/claude/limit-rate_limit.json`. `docs/cli-contracts.md` line 457 changes from docs-only to observed-live.
285
+
286
+ ### B.5 How a switch happens, per CLI
287
+
288
+ The board's Hand off > picker and the automatic path use one mechanism: `runLeg` kills the child, `claimHandoffChoice` picks a rung, `spawnSpec` builds argv, the next process starts in the same terminal. The only new argv per agent:
289
+
290
+ | agent | new-process switch (ships) | keep-the-conversation switch | verdict |
291
+ |---|---|---|---|
292
+ | claude | `--model <alias>`; primed from the bundle as today | `--resume <agent_session_id> --model <alias>` (`agent_session_id` is already on the record; both flags VERIFIED in help; composing them is ASSUMED) | probe first: `claude --resume <id> --model sonnet --print "name your model"`. If it composes and does not re-bill the whole context at Fable rates on an upshift, same-agent rows say "keeps the conversation"; else they take the bundle like every other rung |
293
+ | codex | `-m <model>` | `codex resume <id> -m <model>` (subcommand VERIFIED; composition ASSUMED) | same probe shape; ships as bundle-primed until proven |
294
+ | agy | `--model <model>` | none known | bundle |
295
+ | grok | `-m <model>` | none known | bundle |
296
+
297
+ Rejected, with the reason recorded in PRODUCT.md so "doesn't Claude already do this?" has an answer:
298
+
299
+ - Typing `/model` into the running child: impossible, `stdio: 'inherit'` gives Leg no pipe (`src/attach.mjs:292`); and `/model <name>` "behaves like Enter", which "saves as your default" (model-config 87, 89). A tool that rewrites your default model at 2am is a tool you stop trusting.
300
+ - `--fallback-model`: never fires on a rate-limit error (model-config 287). It is the answer to a 529, not a wall. Leg fills exactly that gap.
301
+ - `autoContinueAtUsageLimit`: Leg already sets it false. If a `quota_auto_resume_fired` Notification ever arrives anyway (the user's own settings can re-enable it), Leg stands down for that terminal and the row says `Claude Code is waiting at the limit itself; Leg is not handing this one off.` Two waiters on one terminal is the failure to avoid.
302
+
303
+ Cost gate: `may_spend` defaults false. A `credits` or `metered` rung is skipped by automatic hand-offs while it is false, and the ledger line says `skipped claude/fable: it spends usage credits and you have not allowed that`. The reason this is load-bearing: an interactive Fable-on-credits request shows a consent prompt (model-config 68); with nobody at the terminal it is held five minutes and then the turn ends (72); in `-p` mode, which is how cards run, it bills without asking (77). Both failure modes are worse than a skipped rung. Today `extra_usage.is_enabled` is false and `can_toggle` is false, so the drawer prints `Usage credits are off, so there is nothing to spend through the wall.` and offers no button: there is no decision to make here from this machine, and pretending otherwise would be a dead control.
304
+
305
+ ### B.6 Board controls
306
+
307
+ - Hand off > picker (existing `<select>`, extended): rows `claude / opus same terminal, keeps the conversation ready`, `claude / sonnet ...`, `codex / default new agent, from the bundle at its usage limit until Sat 2:11 AM`, `claude / fable spends usage credits, off`. A row that buys nothing carries its reason: `claude / opus shares the window that is out, buys nothing`. Index-as-value as today.
308
+ - Ladder editor (replaces the order editor in Settings and in the terminal expansion): numbered rows, each `[dot] agent / model [when v] cost word [up] [down] [remove]`, `[+ Add a rung]` with agent and model selects, the `may_spend` checkbox with its one sentence, the climb-back radio, a reserve number per login. Up and down buttons and `data-focus-key` survive re-renders as they do today. No drag.
309
+ - `Back to fable` button appears on a row when `session.model` is below the top rung and that rung's bucket is known open. It writes `control.json {handoff: true, target: {agent, account, model}}`, which is the existing path plus one key. Its confirm row says `Hands off now. The current turn stops and fable continues from the bundle (or the conversation, once --resume is proven).`
310
+ - Model rail chips in the Capacity drawer: an open chip is a button that sets that rung as this terminal's next; a walled or the current chip is text.
311
+
312
+ ### B.7 Return-to-preferred policy
313
+
314
+ - Never interrupt a running turn to climb. Not configurable: killing a working agent to save budget loses the turn.
315
+ - `next-handoff` (default): `chooseNext` already walks from rung 1 every time, so a reset Fable is picked up at the next hand-off with zero new mechanism.
316
+ - `never`: stay on the lower rung until the human presses `Back to fable`.
317
+ - `when-quiet` (ASSUMED useful, optional, off by default): climb automatically when the bucket is back and the row has been `quiet` for 10 minutes. Recommended not to ship in the first release; it is the one policy that can surprise.
318
+
319
+ ---
320
+
321
+ ## C. Background tasks reborn
322
+
323
+ ### C.1 Mental model
324
+
325
+ A card is a terminal you are not sitting at. It has the same register (state, where, model), the same one sentence, the same 2x2 buttons, the same ladder, and the same hand-off bundle. The differences are honest and printed: it runs `-p --output-format json`, which is mute until the leg exits, so its sentence is `no message until this leg ends`; and it never waits on a permission prompt, because it runs with permissions decided up front.
326
+
327
+ Liveness decides the surface. Live cards (`backlog`, `queued`, `running`, `handing_off`, `needs_approval`, `waiting_human`, `paused`) are rows in a Background panel directly under Terminals. Finished cards (`done`, `failed`, `killed`) fall into one ledger cell: `3 finished cards, 2 done, 1 failed, last 11:02 PM [View]`. Ten finished cards are one row, not eleven.
328
+
329
+ ### C.2 Entry
330
+
331
+ One always-visible line under the Background panel (or under Terminals when there are no live cards):
332
+
333
+ ```
334
+ Run in the background: [ Describe the task ] [Start]
335
+ in leg on main, with claude/opus then codex, build only. More settings
336
+ ```
337
+
338
+ The sentence under the field is inferred and its nouns are buttons: `leg on main` (the repo of the most recently focused terminal, else the last card's repo, else the first terminal's), `claude/opus then codex` (the saved ladder, skipping any rung whose cost is off; a card never starts on a `credits` rung while `may_spend` is false, because `-p` bills without asking), `build only` (the `build` preset). Clicking a noun opens a select in place. `More settings` opens today's dialog verbatim, retitled `More settings`, with the same fields (repo path, first agent, workflow, leases, trunk, merge method, test command, title, approval gate, max turns).
339
+
340
+ Start with an empty task is disabled and says why. Start posts the same body `/api/cards` takes today.
341
+
342
+ ### C.3 The card object (board payload)
343
+
344
+ Unchanged in the store (`src/chain.mjs` states, pipeline, chain, runs, leases). The board row reads:
345
+
346
+ ```
347
+ * waiting on you review station leg on leg/card-3e1c claude/opus (register: state, station, repo/branch, agent/model)
348
+ Add the audit CSV export (title as a button, opens the expansion)
349
+ 4 files, +212 -18, tests green 6m ago (work stat, printed only when measured)
350
+ 38m 3e1c (elapsed, short id)
351
+ [Approve] [Hand off >] [Details] [Kill]
352
+ ```
353
+
354
+ - Work stat: `git diff --shortstat <base>..HEAD` in the card's worktree, run by the server on the SSE tick for live cards only; `tests green 6m ago` from the last `test` station outcome; `land bounced: rebase-conflict` from the land station. Any part not measured is omitted, never estimated.
355
+ - The one sentence: `cardSentence()` unchanged in precedence; the `running` case prints `no message until this leg ends, started 11:04 PM` instead of the last ledger event when the last event is only `leg started`.
356
+ - Buttons: the fixed order stays (Approve, Run, Resume, Pause, Hand off now, Rerun, Reassign, Kill), rendered in the same 2x2 272px grid the terminal rows use (`.r4 .row-actions` already matches). Hand off > on a card opens the same rung picker as a terminal.
357
+ - Chain rail, leases and pipeline move into the expansion; the row carries the model token and the station name instead.
358
+
359
+ ### C.4 Terminal to card, card to terminal
360
+
361
+ - Terminal → card: the End confirm row gains a second verb. `End this terminal? [End] [End, and keep going as a card] [Cancel]`. The second verb writes the bundle (`saveSessionBundle`, the path already used at every hand-off), creates a card whose task is the terminal's prompt plus `continue from the bundle`, whose worktree is the terminal's own worktree when it has one (else a new worktree cut from its branch), whose ladder starts at the terminal's current rung, and whose `lineage.from` is the terminal id. Zero cost to the button grid; it is a verb on a confirm row, not a fifth button. This matches the real moment: "I have to leave, keep going".
362
+ - Card → terminal: `Take over` in the card expansion. It pauses the card (existing `pause`: child killed, bundle written) and prints one copyable command: `leg claude --resume-card 3e1c`, which starts an interactive terminal in the card's worktree primed from the card's bundle, with `lineage.from` the card id. This is the one place a command is handed to the human, because a terminal cannot be opened from a browser tab; the board says so in the line above the command.
363
+
364
+ ### C.5 Digest
365
+
366
+ - Background region head: `1 running, 1 waiting on you` (same predicate as terminals: `needsYou`).
367
+ - Finished ledger cell: `3 finished cards, 2 done, 1 failed, last 11:02 PM [View]` opening the existing drawer filtered to terminal statuses, newest first, each row `done after 3 runs, landed 7f3a2c1` or `failed at station build after 2 runs: <last event>`.
368
+ - The verdict never mentions cards unless one is waiting on a human, in which case the human-blocked branch names it: `card 3e1c has waited on you for 12 minutes.` sub `It is at the review station. Approve or Reassign on its row.`
369
+ - OS and terminal notification (E) fire for `needs_approval` and `waiting_human` the same as for a terminal's permission prompt.
370
+
371
+ ---
372
+
373
+ ## D. Prioritised feature list
374
+
375
+ First release = the five marked (1). Sizes: S under a day, M one to two days, L three or more.
376
+
377
+ | # | feature | pain it solves | size | from | first |
378
+ |---|---|---|---|---|---|
379
+ | 1 | Classifier fix: `(hit\|reached)` plus the observed-live Fable fixture | the only real Claude wall Leg ever captured is classified `no_progress` on the headless path | S | Bucket Ladder | (1) |
380
+ | 2 | Read `limits[]` into `buckets[]`, `binding()`, the strip prints the binding bucket | the board says 47% when the bucket that will stop Wes is at 63% | S | Downshift, Runway | (1) |
381
+ | 3 | Capacity strip plus the `[Capacity and models >]` drawer holding the panels intact; verdict padding and sub size reduced; `VERDICT_CH` test | ~1000px before the terminals; a wrapped 52px headline | M | Downshift, The Sill | (1) |
382
+ | 4 | Destinations become `{agent, account, model}`: ladder in preferences with migration, `--model`/`-m` in `spawnSpec`, picker rows, `session.model` | "switch from fable to opus" is not possible today; a hand-off always means another CLI | M | Downshift, Bucket Ladder | (1) |
383
+ | 5 | Wall attribution by scope (`walls{}`), the wasted-switch rule and sentence, `may_spend` off by default | a Fable wall walls all of claude today; a same-login switch can be offered when it buys nothing; unattended Fable-on-credits stalls or bills silently | M | Bucket Ladder | (1) |
384
+ | 6 | Notification hook: waiting-on-you row mark with the question, rank 3, title badge `(1) Leg` | a terminal parked at a permission prompt looks like a running one; `needsYou` cannot see it | M | Downshift, Annunciator | |
385
+ | 7 | Terminal title via `-n leg#7f3a leg/main` (claude) and OSC 2 before spawn (others); OSC 9 toast through `terminalSequence` | you find out a terminal is stuck by walking over to it | S | Annunciator, Downshift | |
386
+ | 8 | Live cards as rows under Terminals, finished cards to one ledger cell, `no message until this leg ends` | cards are a ledger afterthought; ten done cards are ten rows | M | The work you left running | |
387
+ | 9 | One-line task entry with inferred nouns; dialog demoted to More settings | the 13-field form is why background cards do not get used | M | The work you left running | |
388
+ | 10 | `End, and keep going as a card`; `Take over` with `--resume-card` | "I have to leave" and "let me look at that" have no button | M | The work you left running | |
389
+ | 11 | Row register: `dirty 3, ahead 2`, `quiet 4m`, model token from the transcript | what changed, whether it has gone quiet, and which model actually answered (silent fallback off Fable) are invisible | S | CEILINGS, Downshift | |
390
+ | 12 | `--resume --model` probe and the keeps-the-conversation rows | a downshift today pays a bundle round trip and loses the conversation | S probe, M ship | Downshift | |
391
+ | 13 | Burn rate with sample counts and refusal under 3 samples / 10 minutes, never across a reset | "how long do I have" has no answer; a wrong time at 52px is the one forbidden thing | M | Downshift, Runway | |
392
+ | 14 | Keyboard map: j/k, 1 to 9, h, l, d, e, ?; each is `.click()` on the existing button | the board is mouse-only for a person running four terminals | S | Downshift | |
393
+ | 15 | Reserve floor per login; `below:N` and `walled-only` rung conditions; `Back to fable` and climb-back policy | a card can eat the last 10% Wes wanted; there is no way home after a downshift | M | Standing Orders, Bucket Ladder | |
394
+
395
+ Deliberately not on the list: dollars for subscription sessions (the transcript carries no cost field; codex `credits.balance` may be printed as a measured fact with the word `credits`, never summed with an estimate), phone notifications (needs a relay; local-first says no), agy percentages (none exist), per-terminal attribution of a shared login (nothing publishes it).
396
+
397
+ ---
398
+
399
+ ## E. Rules, policies and notifications
400
+
401
+ Rules that ship (each is a toggle or a sentence on the board, never a JSON edit):
402
+
403
+ 1. Ladder rungs with `when` and `cost`; `may_spend` off; `reserve` per login; `climb_back` next-handoff or never. All in Settings and in the terminal expansion (the ladder editor).
404
+ 2. Never interrupt a running turn: not a toggle, a stated rule printed under the climb-back radio.
405
+ 3. Never hand off mid-tool: the hand-off waits for `cleanBoundary` from the runtime signals (`src/taps/mod.mjs` `deriveSignals`, `cleanBoundary` field VERIFIED to exist; whether `attach.mjs` consumes it is ASSUMED not, per CEILINGS; verify with `grep -n "taps/mod" src/attach.mjs`) with a 90 second ceiling, so a bundle never describes a mid-tool instant.
406
+ 4. Unrecognised wall wording walls the whole login and says so with the evidence string.
407
+ 5. A stale reading prints its age and the direction of its error; a reading older than the window it describes prints the percent with the clock it was taken at, as grok does today.
408
+ 6. Forecast discipline: every time figure carries `from N samples over T`; under 3 samples spanning 10 minutes, the row prints the percent and the reset clock and no time; never extrapolate across a reset.
409
+ 7. Money labelling, if a figure ever ships: `measured` (a CLI's own number), `list-price equivalent`, `estimated`; never summed; `charged` only where money moved.
410
+
411
+ Notifications, three toggles in Settings, all off by default except the title badge:
412
+
413
+ | surface | mechanism | fires on | gate |
414
+ |---|---|---|---|
415
+ | tab title badge `(1) Leg` and favicon dot | `document.title` | any row or card `needsYou` | always on; no permission |
416
+ | OS toast from the board | `new Notification()` | transitions into needsYou | Settings toggle plus `window.isSecureContext` read at runtime; the toggle prints `This page is not a secure context. Open the board at http://localhost:4747 to turn toasts on.` when false (ASSUMED that `127.0.0.1` is not treated as secure by every browser; the runtime read settles it per browser) |
417
+ | terminal toast, claude only | Notification hook returns `terminalSequence` OSC 9 | `permission_prompt`, `idle_prompt`, `agent_needs_input` | Settings toggle; Windows Terminal renders OSC 9 (hooks doc 617) |
418
+ | terminal tab title | `-n` for claude (VERIFIED flag); OSC 2 written to stdout before spawn for codex, agy, grok (ASSUMED a VT terminal keeps it once the child starts drawing; codex may overwrite it) | at spawn; refreshed by the StopFailure `terminalSequence`, which still fires (hooks doc 559) | on by default; the string is `leg#7f3a leg/main`, and on the StopFailure path `leg#7f3a at its limit` |
419
+
420
+ The Notification hook's `permission_prompt` fires only after about six seconds and, per the docs, mainly when the user appears to be away, so "waiting on you" may arrive late when Wes is typing in that window. That is acceptable and must not be sold as instant.
421
+
422
+ ---
423
+
424
+ ## F. Implementation plan
425
+
426
+ Prerequisite (S): `npm i -D playwright` and `npx playwright install chromium`; seed with `node scripts/seed-wes-board.mjs`, serve on a spare port with `LEG_TRUST=never`, never 4747; run `node scripts/board-shots.mjs <port> baseline` and keep the baseline numbers. Without this, no step below can claim a pixel.
427
+
428
+ Step 0, the classifier (S, ships alone).
429
+ Files: `fixtures/limits/claude/claude-model-limit.json` (pattern), new `fixtures/limits/claude/claude-fable-limit.json`, `docs/cli-contracts.md` row 457, `test/limits.test.mjs` (the live string must classify `limit` with signal `claude-fable-limit`; the docs strings still classify `limit`; "You've reached your weekly limit" classifies `claude-weekly-limit`, not model).
430
+ Verify: `npm test -- test/limits.test.mjs` and the one-liner from the verification section returning `limit`.
431
+
432
+ Step 1, the data (S, ships alone, nothing visible changes).
433
+ Files: `src/taps/claude-usage.mjs` (parse `j.limits` into `buckets`, `j.extra_usage` into the small record; keep the existing return shape), `src/usage.mjs` (persist `buckets`, `walls`, `history`; `binding()`, `wallActive()`; `markLimited({scope, model})`), new `src/buckets.mjs` (`bucketFromWall`, alias lists per agent, `modelFlagFor(agent)`), `src/taps/claude.mjs` (StopFailure calls `bucketFromWall`), `src/taps/codex.mjs` (read `session_meta.payload.model`, `plan_type`, `credits.balance` into the record as measured facts), `src/server.mjs` (`accounts[].buckets`, `accounts[].walls`, `sessions[].capacity`; guests get none of it, extend the `ACCOUNT_FIELDS` allowlist test in `test/share-security.test.mjs`), new `fixtures/live/claude/usage-oauth.json` (the shape above, scrubbed; the first recorded shape of an undocumented endpoint).
434
+ Verify: new `test/buckets.test.mjs` (three scopes, the fallback to account scope, `binding()` preferring `is_active`, a payload with no `limits` key falling back to the two windows); `test/share-security.test.mjs` extended; a live `node -e` through `fetchClaudeUsage` printing `buckets.length === 3`.
435
+
436
+ Step 2, the strip and the verdict (M).
437
+ Files: `src/board/sessions.js` (`capacityStrip()`, `renderAccounts` moves `loginPanel()` calls into a `.drawer` behind a disclosure button, `verdictLines` branches from A.5, `modelRail()` on the panel head), `src/board/index.html` (the disclosure button and drawer under `#accounts`), `src/board/board.css` (`.capacity-strip`, `.cap-token`, `.cap-track` 120x6, `.verdict { padding-block: 40px 24px }`, `.verdict p { font-size: var(--t-1) }`, `.section-head { padding-block: 32px 16px }`, strip stacking under 760px), `site/` board recreation (DESIGN.md says it changes in the same pass), `docs/board-guide.md` verdict and logins sections.
438
+ Verify: `test/board-updates.test.mjs` (or the sibling that drives `verdictLines`) gains a `VERDICT_CH` assertion over every branch with fixture inputs; `node scripts/board-shots.mjs <port> step2` against the seeded board reports full-page height, height to the first `.term`, no horizontal overflow at 400, font floor 14, zero console errors; the number to beat is the baseline minus at least 500px to the first row.
439
+
440
+ Step 3, the row (M).
441
+ Files: `src/sessions.mjs` (`model`, `waiting` fields), `src/attach.mjs` (set `model` at spawn; `ahead` count in the git poll; refresh `model` from the transcript for claude), `src/board/sessions.js` (register tokens, capacity phrase, `quiet`, ranks 3 and 8.5, the region head share clause, keyboard map, title badge), `src/board/board.css` (register chips), `docs/board-guide.md` terminal row section.
442
+ Verify: seeded board shot `step3` with per-row height compared with `step2` (the register must not add a line at 1280); `test/session-detail.test.mjs` for the new fields; an a11y pass in `test/board-a11y.test.mjs` for the keyboard map (`?` opens a map whose every entry names a real button).
443
+
444
+ Step 4, the ladder (M to L, the one persisted-shape change).
445
+ Files: `src/preferences.mjs` (`handoff_ladder`, `normalizeHandoffLadder`, `requireHandoffLadder`, `may_spend`, `climb_back`, `reserve`; `handoff_order` kept and derived from the ladder's agent order on write so `validHandoffOrder` stays true), `src/usage.mjs` (`candidates()` and `chooseNext()` walk rungs; the eligibility body from B.3; `preferred_taken` semantics unchanged), `src/attach.mjs` (`spawnSpec` pushes the model flag from `src/buckets.mjs` `modelFlagFor`; `claimHandoffChoice` passes the rung; `ctl.target.model` accepted), `src/server.mjs` (`handoff_targets` rows per rung with `keeps_conversation`, `cost`, `reason`; `PATCH /api/settings` accepts the ladder; per-session ladder route), `src/board/sessions.js` (picker rows, ladder editor, `Back to fable`), `bin/leg.mjs` (`leg sessions handoff <id> --to claude/default/opus`), `docs/concepts.md` handoff section, `PRODUCT.md` one paragraph on `--fallback-model` and `/model`.
446
+ Verify: `test/handoff-order.test.mjs` and `test/handoff-picker.test.mjs` extended (migration is bit-identical for a three- and a four-agent order; a Fable wall with `scope: model` leaves `claude/opus` eligible; an account wall makes every claude rung ineligible with the wasted-switch reason; `may_spend` false skips a `credits` rung with the ledger line); end to end with `leg sessions simulate-limit` carrying the Fable wording, asserting the next process in the same terminal is `claude --model opus` (`test/simulate-limit.test.mjs` pattern).
447
+
448
+ Step 5, attention and titles (M).
449
+ Files: `src/taps/claude.mjs` (`Notification` hook with matchers, returning `terminalSequence`; `quota_auto_resume_fired` handling), `src/hook.mjs` (route `claude-notify`), `src/attach.mjs` (`-n` for claude, OSC 2 for others), `src/board/sessions.js` (toast toggle with the secure-context sentence), Settings in `index.html`.
450
+ Verify: regression test first: a fixture Notification payload through `src/hook.mjs` sets `waiting` and must leave a `limit` set by a concurrent StopFailure alone (the reducer hazard the statusline handler documents); a live probe in a spare terminal: start `leg claude --model haiku` in a scratch repo, run a tool that prompts, wait six seconds, confirm the row says waiting on you and Windows Terminal shows the toast.
451
+
452
+ Step 6, cards (L).
453
+ Files: `src/board/board.js` (liveness split, row register, work stat, one-line entry with inferred nouns, `More settings` retitle, `no message until this leg ends`), `src/board/index.html` (Background section under Terminals, entry line, finished ledger cell), `src/board/board.css` (`.row` takes the `.term` shape), `src/server.mjs` (work stat on the cards SSE tick for live cards; `POST /api/sessions/:id/end-as-card`; `--resume-card` support in `bin/leg.mjs` and `src/attach.mjs`), `src/board/sessions.js` (the second verb on the End confirm row), `docs/board-guide.md` background sections, `docs/concepts.md`.
454
+ Verify: `node scripts/seed-fake-cards.mjs` plus the wes seed, shot `step6`: a board with 10 finished cards renders one ledger row; `test/cards.test.mjs` for end-as-card (bundle written, card created with the terminal's worktree and lineage); `test/e2e-fake.test.mjs` for `--resume-card`.
455
+
456
+ Step 7, forecast and reserve (M, last, because it is the riskiest print).
457
+ Files: `src/usage.mjs` (`history` ring, `burn()` with the sample gate), `src/board/sessions.js` (the time phrase with its sample count), the reserve in `chooseNext`.
458
+ Verify: `test/usage.test.mjs` cases: two samples print nothing; three over nine minutes print nothing; three over eleven minutes print a time with `from 3 samples over 11m`; a reset between samples clears the ring; the reserve skips an automatic rung and not a human pick.
459
+
460
+ ---
461
+
462
+ ## G. Open questions for Wes
463
+
464
+ 1. Should a same-agent downshift try `--resume <id> --model <alias>` (keeps the conversation, cost of an upshift unproven) or always take the bundle? Recommended: probe once in step 4 with `--print`; if it composes and the downshift is cheap, use it for downshifts only, and let upshifts (opus to fable) take the bundle until the re-read cost is measured.
465
+ 2. Default ladder order: `fable, opus, sonnet, codex, agy, grok` as written, or codex before sonnet (a different CLI before a weaker model)? Recommended: models first, because it keeps the conversation and the repo; move codex up per terminal when the task is one where Sonnet is not enough.
466
+ 3. `may_spend`: default off is the safe reading, and today `extra_usage` is off and cannot be toggled from the API anyway. Ship the checkbox now (it will do nothing until credits are enabled at claude.ai) or hide it until they are? Recommended: ship it, with the drawer sentence saying credits are off, so the rule exists before the first day it matters.
467
+ 4. Should `End, and keep going as a card` reuse the terminal's own worktree (the card continues exactly where the terminal stopped, but the terminal cannot be reopened there until the card ends) or cut a fresh worktree from its branch? Recommended: reuse, with `Take over` as the way back; two worktrees on one branch is the conflict machine the roadmap already rejects.
468
+ 5. Notification toasts: OS toast from the board, terminal toast via OSC 9, or both, and which is on by default? Recommended: terminal toast on by default (it reaches the window Wes is in with no browser), board toast off until turned on, title badge always.
469
+
470
+ ## Deviations from the winning candidate
471
+
472
+ - agy is printed as `no figure`, not `walled 71h`: its recorded wall expired 2026-09-16 19:52Z and `isAvailable()` already treats it as open.
473
+ - The live numbers are 63 / 47 / 29, not 62 / 46 / 23 (the endpoint moved between the tournament's probe and mine).
474
+ - The verdict keeps 52px but takes The Sill's character budget and a 17px sub, because the winner's own sentences would wrap to three lines at `max-width: 26ch`.
475
+ - The model rail (Bucket Ladder) lives inside the Capacity drawer on each panel head, not in the strip; the strip prints only the binding bucket.
476
+ - Burn rate is step 7, last, with Runway's sample-count gate, rather than part of the strip.
477
+ - Cards are in scope (steps 6) per every judge; `Take over` is the one place a command is handed to the human, and the board says why.
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "claude-fable-limit",
3
+ "adapter": "claude",
4
+ "source": "observed-live",
5
+ "produced_by": "fixtures/live/claude/limit-rate_limit.json: StopFailure error rate_limit, last_assistant_message on the real run, 2026-09-11 (a per-model wall: the account's session and weekly windows were open)",
6
+ "where": "any",
7
+ "text": "You've reached your Fable limit. Run /usage-credits to continue or switch models with /model.",
8
+ "pattern": "You.ve reached your Fable limit",
9
+ "exit_code": null,
10
+ "classification": "limit"
11
+ }
@@ -5,7 +5,7 @@
5
5
  "produced_by": "https://code.claude.com/docs/en/costs (\"You’ve hit your Opus limit\" / \"You’ve hit your Sonnet limit\")",
6
6
  "where": "any",
7
7
  "text": "You’ve hit your Opus limit",
8
- "pattern": "You.ve hit your (?!session |weekly |usage )[A-Za-z0-9.-]+ limit",
8
+ "pattern": "You.ve (hit|reached) your (?!session |weekly |usage )[A-Za-z0-9.-]+ limit",
9
9
  "exit_code": null,
10
10
  "classification": "limit"
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "produced_by": "https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)",
6
6
  "where": "any",
7
7
  "text": "You’ve hit your session limit",
8
- "pattern": "You.ve hit your session limit",
8
+ "pattern": "You.ve (hit|reached) your session limit",
9
9
  "exit_code": null,
10
10
  "classification": "limit"
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "produced_by": "https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)",
6
6
  "where": "any",
7
7
  "text": "You’ve hit your weekly limit",
8
- "pattern": "You.ve hit your weekly limit",
8
+ "pattern": "You.ve (hit|reached) your weekly limit",
9
9
  "exit_code": null,
10
10
  "classification": "limit"
11
11
  }
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "grok-balance-exhausted",
3
+ "adapter": "grok",
4
+ "source": "observed-live",
5
+ "produced_by": "grok 1.0.34 -p --output-format json on an account with no balance left, 2026-09-17: stdout {\"type\":\"error\",\"message\":\"Internal error: {\n \\\"message\\\": \\\"API error (status 402 Payment Required): Grok Build usage balance exhausted\\\",\n \\\"http_status\\\": 402\n}\"}, exit 1",
6
+ "where": "any",
7
+ "text": "API error (status 402 Payment Required): Grok Build usage balance exhausted",
8
+ "pattern": "usage balance exhausted|status 402 Payment Required",
9
+ "exit_code": null,
10
+ "classification": "limit"
11
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "agent": "claude",
3
+ "signal": "resume-with-model",
4
+ "captured_at": "2026-09-17T21:40:00Z",
5
+ "source": "observed-live",
6
+ "claude_version": "2.1.274",
7
+ "probe": [
8
+ "echo 'reply with exactly: ok' | claude -p --model sonnet --output-format json --max-turns 1",
9
+ "echo 'what did I ask you to reply with before? answer in 5 words' | claude -p --resume <session_id> --model haiku --output-format json --max-turns 1"
10
+ ],
11
+ "observed": {
12
+ "same_session_id": true,
13
+ "conversation_kept": true,
14
+ "second_turn_result": "To reply with exactly ok.",
15
+ "second_turn_modelUsage_keys": ["claude-haiku-4-5-20251001"],
16
+ "second_turn_cacheReadInputTokens": 0,
17
+ "second_turn_is_error": false
18
+ },
19
+ "conclusion": "`--resume <id> --model <alias>` composes: the session id is kept, the prior turn is remembered, and only the new model answers. The resumed context is re-read at the new model's rates (cache read 0 on the first resumed turn), so a downshift keeps the conversation and an upshift pays the re-read at the higher price. Settles spec G1 (docs/redesign-2026-09-17.md)."
20
+ }