@ucsandman/legcli 0.11.0 → 0.13.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 (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
package/docs/faq.md CHANGED
@@ -57,9 +57,25 @@ same summary). The short version: owning two paid subscriptions is not named
57
57
  as prohibited by Anthropic or OpenAI, but rotating to a second account of the
58
58
  same vendor because the first is rate-limited sits close to OpenAI's
59
59
  "circumvent any rate limits" wording and Anthropic's "circumvent product
60
- guardrails". Leg's default chain switches vendors (claude, codex, agy),
61
- which is plainly fine. Same-vendor rotation only happens after you run
62
- `leg accounts add`.
60
+ guardrails". Leg's default fallback ladder switches vendors (claude, codex,
61
+ agy) only after it has already tried claude's own weaker models (fable, opus,
62
+ sonnet), which is plainly fine. Same-vendor rotation only happens after you
63
+ run `leg accounts add`; switching models on one login, claude/fable to
64
+ claude/opus, is not a second account at all and none of this applies to it.
65
+
66
+ **Can I switch models mid-session, or does a hand-off always mean a different CLI?**
67
+ Yes. `handoff_ladder` in `preferences.json` names an agent, account and model
68
+ per rung (`src/preferences.mjs`), and a fresh install tries claude/fable, then
69
+ claude/opus, then claude/sonnet before it ever leaves the claude login, then
70
+ the other agents. A downshift to a weaker claude model with a known session id
71
+ runs `claude --resume <id> --model <alias>` and keeps the conversation; every
72
+ other rung takes the bundle as today. `may_spend` (default `false`) keeps an
73
+ automatic hand-off off any rung whose live cost is `credits` or `metered`; a
74
+ hand-off you press yourself can still take it. `climb_back` decides whether
75
+ Leg returns to the top rung at the next hand-off (`next-handoff`, the default)
76
+ or waits for you to press **Back to fable** (`never`). Edit the ladder with
77
+ `leg ladder` or in Settings; see
78
+ [configuration.md](configuration.md#the-hand-off-ladder).
63
79
 
64
80
  **What does `leg uninstall` remove?**
65
81
  `~/.leg` and nothing else: sessions, usage files, the extra account
@@ -74,7 +90,7 @@ package gone too.
74
90
  Yes. A parent Claude Code session exports `CLAUDECODE` and `CLAUDE_CODE_*`
75
91
  markers that make a nested Claude refuse to start; `sanitizeEnv`
76
92
  (`src/env.mjs`) strips them along with the API-key variables, so the child
77
- starts normally. It becomes its own session with its own card on the board,
93
+ starts normally. It becomes its own session with its own row on the board,
78
94
  unrelated to the parent's.
79
95
 
80
96
  **Why are `--dangerously-skip-permissions` and similar flags never
@@ -118,7 +134,7 @@ and nothing a terminal has said, read or written: no prompt, no file names, no
118
134
  paths, no bundle, no events, no logs, and none of the pipeline side. The
119
135
  limit line keeps only the reason and the reset time, never the raw limit
120
136
  text. The one thing they can do on your terminal is ask for a hand-off, which
121
- you approve or dismiss on the card. `leg share off` ends it and every link
137
+ you approve or dismiss on the row. `leg share off` ends it and every link
122
138
  stops working. There is no TLS, so keep it on Tailscale or a network you trust.
123
139
 
124
140
  **What if the CLI I want isn't installed?**
@@ -62,10 +62,12 @@ Claude print session. The subscription login is always what runs. Nothing in
62
62
 
63
63
  ## 4. What you get while it runs
64
64
 
65
- - A card on the board for this terminal: agent, account, repo@branch, the first
66
- prompt, turns, the files being touched, 5h and 7d usage.
67
- - A warning at 85 % of either window (`LEG_WARN_PCT`): amber card, an event,
68
- one terminal bell.
65
+ - A row on the board for this terminal: status, repo@branch, dirty/ahead
66
+ counts, agent/model, the first prompt, turns, the files being touched, and
67
+ the capacity phrase for whichever bucket actually binds (a percentage, or a
68
+ forecast like `about 2h 40m of fable left` once there are enough readings).
69
+ - A warning at 85 % of either window (`LEG_WARN_PCT`): the row goes amber, an
70
+ event, one terminal bell.
69
71
  - For Codex, the board and active attach poll the read-only app-server
70
72
  `account/rateLimits/read` response every 60 seconds. Leg maps the returned
71
73
  300- and 10080-minute durations to 5h and 7d, shows `<n>% used`, and labels
@@ -77,13 +79,15 @@ Claude print session. The subscription login is always what runs. Nothing in
77
79
  section before the raw bundle dump.
78
80
  - At the limit: the bundle is saved, the agent is stopped, and the next option
79
81
  starts in the same terminal from `.leg/RESUME-<session-id>.md`, copied to
80
- `.leg/RESUME.md`. Order is other logins of
81
- the same agent first, then the remaining agents (claude, codex, agy). When
82
- every option is out, Leg prints each reset time, soonest first, waits with
83
- a countdown, and starts the first one back from the bundle. Ctrl-C quits.
82
+ `.leg/RESUME.md`. The order is a ladder of rungs, each an agent, account and
83
+ model: a fresh install goes claude/fable, then claude/opus, then
84
+ claude/sonnet before it ever leaves the claude login, then the remaining
85
+ agents (codex, agy). `leg ladder` (or Settings) edits it. When every option
86
+ is out, Leg prints each reset time, soonest first, waits with a countdown,
87
+ and starts the first one back from the bundle. Ctrl-C quits.
84
88
 
85
- Force a hand-off at any time with the **Hand off now** button on the card, or
86
- `leg sessions handoff <id>`.
89
+ Force a hand-off at any time with the **Hand off now** button on the row, or
90
+ `leg sessions handoff <id> [--to <agent>[/<account>[/<model>]]]`.
87
91
 
88
92
  ## Is the resume file still true?
89
93
 
@@ -95,7 +99,7 @@ leg resume # the same verdict, then the pointer itself
95
99
  Leg stamps every resume file with the commit, the working tree and the
96
100
  terminals it was written against, and recomputes freshness from git when you
97
101
  read it. A commit landing, the tree moving, or the terminal it described going
98
- away all make it stale; the terminal card's drawer shows the same verdict under
102
+ away all make it stale; the terminal's expansion shows the same verdict under
99
103
  "What happens next". A session ending rewrites `RESUME.md` to say nothing is in
100
104
  flight, and the board does the same at start for a terminal that crashed, so
101
105
  nothing is left describing work that has moved on.
@@ -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