@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
@@ -45,6 +45,7 @@ Source: the `appendEvent`/`updateSession` call sites in `src/attach.mjs`,
45
45
  | `limit` | a usage limit was detected; carries the agent's own wording, `(simulated)` when `leg sessions simulate-limit` produced it |
46
46
  | `handoff_requested` | someone pressed Hand off now, ran `leg sessions handoff`, or (on a shared board) asked for or approved a hand-off; `by` names the human (a dismissed request logs as `status` instead) |
47
47
  | `handoff` | the switch happened: from, to, reason, bundle id |
48
+ | `waiting` | a claude `Notification` said a human is being waited on: the type (`permission_prompt`, `idle_prompt`, `agent_needs_input`) and the question verbatim; the next prompt or the end of the turn clears it |
48
49
  | `all_out` | every option is walled; the resets are printed and the terminal waits for the first one (`ended` with "quit while waiting" if Ctrl-C or End cuts the wait short, exit 3) |
49
50
  | `agent_exit` | the agent process exited, with its code |
50
51
  | `continued` | the session was started by `leg history continue` on a conversation the agent's own store holds; `session.json` carries `continued_from: { id, provider, native_id }` |
@@ -61,6 +62,32 @@ Source: the `appendEvent`/`updateSession` call sites in `src/attach.mjs`,
61
62
  | `bounced` | the landing stopped with a [bounce reason](#bounce-reasons-land-station); the full detail is in `body` |
62
63
  | `land_noop` | Land found nothing on the branch beyond its base |
63
64
 
65
+ ## Routing and capacity words
66
+
67
+ The words the ladder, the usage record and the board top use. Source:
68
+ `src/preferences.mjs` (`RUNG_COSTS`, `WHEN_RE`, `defaultLadder`),
69
+ `src/usage.mjs` (`binding`, `burn`, `emptyRecord`), `src/buckets.mjs`
70
+ (`bucketFromWall`) and `src/board/sessions.js`.
71
+
72
+ | word | meaning |
73
+ |------|---------|
74
+ | rung | one destination on the ladder: `{agent, account, model, when, cost}`. `leg ladder` writes `claude/default/opus`; a rung with `model: null` means "whatever that CLI's default is", never a guess |
75
+ | ladder | the ordered list of rungs a terminal falls down, `preferences.handoff_ladder`. `handoff_order` is its older, narrower form and is derived from it, so both stay true |
76
+ | `when` | when a rung may be taken: `always`, `below:N` (only while that login's binding percent is under N), or `walled-only` (only when every rung above it is walled, never when they are merely slow) |
77
+ | `cost` | what a rung spends: `free`, `plan`, `credits` or `metered`. Computed at the moment of choosing, never trusted from disk, because `credits` depends on whether the login has extra usage enabled right now |
78
+ | `may_spend` | off by default. While it is off, an automatic hand-off skips any `credits` or `metered` rung and says so in the ledger. A hand-off a human presses is not automatic and is not gated |
79
+ | `reserve` | a per-login floor, `{agent: percent}`. An automatic hand-off skips a rung past the floor so a background card cannot eat what you kept for yourself; a human pick still takes it and the picker says `past your N% reserve` |
80
+ | `climb_back` | `next-handoff` (the default: the ladder is walked from rung 1 every time, so a reset model is picked up at the next hand-off) or `never` (stay put until **Back to fable**). Leg never interrupts a running turn to climb |
81
+ | bucket | one measured window in a usage record's `buckets[]`: `kind` (`session`, `weekly_all`, `weekly_scoped`), `model` (set only on `weekly_scoped`), `percent`, `resets_at`, `is_active`. A percentage, always measured, never inferred from wording |
82
+ | binding bucket | the bucket that will stop this login first, what `binding(u, model)` returns. The strip, the verdict and the row all print this one rather than whichever window happened to be stored |
83
+ | wall scope | how far a wall reaches, from `bucketFromWall`. `model` walls one model family and writes `walls[model]`, leaving the login open; `account` walls the whole login through `limited_until`. Wording Leg cannot parse is `account`, and the row prints the evidence it failed on |
84
+ | capacity | the phrase a row prints for its own binding bucket: `63% of the fable week`, or nothing when there is no reading. Computed per request, never persisted |
85
+ | forecast | a time figure from `burn()`, and only ever with its sample count: `about 2h 40m of Fable left, from 9 samples over 4h`. Under three samples spanning ten minutes, or on a flat or falling line, or across a reset, it prints nothing at all |
86
+ | waiting on you | a row's status word when `session.waiting` is set by a claude `Notification` hook. It sorts the row first, counts in the Terminals head and puts `(N) Leg` with a dotted favicon in the browser tab. Only claude publishes this signal; other agents say `quiet Nm` and never claim to be waiting |
87
+ | Background | the panel of live cards directly under Terminals. Liveness decides the surface, not kind: `backlog`, `queued`, `running`, `handing_off`, `needs_approval`, `waiting_human` and `paused` are rows here, and `done`, `failed` and `killed` collapse into one ledger line |
88
+ | end as a card | the second verb on a terminal's End confirm row (`POST /api/sessions/:id/end-as-card`): writes the bundle, hands the terminal's own worktree to a card starting at the terminal's current rung, and records the terminal as its `lineage.from` |
89
+ | take over | the reverse, in a card's expansion (`POST /api/cards/:id/take-over`): pauses the card and prints `leg claude --resume-card <id>`, the one command a browser cannot run for you |
90
+
64
91
  ## Harness states (terminal cards, drawer, `leg harness`)
65
92
 
66
93
  Source: `STATES` in `src/harness/index.mjs`; recorded on `session.harness.state`.
package/docs/adapters.md CHANGED
@@ -239,25 +239,107 @@ These are what a pipeline card's chain spawns. Unchanged since 0.2.0.
239
239
  `emulates` to that CLI's name, so the limit classifier applies that CLI's own
240
240
  fixtures to it and a demo chain reads like a real fallback.
241
241
 
242
- ### grok (built, not registered)
242
+ ### grok (headless)
243
243
 
244
244
  - **Binary**: `LEG_GROK_BIN`, else `~/.grok/bin/grok.exe`, else `grok` on
245
245
  PATH.
246
- - **Argv**: `grok -p "<prompt>" --output-format json --permission-mode
247
- <mode>`, plus `-m <model>`, `-r <session-id>`.
246
+ - **Argv**: `grok --prompt-file <run>/prompt.txt --output-format json
247
+ --permission-mode <mode> --cwd <worktree>`, plus `--max-turns <n>`,
248
+ `-m <model>`, `-r <session-id>`. Every flag was read from `grok --help` on
249
+ grok 1.0.34 (`3736acbc8658`) on 2026-09-17. The prompt travels by file
250
+ rather than on argv because a hand-off prompt carries the whole bundle
251
+ summary and Windows caps one command line at about 32k; with no prompt file
252
+ the adapter falls back to `-p "<prompt>"`. `--cwd` is passed explicitly
253
+ rather than relying on the spawn's working directory: grok can run against a
254
+ shared leader process (`~/.grok/leader.sock`), and a leg must edit its own
255
+ worktree, not whatever directory the leader started in.
248
256
  - **Stdin**: `ignore`.
249
257
  - **Modes**: default `acceptEdits`; allowed `default`, `acceptEdits`,
250
258
  `auto`, `dontAsk`, `plan`.
251
259
  - **Forbidden flags**: `--always-approve`, `bypassPermissions`,
252
260
  `--permission-mode=bypassPermissions`.
253
- - **Status**: `src/adapters/grok.mjs` exists and is unit-tested, but is
254
- **not** in `src/adapters/index.mjs`'s registry. The build machine had no
255
- `grok` login: the probe printed a device-code prompt and exited
256
- `Cancelled`. Register it (add an entry to `REGISTRY` in
257
- `src/adapters/index.mjs`) once `grok login` has been completed and
258
- `node scripts/probe.mjs --adapter grok --repo <toy-repo>` passes.
259
-
260
- ## How to add an adapter
261
+ - **Result**: grok's headless writer emits the Claude Code result envelope.
262
+ The field names (`"type":"result"`, `subtype`, `is_error`, `session_id`,
263
+ `result`, `num_turns`, `stop_reason`, `total_cost`) were read out of the
264
+ shipped `grok.exe` on 2026-09-17. An error is the other envelope,
265
+ `{"type":"error","message":…}`, observed live the same day.
266
+ - **Status**: registered on 2026-09-17. The probe reached the account and came
267
+ back with a real wall — `API error (status 402 Payment Required): Grok Build
268
+ usage balance exhausted`, exit 1, classified `limit` and handed off — so the
269
+ spawn, the argv, the auth and the wall path are all verified live
270
+ (`fixtures/live/grok/`). The **success** path of a grok leg is still
271
+ unprobed: that needs balance on the account. Until it is, a grok leg whose
272
+ envelope does not parse falls back to the `.leg/DONE` marker and the diff,
273
+ which is what every adapter does when `parseResult` returns null. Re-run
274
+ `node scripts/probe.mjs --adapter grok --repo <toy-repo>` with balance to
275
+ close it.
276
+
277
+ ## Custom adapters
278
+
279
+ Any other coding-agent CLI becomes a card adapter through a JSON spec in
280
+ `$LEG_HOME/adapters/<name>.json`, with no code in this package. This is the
281
+ open end of the chain: claude, codex, agy and grok ship with taps and a probe,
282
+ and anything else joins as "argv in, JSON out".
283
+
284
+ A custom adapter runs **cards**: headless, in a worktree, handing off like any
285
+ other leg. It is not an interactive `leg <agent>` terminal, because that needs
286
+ a usage tap and a wall signal, which only the four built-ins expose.
287
+
288
+ ```
289
+ leg adapter template --name muse > muse.json # a starter spec
290
+ leg adapter add muse.json # validate and install it
291
+ leg adapter check muse # the exact command a leg would run
292
+ leg card add --repo <path> --task "<t>" --chain muse,claude --queue
293
+ leg adapter list | show muse | rm muse
294
+ ```
295
+
296
+ The spec:
297
+
298
+ ```json
299
+ {
300
+ "name": "muse",
301
+ "bin": "muse",
302
+ "stdin": "ignore",
303
+ "args": ["run", "--json",
304
+ ["--dir", "{{cwd}}"],
305
+ ["--model", "{{model}}"],
306
+ "--prompt-file", "{{promptFile}}"],
307
+ "modes": { "default": "auto", "allowed": ["auto", "readonly"] },
308
+ "forbiddenFlags": ["--unsafe"],
309
+ "result": { "format": "json", "sessionId": "session_id",
310
+ "message": "result", "stopReason": "stop_reason" }
311
+ }
312
+ ```
313
+
314
+ - **`bin`** is argv[0]. It is spawned directly, never through a shell, so it
315
+ cannot contain `< > | & ;`. `LEG_<NAME>_BIN` overrides it. A `.mjs`, `.cjs`
316
+ or `.js` path runs under this Node.
317
+ - **`args`** is a list of strings and groups. Placeholders: `{{prompt}}`,
318
+ `{{promptFile}}`, `{{cwd}}`, `{{mode}}`, `{{model}}`, `{{resume}}`,
319
+ `{{maxTurns}}`, `{{runDir}}`. A bare string is always kept; a **group** (a
320
+ nested array) is dropped whole when a placeholder inside it has no value, so
321
+ `["--model", "{{model}}"]` disappears rather than passing a bare `--model`.
322
+ Something has to carry the prompt: `{{prompt}}`, `{{promptFile}}`, or
323
+ `"stdin": "pipe"`.
324
+ - **`modes`** is validated before anything spawns, the same way a built-in's
325
+ is: a chain entry naming a mode outside `allowed` throws `forbidden flag`.
326
+ - **`forbiddenFlags`** is yours to add to. The flags that turn a supervised
327
+ agent into an unsupervised one are refused whatever the spec says, in `args`,
328
+ in `modes` and at argv time (`NEVER_ALLOWED`, `src/adapters/custom.mjs`).
329
+ - **`result.format`** is `json` (the first parseable object in stdout),
330
+ `jsonl` (the last line carrying the message field) or `text` (no parsing).
331
+ `sessionId`, `message` and `stopReason` are dotted paths, so
332
+ `"sessionId": "thread.id"` reads `{"thread":{"id":…}}`. With `text`, or when
333
+ nothing parses, the leg is judged by its `.leg/DONE` marker and its diff,
334
+ which is what happens for any adapter whose `parseResult` returns null.
335
+
336
+ A broken spec is reported, never thrown: `leg adapter list` names the file and
337
+ the reason, and the board, the scheduler and `leg card add` carry on without
338
+ it. A spec may not take a built-in's name. The directory is re-read whenever
339
+ the list is asked for, keyed on its entries, so a spec added while the board is
340
+ up appears in the New card form without a restart.
341
+
342
+ ## How to add a built-in adapter
261
343
 
262
344
  An adapter is a plain object (see `src/adapters/common.mjs` for the shared
263
345
  helpers, `src/adapters/fake.mjs` for the simplest full example):