@ucsandman/legcli 0.7.0 → 0.9.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 (116) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -558
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +64 -34
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/adapters.md +17 -3
  12. package/docs/board-guide.md +13 -0
  13. package/docs/cli-contracts.md +57 -3
  14. package/docs/concepts.md +42 -3
  15. package/docs/configuration.md +42 -2
  16. package/docs/faq.md +19 -0
  17. package/docs/getting-started.md +272 -251
  18. package/docs/harness.md +319 -0
  19. package/fixtures/limits/grok/grok-rate-limit.json +11 -0
  20. package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
  21. package/fixtures/verified.json +1 -1
  22. package/package.json +8 -4
  23. package/scripts/build-docs-site.mjs +15 -7
  24. package/scripts/check-branding.mjs +118 -0
  25. package/scripts/check-claims.mjs +1 -1
  26. package/scripts/license-sign.mjs +1 -1
  27. package/scripts/limits-table.mjs +1 -1
  28. package/scripts/live-limits.mjs +1 -1
  29. package/scripts/npm-publish-gate.mjs +114 -0
  30. package/scripts/probe.mjs +4 -3
  31. package/scripts/seed-fake-cards.mjs +4 -3
  32. package/scripts/seed-floor-board.mjs +5 -4
  33. package/scripts/seed-wes-board.mjs +5 -4
  34. package/scripts/stripe-setup.mjs +1 -1
  35. package/scripts/sync-harness-engine.mjs +159 -0
  36. package/scripts/sync-leg-agents.mjs +127 -0
  37. package/src/accounts.mjs +10 -2
  38. package/src/adapters/codex.mjs +1 -1
  39. package/src/adapters/grok.mjs +4 -7
  40. package/src/attach.mjs +162 -37
  41. package/src/auth.mjs +2 -2
  42. package/src/board/board.css +45 -17
  43. package/src/board/board.js +4 -4
  44. package/src/board/floor.js +2 -2
  45. package/src/board/sessions.js +181 -38
  46. package/src/bundle.mjs +54 -8
  47. package/src/chain.mjs +1 -1
  48. package/src/contract.mjs +4 -3
  49. package/src/fsx.mjs +5 -2
  50. package/src/handoff.mjs +6 -6
  51. package/src/harness/cli.mjs +281 -0
  52. package/src/harness/fingerprint.mjs +68 -0
  53. package/src/harness/index.mjs +407 -0
  54. package/src/harness/registry.mjs +124 -0
  55. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  56. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  57. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  58. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  74. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  75. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  76. package/src/hook.mjs +49 -49
  77. package/src/land.mjs +660 -47
  78. package/src/launcher.mjs +40 -27
  79. package/src/ledger.mjs +6 -6
  80. package/src/license.mjs +10 -9
  81. package/src/live-capture.mjs +1 -1
  82. package/src/mergequeue.mjs +6 -6
  83. package/src/orchestrator.mjs +28 -4
  84. package/src/preferences.mjs +63 -9
  85. package/src/redact.mjs +1 -1
  86. package/src/resume.mjs +17 -15
  87. package/src/runner.mjs +3 -3
  88. package/src/scheduler.mjs +1 -1
  89. package/src/server.mjs +69 -20
  90. package/src/session-detail.mjs +15 -1
  91. package/src/sessions.mjs +9 -5
  92. package/src/share.mjs +2 -2
  93. package/src/stations/agent.mjs +1 -1
  94. package/src/sync/dashclaw.mjs +4 -4
  95. package/src/synthesis.mjs +165 -0
  96. package/src/taps/agy.mjs +2 -2
  97. package/src/taps/claude-usage.mjs +1 -1
  98. package/src/taps/claude.mjs +170 -170
  99. package/src/taps/codex.mjs +286 -286
  100. package/src/taps/grok.mjs +251 -0
  101. package/src/trust.mjs +205 -36
  102. package/src/usage.mjs +5 -1
  103. package/src/worktree.mjs +5 -4
  104. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  105. package/fixtures/live/agy/err.log +0 -0
  106. package/fixtures/live/agy/out.log +0 -1
  107. package/fixtures/live/agy/supervisor.log +0 -2
  108. package/fixtures/live/claude/err.log +0 -0
  109. package/fixtures/live/claude/out.log +0 -1
  110. package/fixtures/live/claude/supervisor.log +0 -2
  111. package/fixtures/live/codex/err.log +0 -1
  112. package/fixtures/live/codex/out.log +0 -8
  113. package/fixtures/live/codex/supervisor.log +0 -2
  114. package/fixtures/live/grok/err.log +0 -32
  115. package/fixtures/live/grok/out.log +0 -7
  116. package/fixtures/live/grok/supervisor.log +0 -2
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // fake-agent — stands in for a coding-agent CLI in tests and the demo. It acts
3
3
  // in the process cwd (the card's worktree). FAKE_MODE selects behaviour:
4
- // success write the target file and .baton/DONE, print a result JSON, exit 0
4
+ // success write the target file and .leg/DONE, print a result JSON, exit 0
5
5
  // incomplete write the target file but no DONE marker, exit 0
6
6
  // limit print the recorded limit text (FAKE_LIMIT_FIXTURE, default
7
7
  // claude-session-limit) to the fixture's stream, exit with its code
@@ -58,8 +58,8 @@ function writeTarget() {
58
58
  }
59
59
 
60
60
  function writeDone(line) {
61
- mkdirSync(join(cwd, '.baton'), { recursive: true })
62
- writeFileSync(join(cwd, '.baton', 'DONE'), line + '\n')
61
+ mkdirSync(join(cwd, '.leg'), { recursive: true })
62
+ writeFileSync(join(cwd, '.leg', 'DONE'), line + '\n')
63
63
  }
64
64
 
65
65
  const prompt = process.stdin.isTTY ? '' : await readStdin()
@@ -68,7 +68,7 @@ if (delay > 0) await sleep(delay)
68
68
  if (mode === 'success') {
69
69
  writeTarget()
70
70
  writeDone(`wrote ${target}`)
71
- out({ session_id: 'sess-fake', result: `wrote ${target} and .baton/DONE`, prompt_chars: prompt.length, argv: process.argv.slice(2) })
71
+ out({ session_id: 'sess-fake', result: `wrote ${target} and .leg/DONE`, prompt_chars: prompt.length, argv: process.argv.slice(2) })
72
72
  process.exit(0)
73
73
  } else if (mode === 'break-test') {
74
74
  // land demo: ship the change together with a failing test
package/bin/leg.mjs CHANGED
@@ -20,12 +20,13 @@ import { availableActions } from '../src/chain.mjs'
20
20
  import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
21
21
  import { attach, ensureBoard } from '../src/attach.mjs'
22
22
  import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed } from '../src/share.mjs'
23
- import { AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
23
+ import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
24
24
  import { addAccount, removeAccount, listAccountRows, LAYOUT } from '../src/accounts.mjs'
25
25
  import { listUsage, fmtReset } from '../src/usage.mjs'
26
26
  import { home } from '../src/store.mjs'
27
27
  import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
28
28
  import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
29
+ import { harnessCommand } from '../src/harness/cli.mjs'
29
30
 
30
31
  const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
31
32
  // one source of truth for the version, so the help text cannot drift from the package
@@ -86,9 +87,14 @@ function simulateLimit(s) {
86
87
  if (s.agent === 'agy') {
87
88
  appendFileSync(join(sessionDir(s.session_id), 'agy.log'), '\nrpc error: code = ResourceExhausted desc = RESOURCE_EXHAUSTED quota (simulated by leg sessions simulate-limit)\n')
88
89
  appendEvent(s.session_id, { type: 'status', summary: 'simulated RESOURCE_EXHAUSTED appended to the session log' })
89
- return out(`simulated: RESOURCE_EXHAUSTED appended to ${join(sessionDir(s.session_id), 'agy.log')}; the runner reads it within ${process.env.BATON_ATTACH_POLL_MS || 2000} ms and hands off to ${s.chain?.[0]?.agent ?? 'nothing'}`)
90
+ return out(`simulated: RESOURCE_EXHAUSTED appended to ${join(sessionDir(s.session_id), 'agy.log')}; the runner reads it within ${(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS) || 2000} ms and hands off to ${s.chain?.[0]?.agent ?? 'nothing'}`)
90
91
  }
91
- die(2, `simulate-limit drives the claude hook path (and the agy log); codex's wall comes from its own rollout file, which Baton never writes. Use "leg sessions handoff ${s.session_id}" to force the switch.`)
92
+ if (s.agent === 'grok') {
93
+ appendFileSync(join(sessionDir(s.session_id), 'grok.log'), "\nRate limited (429): You've hit the rate limit for your plan. Try again later. (simulated by leg sessions simulate-limit)\n")
94
+ appendEvent(s.session_id, { type: 'status', summary: 'simulated rate limit appended to the grok log' })
95
+ return out(`simulated: rate limit appended to ${join(sessionDir(s.session_id), 'grok.log')}; the runner reads it within ${(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS) || 2000} ms and hands off to ${s.chain?.[0]?.agent ?? 'nothing'}`)
96
+ }
97
+ die(2, `simulate-limit drives the claude hook path (and the agy/grok log); codex's wall comes from its own rollout file, which Leg never writes. Use "leg sessions handoff ${s.session_id}" to force the switch.`)
92
98
  }
93
99
 
94
100
  function fmtCard(c) {
@@ -97,36 +103,51 @@ function fmtCard(c) {
97
103
  return `${c.card_id} [${c.status}] ${c.station}${leg} leases=${(c.leases?.length ? c.leases : ['**']).join(',')} ${String(c.title ?? c.task).slice(0, 60)}`
98
104
  }
99
105
 
100
- const TERMS = `Terms check (fetched 2026-09-11): Anthropic Consumer Terms forbid sharing account credentials and "bypassing any of our systems or protective measures"; the Anthropic Usage Policy forbids coordinating across multiple accounts to circumvent product guardrails; OpenAI's Terms of Use forbid sharing credentials and "circumvent any rate limits or restrictions". Two paid logins you own are not banned by name, but rotating to a second account of the same vendor because the first is rate-limited is close to that wording. Leg's default chain switches vendors (claude -> codex -> agy); a second account of one vendor is your call.`
106
+ const TERMS = `Terms check (fetched 2026-09-11): Anthropic Consumer Terms forbid sharing account credentials and "bypassing any of our systems or protective measures"; the Anthropic Usage Policy forbids coordinating across multiple accounts to circumvent product guardrails; OpenAI's Terms of Use forbid sharing credentials and "circumvent any rate limits or restrictions". Two paid logins you own are not banned by name, but rotating to a second account of the same vendor because the first is rate-limited is close to that wording. Leg's default chain switches vendors (claude -> codex -> agy -> grok); a second account of one vendor is your call.`
101
107
 
102
108
  async function main() {
103
109
  const [group, cmd, ...rest] = process.argv.slice(2)
104
110
  const args = parseArgs(rest)
105
111
  if (group === '--version' || group === '-v') return out(VERSION)
106
112
  if (group === '🦿' || group === 'prosthetic' || group === 'easter-egg') {
107
- out(' 🦿 LegCli — The mechanical relay runner for coding agents.')
108
- out(`
109
- .--------.
110
- | ____ |
111
- | | | |
112
- | |____| |
113
- '--------'
114
- ||
115
- .--||--.
116
- | || | knee servo
117
- '--||--'
118
- ||
119
- ||
120
- ___||___
121
- |________|
122
- `)
123
- out(' Passing the leg to the next runner when limits hit.')
113
+ const ORANGE = '\x1b[38;5;208m'
114
+ const RESET = '\x1b[0m'
115
+ const LEG_ART = [
116
+ ' ███████',
117
+ ' ███████',
118
+ ' ███████',
119
+ ' ███████',
120
+ ' ███████',
121
+ ' ███████',
122
+ ' ███████',
123
+ ' ███████',
124
+ ' ███████',
125
+ ' ███████',
126
+ ' ███████',
127
+ ' ███████████',
128
+ ' █████████████',
129
+ ` ██████(${ORANGE}00${RESET})███ ← knee servo`,
130
+ ' █████████████',
131
+ ' ███████████',
132
+ ' ███████',
133
+ ' █████',
134
+ ' █████',
135
+ ' █████',
136
+ ' █████',
137
+ ' █████',
138
+ ' █████',
139
+ ' ██████████████████████████',
140
+ ' ████ ████ ████ ████ ████ ████',
141
+ ].join('\n')
142
+ out('🦿 Leg: the mechanical relay runner for coding agents.\n')
143
+ out(LEG_ART)
144
+ out('\nPassing the leg to the next runner when limits hit.')
124
145
  return
125
146
  }
126
- if (AGENTS.includes(group)) {
127
- // leg claude|codex|agy [agent args...]: everything after the agent name
147
+ if (SUPERVISED_AGENTS.includes(group)) {
148
+ // leg claude|codex|agy|grok [agent args...]: everything after the agent name
128
149
  // goes straight through.
129
- const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: process.env.BATON_NO_OPEN !== '1' })
150
+ const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: (process.env.LEG_NO_OPEN || process.env.BATON_NO_OPEN) !== '1' })
130
151
  process.exit(code)
131
152
  }
132
153
  if (group === 'sessions') {
@@ -172,15 +193,15 @@ async function main() {
172
193
  const v = resumeVerdict(where)
173
194
  if (a.json) { out(JSON.stringify(v, null, 2)); process.exit(v.exit_code) }
174
195
  if (v.state === 'missing') {
175
- out(`no resume pointer in this checkout (looked for .baton/RESUME.md from ${where} upward).`)
176
- out('Baton writes one when a terminal hands off; `baton claude` in this directory starts one.')
196
+ out(`no resume pointer in this checkout (looked for .leg/RESUME.md from ${where} upward).`)
197
+ out('Leg writes one when a terminal hands off; `leg claude` in this directory starts one.')
177
198
  process.exit(v.exit_code)
178
199
  }
179
200
  const head = v.head?.now ? `${v.head.now.slice(0, 7)}${v.head.branch ? ` on ${v.head.branch}` : ''}` : 'no commit'
180
201
  const line = v.state === 'fresh'
181
202
  ? `${v.file} is current: written ${v.written_at ? ago(v.age_ms) : 'at an unrecorded time'}, and the repository is still at ${head}.`
182
203
  : v.state === 'unstamped'
183
- ? `${v.file} is UNSTAMPED: ${v.reasons[0]}. Baton did not write it, or an older Baton did.`
204
+ ? `${v.file} is UNSTAMPED: ${v.reasons[0]}. Leg did not write it, or an older version did.`
184
205
  : `${v.file} is STALE: ${v.reasons.join('; ')}.`
185
206
  if (a.check) {
186
207
  out(line)
@@ -280,7 +301,7 @@ async function main() {
280
301
  out('')
281
302
  out('Log in once (paste in PowerShell):')
282
303
  out(` ${r.login}`)
283
- out(`Then: $env:BATON_ACCOUNT='${name}'; baton ${agent} (or let a limit hand off to it)`)
304
+ out(`Then: $env:LEG_ACCOUNT='${name}'; leg ${agent} (or let a limit hand off to it)`)
284
305
  } catch (err) { die(2, err.message) }
285
306
  return
286
307
  }
@@ -302,6 +323,12 @@ async function main() {
302
323
  if (cmd === 'terms') return out(TERMS)
303
324
  die(2, `unknown accounts command "${cmd}" (ls|add|rm|terms)`)
304
325
  }
326
+ if (group === 'harness') {
327
+ // The portable harness: the working environment a hand-off carries with
328
+ // the task. Off until `leg harness enable` (src/harness/index.mjs).
329
+ const code = await harnessCommand(cmd, args, { out, die })
330
+ process.exit(code)
331
+ }
305
332
  if (group === 'license') {
306
333
  // The paid gate. Keys verify offline against the public key in
307
334
  // src/license.mjs; nothing here talks to the network except refresh.
@@ -330,7 +357,7 @@ async function main() {
330
357
  }
331
358
  if (group === 'uninstall') {
332
359
  // Leg never edits ~/.claude or ~/.codex; everything it added lives under
333
- // $BATON_HOME (sessions, usage, extra-account dirs, cards).
360
+ // $LEG_HOME (sessions, usage, extra-account dirs, cards).
334
361
  const dir = home()
335
362
  if (!args.yes) {
336
363
  out(`leg uninstall removes ${dir} (sessions, usage, extra-account dirs, cards, board pidfile) and nothing else.`)
@@ -340,7 +367,7 @@ async function main() {
340
367
  for (const r of listAccountRows()) if (r.name !== 'default') removeAccount(r.agent, r.name)
341
368
  await down()
342
369
  rmSync(dir, { recursive: true, force: true })
343
- return out(`removed ${dir}; now: npm rm -g legcli`)
370
+ return out(`removed ${dir}; now: npm rm -g @ucsandman/legcli`)
344
371
  }
345
372
  if (group === 'card') {
346
373
  if (cmd === 'add') return cardAdd(args)
@@ -427,16 +454,19 @@ async function main() {
427
454
  out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
428
455
  return
429
456
  }
430
- if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|sessions|resume|accounts|license|share|up|down|status|open|card|scheduler|uninstall)`)
457
+ if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
431
458
  out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
432
- claude|codex|agy [args...] the normal interactive agent in this terminal; args pass straight through
433
- the board opens once, the session shows as a card, usage is tracked, a limit hands off
434
- a second live session in one checkout gets its own worktree (--no-worktree to share)
459
+ claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
460
+ the board opens once, the session shows as a card, usage is tracked, a limit hands off
461
+ a second live session in one checkout gets its own worktree (--no-worktree to share)
462
+ auto-approve mode (--no-auto-approve to opt out)
435
463
  sessions ls|show|events|handoff|end|rm|simulate-limit <id>
436
464
  resume [--check] [--json] [--path <dir>] the hand-off waiting in this checkout, and whether it is still true
437
465
  freshness is recomputed from git at read time; --check prints only the verdict
438
466
  exit 0 current, 1 stale or unstamped, 3 no pointer here
439
467
  accounts ls|add <agent> <name>|rm|terms optional second login for claude or codex
468
+ harness status|enable|sync|check|explain|... carry the source agent's rules, hooks, skills, agents, commands and MCP
469
+ servers to the agent a hand-off lands on; off until enabled (leg harness help)
440
470
  license [status|activate <key>|deactivate|refresh]
441
471
  personal or team license status and management
442
472
  share status|on|add <name>|rotate <name>|rm <name>|off
package/docs/DECISIONS.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  Durable product and design decisions that the code does not explain on its own. One entry per decision, newest first.
4
4
 
5
+ ## 2026-09-16: the portable harness is an opt-in subsystem over a vendored, hash-pinned engine
6
+
7
+ - **What.** `leg harness` carries the source agent's working environment (rules, identity, hooks, skills, subagents, commands, MCP servers, permissions) to the agent a hand-off lands on. The capture, neutral bundle and apply engine is the Agnostic AI port engine (MIT), embedded byte for byte under `src/harness/vendor/agnostic-ai/` and driven through its library entry; Leg owns consent, policy, the client registry, state, the fingerprint, the trail and the hand-off decision (`src/harness/*.mjs`).
8
+ - **Why vendor, and why verbatim.** Leg ships with zero runtime dependencies and no build step, and Agnostic AI is a private-by-default template repo, so a package dependency was not on the table. A copy that is edited locally drifts forever; a copy that is verified against recorded hashes cannot. `scripts/sync-harness-engine.mjs --check` runs in `npm test` and fails on any local edit; a fix lands upstream, then the sync copies it in. Upstream was made embeddable first (`configure({ brand, secretPatterns, shimPath, importRoots })`, injected registry and policy, a library entry), with its own regression proving the boundary, so the vendored files need no patching.
9
+ - **Why CommonJS stays.** Node's ESM loader imports the CommonJS entry directly. Converting upstream to ESM would have rewritten working machinery for style and broken its dynamic adapter loading; keeping the boundary keeps the copy verbatim.
10
+ - **Why off by default, and why a policy.** Leg's promise that it leaves your settings files alone stands for every install that never runs `leg harness enable`. The first run shows what will be written and asks. After that a hand-off lands when nobody is at the keyboard, so the saved policy decides, never a prompt: `warn` reports, `sync` writes what is safe, `strict` refuses what is not and tries the next option, ending the terminal with exit 5 only when none is left. The strict refusal applies to hand-offs, not to the agent the human started.
11
+ - **What a write may do.** Only owned files (`GENERATED by Leg harness` in the head) and marked regions or owned keys inside files the user also owns; a backup before every overwrite; a hand-edited file skipped and named; the source client never written; credentials replaced by `${NAME}` and a bundle that still carries one refused. The same discipline as the folder-trust record, extended.
12
+ - **What stays separate.** Accounts. The harness describes behaviour; the account layer decides which login runs, and a same-agent hand-off to a second login carries no harness.
13
+ - **Registry.** Only the clients Leg launches, plus Gemini CLI because it shares `GEMINI.md` with agy. Grok is reported `unsupported`, never guessed. Upstream's other fifteen targets are not exposed.
14
+
15
+ ## 2026-09-16: @ucsandman/legcli and leg-agents stay on the same version
16
+
17
+ - **What.** The unscoped `leg-agents` package is an alias installer for `@ucsandman/legcli`. It lives in `packages/leg-agents`, always carries the root version, and pins `@ucsandman/legcli` to that exact version. CI publishes both from `.github/workflows/ci.yml` after the same registry gate.
18
+ - **Why.** `npm i -g leg-agents` is the name agents and muscle memory will type. A hand-kept alias is how the two versions drift, and a drifted alias installs yesterday's CLI.
19
+ - **How drift is refused.** `scripts/sync-leg-agents.mjs --check` is part of `npm test`. The publish gate refuses to ship if the two package.json versions differ, or if the alias pin is not the root version. `npm version` / `npm run sync-alias` writes the alias in the same step as the root bump. The alias tarball is not included in the scoped package (`files` does not list `packages/`).
20
+ - **Windows.** The alias wrapper loads `bin/leg.mjs` through `pathToFileURL`. A bare `import(join(absPath))` is a `c:` URL scheme on Windows and throws `ERR_UNSUPPORTED_ESM_URL_SCHEME`.
21
+ - **First publish.** `leg-agents` is a new npm name. Bind a trusted publisher on npmjs.com for `leg-agents` to `ucsandman/legcli` + `ci.yml` (same as the scoped package) before the first CI publish, or publish the first version once with OTP.
22
+
5
23
  ## 2026-09-15: the board is dark cobalt, and there is no light mode
6
24
 
7
25
  - **What.** The board ground is a saturated deep cobalt at hue 258, the same hue the marketing site is drenched in, taken to its dark end. Not a neutral near-black: measured in OKLab, `--e0` sits 0.0507 from `#0f1115` at 5.7 times its chroma, so the anti-reference colour `PRODUCT.md` bans is not reachable from this palette.
@@ -22,7 +40,7 @@ Durable product and design decisions that the code does not explain on its own.
22
40
 
23
41
  ## 2026-09-15: Leg records the folder-trust answer, and never overrides one already given
24
42
 
25
- - **What.** Before starting an agent, Leg writes the folder-trust answer for the repository the user chose by typing `baton <agent>` in it: `hasTrustDialogAccepted` in `~/.claude.json`, `trust_level` in `~/.codex/config.toml`, an entry in `~/.gemini/trustedFolders.json`. `LEG_TRUST=never` turns it off.
43
+ - **What.** Before starting an agent, Leg writes the folder-trust answer for the repository the user chose by typing `leg <agent>` in it: `hasTrustDialogAccepted` in `~/.claude.json`, `trust_level` in `~/.codex/config.toml`, `trustedWorkspaces` in `~/.gemini/antigravity-cli/settings.json` (and `default-cli-project.json` for Gemini project resources). For worktrees, both the repo root and the worktree directory are recorded because Antigravity CLI does an exact string match against `Store.workspacePath`. `LEG_TRUST=never` turns it off.
26
44
  - **Why.** The handoff is the product, and it fires when the limit hits, which is usually when nobody is watching. An agent that had never run in that folder stopped on its first-run trust prompt and waited for a keypress that was not coming, so the bundle was written and the terminal sat idle until morning.
27
45
  - **Why writing those files is allowed at all.** `stdio: 'inherit'` in `src/attach.mjs` hands the real terminal to the agent, so Leg cannot watch for the prompt and answer it. Pre-seeding is the only mechanism that does not change Leg's architecture. For Claude Code it is also the documented remedy: its permissions guide prescribes exactly this edit.
28
46
  - **The three rules that bound it.** Never create a config file that is not already there. Never rewrite a file to say what it already says. Never override an answer already on file: only an absent key is an unanswered question, so a recorded refusal stays a refusal.
@@ -41,7 +59,7 @@ Durable product and design decisions that the code does not explain on its own.
41
59
  - **What.** One static HTML page (`site/index.html`, `style.css`, `site.js`), self-hosted fonts, deployed to Vercel from the `site/` directory with `vercel.json` headers. No framework, no build step. PRODUCT.md and DESIGN.md at the repo root carry the brief and the tokens so later edits inherit them.
42
60
  - **How it was chosen.** A four-concept tournament (light restrained, drenched racing green, committed cobalt, product-led dark terminal) judged against a written rubric. Committed cobalt won and borrowed the DOM-recreated board from the light concept and the typed full-bleed terminal from the product-led one. The scores and disqualifications are recorded in the session notes; the design tokens are in DESIGN.md.
43
61
  - **Why cobalt and not the board's own dark palette.** The operator board and the sibling site declick.dev are both near-black; a third near-black surface from the same author would read as one family and as the generic dark AI-tool page. The site's warmth comes only from the agent colors inside product visuals.
44
- - **What the page promises.** Every number, path, version and date on it is copied from the README as verified on 2026-09-11. The terminal transcript is a labeled sample session whose `[baton]` lines are the strings `src/attach.mjs` prints and whose pointer prompt is the one `src/bundle.mjs` sends. The two-session cards are from the live run the README documents.
62
+ - **What the page promises.** Every number, path, version and date on it is copied from the README as verified on 2026-09-11. The terminal transcript is a labeled sample session whose `[leg]` lines are the strings `src/attach.mjs` prints and whose pointer prompt is the one `src/bundle.mjs` sends. The two-session cards are from the live run the README documents.
45
63
  - **Analytics and search.** Vercel Web Analytics is the only script besides `site.js`; nothing on the page depends on it. Search Console and Bing registration state is recorded below this entry when done.
46
64
 
47
65
  ### Registration state, 2026-09-11
package/docs/ERRORS.md CHANGED
@@ -3,6 +3,77 @@
3
3
  What broke, why, and what fixed it. One entry per failure, newest first. A first
4
4
  occurrence has to be written down or a repeat is never countable.
5
5
 
6
+ ## 2026-09-16: a hand-edited `package.json` version left `package-lock.json` behind, and the publish gate refused 0.9.0
7
+
8
+ **Fixed by bumping the lockfile root version; the gate is `scripts/npm-publish-gate.mjs`.**
9
+
10
+ The version went from 0.8.1 to 0.9.0 by editing `package.json` directly, so
11
+ the lockfile's two root `version` fields still said 0.8.1 and CI's
12
+ `publish-npm` job stopped at "package.json and package-lock.json root metadata
13
+ do not match". The run before it had failed the same way, which is why npm
14
+ still served 0.8.0. Tests, lint and the docs job were green; only the publish
15
+ was refused, which is the gate doing its job. Lesson: bump with `npm version`
16
+ (it writes the lockfile and runs the alias sync) and run the gate locally
17
+ before pushing a release; a green test matrix says nothing about the publish.
18
+
19
+ ## 2026-09-16: the vendored engine's secret scan covered two fields; the review found the other five
20
+
21
+ **Fixed upstream (Agnostic AI 7e35b51) and re-vendored; regression in `test/harness-engine.test.mjs`.**
22
+
23
+ `docs/harness.md` promised "a bundle that still carries a credential is refused
24
+ at save time". The engine's `validate()` scanned `mcp.<n>.env` and
25
+ `mcp.<n>.headers` only; the rules text, identity, hook command lines, MCP
26
+ arguments, URLs and agent bodies were copied verbatim, and the four canary
27
+ tests planted tokens exactly where the scan already looked. The read-only
28
+ security review planted one everywhere else and got one problem back. Root
29
+ cause: a scan written for two map keys, and a test fixture shaped to it. Fix:
30
+ whole-bundle scanning with redaction of free text and drops of unsafe
31
+ handlers or servers, plus `PLANTED` tokens in every place the scan must reach.
32
+ Lesson: a canary proves the place it sits in, nothing else; a fixture written
33
+ by the same hands as the scan finds nothing the scan missed.
34
+
35
+ ## 2026-09-16: `CLAUDE_CONFIG_DIR` outside the OS home was captured from `~/.claude` instead
36
+
37
+ **Fixed upstream in `sources/claude.cjs` (`pick()`), regression in `test/harness-engine.test.mjs`.**
38
+
39
+ The engine kept a registry path only when it sat inside the OS home and fell
40
+ back to `~/.claude` otherwise, while Leg's registry, detection and fingerprint
41
+ honoured the override. A per-account config dir under a `LEG_HOME` on another
42
+ drive would have ported the dormant profile and never noticed edits to the
43
+ active one. Fix: the registry path is trusted as given. Lesson: three code
44
+ paths agreeing on a directory is a property to test, not to assume.
45
+
46
+ ## 2026-09-16: `leg harness sync` wrote before `leg harness enable`; the board could widen the policy
47
+
48
+ **Fixed in `src/harness/cli.mjs` and `src/server.mjs`, regressions in `test/harness-cli.test.mjs` and `test/harness-policy.test.mjs`.**
49
+
50
+ The consent gate lived in `enable` only, so `sync` on an install that never
51
+ enabled the feature wrote managed files while `status` said off; the settings
52
+ route accepted any policy value, so a board POST could take `warn` to
53
+ `strict`. Both were one-line fixes the review caught. Lesson: a consent rule
54
+ has to be checked at every writer, not at the one verb that grants it.
55
+
56
+ ## 2026-09-16: the backup count was always zero
57
+
58
+ **Fixed in `src/harness/index.mjs`.**
59
+
60
+ The engine's writer returns the backup path, but every adapter keeps only the
61
+ action, so counting `f.backup` counted nothing. The count now comes from the
62
+ backups directory before and after an apply. Lesson: a number that never moves
63
+ in a demo is a number nobody is computing.
64
+
65
+ ## 2026-09-16: alias `import(join(windowsPath))` is a `c:` URL scheme
66
+
67
+ **Fixed in `packages/leg-agents/bin/leg.mjs`.**
68
+
69
+ The first `leg-agents` wrapper did `await import(join(pkgRoot, 'bin', 'leg.mjs'))`.
70
+ On Windows that string is `C:\…\bin\leg.mjs`, which Node's ESM loader treats as a
71
+ URL with protocol `c:` and throws `ERR_UNSUPPORTED_ESM_URL_SCHEME`. `npm install`
72
+ of the tarball succeeded; `leg --version` died before printing `0.8.0`.
73
+
74
+ The fix is `await import(pathToFileURL(join(pkgRoot, 'bin', 'leg.mjs')).href)`.
75
+ `require.resolve` returning a path is not a valid ESM specifier on Windows.
76
+
6
77
  ## 2026-09-15: macOS `/var` symlink broke two e2e tests
7
78
 
8
79
  **Fixed in `test/helpers.mjs`.**
package/docs/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  - [concepts.md](concepts.md): sessions, accounts, usage windows and the interactive handoff, then cards, stations, chains, outcomes, leases, the land station and the card status state diagram.
7
7
  - [board-guide.md](board-guide.md): the instrument head (a row per login, two window rails each, the 85 percent post), the Terminals panels, overlap flags, Landed on main, Background tasks, Settings, and the floor view.
8
8
  - [configuration.md](configuration.md): every environment variable, the accounts layout, `.env`, network exposure, card-level options.
9
+ - [harness.md](harness.md): the portable harness, off by default: what moves between agents and what does not, the first run, policies, ownership and backups, secrets, the evidence trail, and how Leg relates to the Agnostic AI engine it embeds.
9
10
  - [adapters.md](adapters.md): what Leg reads from each CLI in an interactive session, each adapter's headless argv, modes, forbidden flags, gotchas, and how to add a new one.
10
11
  - [faq.md](faq.md): short answers to real questions (the status line, codex's missing hook, agy's missing percentage, second accounts, uninstall, limits, secrets, Windows support).
11
12
 
@@ -22,6 +23,7 @@
22
23
  | area | modules |
23
24
  |------|---------|
24
25
  | interactive sessions | `src/attach.mjs` (the `leg <agent>` runner), `src/sessions.mjs` (the session store), `src/usage.mjs` (usage windows and the chooser), `src/accounts.mjs` (extra logins), `src/bundle.mjs` (the per-session bundle), `src/hook.mjs` (what Claude Code's hooks run) |
26
+ | portable harness | `src/harness/index.mjs` (capture, compare, apply, status, the hand-off decision), `src/harness/registry.mjs` (which clients, where their files are), `src/harness/fingerprint.mjs`, `src/harness/cli.mjs` (`leg harness`), `src/harness/vendor/agnostic-ai/` (the engine, verbatim; `scripts/sync-harness-engine.mjs` is the only writer) |
25
27
  | taps | `src/taps/claude.mjs`, `src/taps/claude-usage.mjs`, `src/taps/codex.mjs`, `src/taps/agy.mjs` |
26
28
  | board | `src/server.mjs`, `src/board/sessions.js` (Terminals lane), `src/board/board.js` and `src/board/floor.js` (pipelines) |
27
29
  | pipelines | `src/orchestrator.mjs`, `src/scheduler.mjs`, `src/chain.mjs`, `src/pipeline.mjs`, `src/runner.mjs`, `src/ledger.mjs`, `src/leases.mjs`, `src/mergequeue.mjs`, `src/adapters/*.mjs` |
package/docs/REUSE.md CHANGED
@@ -229,7 +229,7 @@ station):
229
229
  Network off by default: `-c sandbox_workspace_write.network_access=false`.
230
230
  - Git workflow: snapshot before a leg, snapshot `--diff-since` after; the
231
231
  `worktree` recommendation is Leg's only mode (one worktree per card,
232
- branch `baton/<card-id>`); the diff, never prose, is what review and landing
232
+ branch `leg/<card-id>`); the diff, never prose, is what review and landing
233
233
  trust.
234
234
  - Failure rules that carry over: exit 11 means a supervisor is already running,
235
235
  never relaunch over it; a failed launch is retried once then the chain moves
@@ -51,6 +51,8 @@ Source: the `appendEvent`/`updateSession` call sites in `src/attach.mjs`,
51
51
  | `lost` | the runner pid is gone; the session was marked `lost` |
52
52
  | `error` | a spawn error, a tap error, a failed bundle checkpoint, or an error the agent reported |
53
53
  | `status` | a note that does not fit another type |
54
+ | `harness` | the portable harness was prepared for the leg starting now: the destination's state (`synced`, `partial`, `stale`, `attention`, `unsupported`, `source`, `error`) with what was dropped in the body |
55
+ | `harness_blocked` | the strict harness policy refused the chosen destination; the next option is tried |
54
56
  | `worktree` | another live session was in the checkout, so this one got its own worktree: path, branch, base |
55
57
  | `land_requested` | Land was pressed: the branch and its base |
56
58
  | `land_warning` | the landing ran without a test command |
@@ -58,6 +60,23 @@ Source: the `appendEvent`/`updateSession` call sites in `src/attach.mjs`,
58
60
  | `bounced` | the landing stopped with a [bounce reason](#bounce-reasons-land-station); the full detail is in `body` |
59
61
  | `land_noop` | Land found nothing on the branch beyond its base |
60
62
 
63
+ ## Harness states (terminal cards, drawer, `leg harness`)
64
+
65
+ Source: `STATES` in `src/harness/index.mjs`; recorded on `session.harness.state`.
66
+
67
+ | state | meaning |
68
+ |-------|---------|
69
+ | `off` | the portable harness is not enabled; nothing recorded |
70
+ | `same-client` | a hand-off to another login of the same client; the harness is shared already |
71
+ | `source` | the destination is the source client; never written |
72
+ | `synced` | every component the destination supports is current, nothing dropped |
73
+ | `partial` | current, some items could not be carried (each with a reason) |
74
+ | `stale` | the destination is behind the source (`warn` policy, or a check) |
75
+ | `attention` | a managed file was hand-edited (backed up, skipped) or a component errored |
76
+ | `unsupported` | no adapter for the destination (Grok), or it is not installed |
77
+ | `blocked` | the strict policy refused the destination |
78
+ | `error` | the preparation failed; the reason is recorded |
79
+
61
80
  ## Land states (terminal cards)
62
81
 
63
82
  `$LEG_HOME/sessions/<id>/land.json`, written by the board server only
@@ -166,6 +185,8 @@ across `src/chain.mjs`, `src/orchestrator.mjs`, `src/scheduler.mjs`,
166
185
  | `failed` | the card failed (chain exhausted, land attempts exhausted, or an environment fault) |
167
186
  | `error` | an unexpected error (orchestrator crash, handoff bundle write failure, land station crash) |
168
187
  | `status` | a status note that doesn't fit another type (e.g. "rerun from build leg 0") |
188
+ | `harness` | the portable harness was prepared for the adapter about to run; the summary names the state, the body what was dropped |
189
+ | `harness_blocked` | the strict harness policy refused the adapter; the leg fails as `launch_failed` and does not advance |
169
190
 
170
191
  ## Actor types
171
192
 
package/docs/adapters.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Adapters
2
2
 
3
3
  Two things per agent: what Leg reads from an interactive session
4
- (`leg claude|codex|agy`), and the headless argv the v0.1 pipeline spawns.
4
+ (`leg claude|codex|agy|grok`), and the headless argv the v0.1 pipeline spawns.
5
5
  Every fact here was written against `src/taps/*.mjs`, `src/attach.mjs` and
6
6
  `src/adapters/*.mjs`; the evidence trail, including which lines an artifact
7
7
  backs, is [cli-contracts.md](cli-contracts.md).
@@ -120,7 +120,7 @@ documentation say docs-only.
120
120
  - **The wall**: `RESOURCE_EXHAUSTED`, "it resets in %s" and "out of quota" in
121
121
  the log. Those strings are present in `agy.exe`, and `scanLog()` also reads a
122
122
  relative reset out of "resets in \<n>\<s|m|h|d>". Status:
123
- **docs-only** <!-- live:agy/agy-resource-exhausted --> —
123
+ **observed-live 2026-09-16** <!-- live:agy/agy-resource-exhausted --> —
124
124
  `RESOURCE_EXHAUSTED (code 429): Individual quota reached … Resets in
125
125
  71h19m42s.` appeared in a session's `agy.log` at 08:02:42Z and walled the
126
126
  agent. No payload was kept: the capture call in `src/attach.mjs` was added
@@ -133,11 +133,25 @@ documentation say docs-only.
133
133
  - **One account only**: agy 1.2.0 has no config-directory override, so
134
134
  `leg accounts add agy …` is refused.
135
135
 
136
+ ### grok
137
+
138
+ - **How Leg attaches**: `grok <your args>` with `--debug-file <~/.leg/sessions/<id>/grok.log>` passed by Leg (`src/attach.mjs` `spawnSpec`).
139
+ - **Usage percentages**: `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` and `GET https://cli-chat-proxy.grok.com/v1/user?include=subscription` (`LEG_GROK_BILLING_URL` and `LEG_GROK_USER_URL` override), reading the OAuth token stored in `~/.grok/auth.json`. The billing endpoint reports `creditUsagePercent` and `currentPeriod` (with weekly resets). Polled every 60 s (`LEG_USAGE_POLL_MS`). If the token expires or returns 401, usage is marked unknown without crashing, and re-reads `auth.json` on the next poll.
140
+ - **The wall**: rate limit signals cited directly from `xai-org/grok-build`:
141
+ - `SamplingError::Api { status: StatusCode::TOO_MANY_REQUESTS }` (`crates/codegen/xai-grok-sampling-types/src/error.rs:304`)
142
+ - `RATE_LIMITED_ERROR_CODE = -32003` and user messages `RATE_LIMITED_USER_MESSAGE_OAUTH` ("You've hit the rate limit for your plan. Try again later.") and `RATE_LIMITED_USER_MESSAGE_API_KEY` ("You've hit the rate limit for your API key. Try again later.") (`crates/codegen/xai-grok-shell/src/sampling/error.rs:15, 18-21`)
143
+ - Headline "Rate limited (429)" and "You've hit the rate limit for your plan" (`crates/codegen/xai-grok-pager/src/app/error_display.rs:263-267`)
144
+ - `StopFailureKind::RateLimit` ("rate_limit") (`crates/codegen/xai-grok-hooks/src/event.rs:306-315`)
145
+ - Free usage exhausted: `FREE_USAGE_USER_MESSAGE` and `FREE_USAGE_EXHAUSTED_ERROR_CODE` ("subscription:free-usage-exhausted") (`crates/codegen/xai-grok-shell/src/sampling/error.rs:30, 33`)
146
+ `src/taps/grok.mjs` scans `grok.log` for these exact signals and extracts reset durations when available.
147
+ - **Prompts and session id**: `~/.grok/sessions/<url-encoded-cwd>/prompt_history.jsonl`, recorded per prompt with timestamp, `session_id`, and `prompt`.
148
+ - **Accounts**: Supports `GROK_HOME` override. `leg accounts add grok <name>` creates junctioned directories copying `config.toml`.
149
+
136
150
  ### Resume prompt per agent
137
151
 
138
152
  After a hand-off the next agent starts in the same terminal with the pointer
139
153
  prompt as its first positional argument: `claude "<prompt>"`,
140
- `codex "<prompt>"`, `agy -i "<prompt>"` (`src/attach.mjs` `spawnSpec`).
154
+ `codex "<prompt>"`, `agy -i "<prompt>"`, `grok "<prompt>"` (`src/attach.mjs` `spawnSpec`).
141
155
 
142
156
  ## Headless adapters (the v0.1 pipeline)
143
157
 
@@ -184,6 +184,11 @@ is history, and after a day's work it is most of the list. It moves to the
184
184
  ledger (below) as part of a count that opens. A finished terminal that still
185
185
  needs you, or whose expansion you have open, stays in place.
186
186
 
187
+ With the portable harness on, the register also carries one chip for the leg
188
+ now running: `harness synced`, `harness partial`, `harness stale`, `harness
189
+ attention` or `harness refused` (`harness.md`, "States"). Nothing shows when
190
+ the feature is off.
191
+
187
192
  ### The one sentence
188
193
 
189
194
  `rankedNotes` in `src/board/sessions.js` is the single source of every sentence
@@ -289,6 +294,14 @@ from the right, and the page keeps one scroll container
289
294
  Hand off now. A normal exit ends this terminal.`, the **Change order**
290
295
  editor, the current bundle id, and whether `.leg/RESUME.md` still describes
291
296
  the repository (recomputed from git on every poll).
297
+ 8. **Harness** (only when the [portable harness](harness.md) is on): the
298
+ source client, when it was captured and synced, the policy, one line for
299
+ the leg now running (`codex harness partial · 8/8 components, 3 dropped ·
300
+ 1 file(s) written`), a row per component with its state and `carried /
301
+ total`, **Needs you** for a hand-edited file or an unreadable config,
302
+ **Dropped** with a reason per item (`excluded by policy` when the drop was
303
+ yours), and the last eight entries of the harness trail. Every word comes
304
+ from what the session recorded when the leg started, never from a guess.
292
305
 
293
306
  It refetches every 3 seconds while it is open, and stops on **Pause updates**,
294
307
  when the tab is in the background, or when it is closed. The rest of the page
@@ -189,9 +189,30 @@ stderr are 0 bytes, and codex's is the one stdin notice. observed-live.
189
189
  out of `src/adapters/index.mjs` until `grok login` has been completed on the
190
190
  machine and `node scripts/probe.mjs --adapter grok --repo <toy>` passes.
191
191
 
192
+ ## `leg harness` (the portable harness)
193
+
194
+ What the command reads and writes per client, each fact from the engine's
195
+ adapter source (`src/harness/vendor/agnostic-ai/engine/harness/{sources,targets}/*.cjs`,
196
+ byte for byte the Agnostic AI engine) and verified by
197
+ `test/harness-*.test.mjs` against fixture homes on 2026-09-16.
198
+
199
+ | client | read as a source | written as a destination | shim |
200
+ |---|---|---|---|
201
+ | Claude Code | `~/.claude/CLAUDE.md` (+ `@imports` inside the home), `SOUL.md`, the hooks and permissions of its settings file, the `mcpServers` of `~/.claude.json` and `~/.claude/.mcp.json` (the oauth block is never read), `agents/*.md`, `commands/*.md`, `skills/*/SKILL.md` | `~/.claude/leg-rules.md` + one `@` line appended to `CLAUDE.md`; owned hook groups and permission entries in its settings file; owned servers in `~/.claude.json`; `agents/`, `commands/`, skill links | none: the bundle is Claude's dialect |
202
+ | Codex CLI | `~/.codex/AGENTS.md`, `config.toml` (`hooks.*`, `mcp_servers.*`), `agents/*.toml`, `prompts/*.md`, `skills/`, `rules/*.rules` (per-invocation absolute-path approvals skipped; `auth.json` never read) | `AGENTS.md` (whole file, owned), `config.toml` regions `hooks` (with `[hooks.state]` trust hashes, self-tested), `skills` (duplicate disables), `mcp`; `agents/*.toml`; `prompts/*.md`; skill links; `rules/leg-harness.rules` prefix rules | none |
203
+ | Antigravity CLI | not a source | `~/.gemini/GEMINI.md` (owned), the `leg-harness` key in `~/.gemini/config/hooks.json`, `mcp_config.json` servers, `config/agents`, `config/commands`, `config/skills` links | the engine's hook shim, chained with `++` |
204
+ | Gemini CLI | not a source | `GEMINI.md`, owned hook groups in its settings file, `commands/*.toml`, `mcpServers`, skill links | shim |
205
+ | Grok CLI | no | no (reported `unsupported`) | |
206
+
207
+ Exit codes: `0` fine; `1` stale or attention (`check`, `sync`) or a doctor
208
+ failure; `2` usage; `3` not captured, no source, or consent declined
209
+ (`enable` without `--yes` and without a terminal). `--json` on `status`,
210
+ `inspect`, `sync`, `check`, `explain`, `history`, `doctor` prints the same
211
+ record the board reads.
212
+
192
213
  ## Interactive taps
193
214
 
194
- What `leg claude|codex|agy` reads while the real interactive CLI runs. Same
215
+ What `leg claude|codex|agy|grok` reads while the real interactive CLI runs. Same
195
216
  tagging rule: `observed-live 2026-09-11` means the build machine did it;
196
217
  `docs-only` means the CLI's own source or documentation says so and Leg has
197
218
  not seen it happen. Machine: Claude Code 2.1.268, codex-cli 0.153.4, agy 1.2.0.
@@ -295,7 +316,7 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
295
316
  "quota exhausted/exceeded" in the log, plus a relative reset parsed out of
296
317
  "resets in \<n>\<s|m|h|d>" (source: strings present in `agy.exe`;
297
318
  src/taps/agy.mjs `scanLog`).
298
- **docs-only** <!-- live:agy/agy-resource-exhausted -->:
319
+ **observed-live 2026-09-16** <!-- live:agy/agy-resource-exhausted -->:
299
320
  `RESOURCE_EXHAUSTED (code 429): Individual quota reached … Resets in
300
321
  71h19m42s.` appeared in a session's `agy.log` at 08:02:42Z, `scanLog` read
301
322
  the relative reset, and the agent was walled. No payload was kept, the
@@ -310,6 +331,38 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
310
331
  `LAYOUT.agy.env` is `null` and `accounts add agy` is refused (source:
311
332
  src/accounts.mjs).
312
333
 
334
+ ### grok tap
335
+
336
+ - Attach: `grok <args> --debug-file <LEG_HOME>/sessions/<id>/grok.log` (source:
337
+ src/attach.mjs `spawnSpec`).
338
+ - Usage: `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` and
339
+ `GET https://cli-chat-proxy.grok.com/v1/user?include=subscription` with
340
+ `Authorization: Bearer <token>` read from `~/.grok/auth.json` (per-scope
341
+ key map or direct object). The billing endpoint reports `creditUsagePercent`
342
+ and `currentPeriod` (with weekly reset timestamp `end`). Polled every 60 s
343
+ (`LEG_USAGE_POLL_MS`). Token expiry and HTTP 401 are handled gracefully
344
+ without throwing, marking usage unknown until re-authenticated.
345
+ (source: src/taps/grok.mjs; probe against xAI proxy).
346
+ - The wall: exact rate-limit signals cited from `xai-org/grok-build` (Rust):
347
+ - `SamplingError::Api { status: StatusCode::TOO_MANY_REQUESTS }`
348
+ (`crates/codegen/xai-grok-sampling-types/src/error.rs:304`)
349
+ - `RATE_LIMITED_ERROR_CODE = -32003` and messages
350
+ `RATE_LIMITED_USER_MESSAGE_OAUTH` ("You've hit the rate limit for your plan. Try again later.")
351
+ and `RATE_LIMITED_USER_MESSAGE_API_KEY` ("You've hit the rate limit for your API key. Try again later.")
352
+ (`crates/codegen/xai-grok-shell/src/sampling/error.rs:15, 18-21`)
353
+ - Headline "Rate limited (429)" and "You've hit the rate limit for your plan"
354
+ (`crates/codegen/xai-grok-pager/src/app/error_display.rs:263-267`)
355
+ - `StopFailureKind::RateLimit` ("rate_limit")
356
+ (`crates/codegen/xai-grok-hooks/src/event.rs:306-315`)
357
+ - Free usage exhausted: `FREE_USAGE_USER_MESSAGE` and `FREE_USAGE_EXHAUSTED_ERROR_CODE`
358
+ ("subscription:free-usage-exhausted")
359
+ (`crates/codegen/xai-grok-shell/src/sampling/error.rs:30, 33`)
360
+ `src/taps/grok.mjs` scans `grok.log` for these patterns and parses reset duration.
361
+ - Prompts and session id: `~/.grok/sessions/<url-encoded-cwd>/prompt_history.jsonl`,
362
+ one `{ timestamp, session_id, prompt }` per entry (source: src/taps/grok.mjs `promptsSince`).
363
+ - Accounts: `GROK_HOME` override supported; `leg accounts add grok <name>` creates
364
+ junctioned directories with copied `config.toml` (source: src/accounts.mjs).
365
+
313
366
  ### Usage store and the chooser
314
367
 
315
368
  - `<LEG_HOME>/usage/<agent>--<account>.json`:
@@ -361,7 +414,7 @@ non-zero exit → `failed`. Every outcome except `completed`, `auth_failed` and
361
414
  `killed` asks the chain to hand off.
362
415
 
363
416
  <!-- limits-table:start -->
364
- Generated by `node scripts/limits-table.mjs` from 21 fixtures (4 observed-live, 17 docs-only). Classification `limit` hands the card to the next agent as a usage limit; `auth` is a failed launch (never a limit); `launch` is a failed launch that the next agent may still try; `budget` is a turn or spend cap set by Leg itself; `info` must never classify as a limit.
417
+ Generated by `node scripts/limits-table.mjs` from 22 fixtures (4 observed-live, 18 docs-only). Classification `limit` hands the card to the next agent as a usage limit; `auth` is a failed launch (never a limit); `launch` is a failed launch that the next agent may still try; `budget` is a turn or spend cap set by Leg itself; `info` must never classify as a limit.
365
418
 
366
419
  | id | adapter | class | where | source | text (excerpt) | produced by |
367
420
  |----|---------|-------|-------|--------|----------------|-------------|
@@ -383,6 +436,7 @@ Generated by `node scripts/limits-table.mjs` from 21 fixtures (4 observed-live,
383
436
  | generic-resource-exhausted | * | limit | any | **docs-only** | RESOURCE_EXHAUSTED | generic matcher (gRPC RESOURCE_EXHAUSTED); lowest priority |
384
437
  | generic-usage-limit | * | limit | any | **docs-only** | usage limit | generic matcher; lowest priority |
385
438
  | grok-not-logged-in | grok | auth | stderr | **observed-live** | To sign in, open this URL in your browser: https://accounts.x.ai/oauth2/device?user_cod | fixtures/live/grok/err.log from scripts/probe.mjs --adapter grok, 2026-09-10 (stdout JSON stopReason: Cancelled, exit 0) |
439
+ | grok-rate-limit | grok | limit | any | **docs-only** | You've hit the rate limit for your plan. Try again later. | crates/codegen/xai-grok-shell/src/sampling/error.rs:18 (RATE_LIMITED_USER_MESSAGE_OAUTH: "You've hit the rate limit for your plan. Try again later.") |
386
440
  | auth-source-set | * | auth | stderr | **docs-only** | another auth source is set | project brief (Wes, 2026-09-10): stderr saying "another auth source is set" counts as a failed launch; wording not yet observed live |
387
441
  | compile-error | * | info | stderr | **docs-only** | SyntaxError: Unexpected token ) at compileSourceTextModule (node:internal/modules/esm/ | synthetic negative fixture (a crashed agent is not a limit) |
388
442
  | empty-stdout-exit-0 | * | info | stdout | **observed-live** | | fixtures/live/grok (exit 0, no work): silence is not a limit |