@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
package/src/launcher.mjs CHANGED
@@ -16,7 +16,19 @@ import { schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
16
16
  import { enabledSyncs } from './sync/index.mjs'
17
17
 
18
18
  const SRC = dirname(fileURLToPath(import.meta.url))
19
- const SERVER = process.env.LEG_SERVER_SCRIPT || process.env.BATON_SERVER_SCRIPT || join(SRC, 'server.mjs')
19
+ function resolveServer() {
20
+ if (process.env.LEG_SERVER_SCRIPT || process.env.BATON_SERVER_SCRIPT) {
21
+ return process.env.LEG_SERVER_SCRIPT || process.env.BATON_SERVER_SCRIPT
22
+ }
23
+ const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(SRC)
24
+ if (wtMatch) {
25
+ const root = SRC.slice(0, wtMatch.index)
26
+ const mainServer = join(root, 'src', 'server.mjs')
27
+ if (existsSync(mainServer)) return mainServer
28
+ }
29
+ return join(SRC, 'server.mjs')
30
+ }
31
+ const SERVER = resolveServer()
20
32
  const VERSION = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8')).version
21
33
  const HEALTH_TIMEOUT_MS = Number(process.env.LEG_HEALTH_TIMEOUT_MS || process.env.BATON_HEALTH_TIMEOUT_MS || 20000)
22
34
 
@@ -46,6 +58,7 @@ const INSTALL_HINT = {
46
58
  claude: 'https://claude.com/claude-code (then `claude` to log in)',
47
59
  codex: 'npm i -g @openai/codex (then `codex login`)',
48
60
  agy: 'Antigravity CLI (`agy`), log in once interactively',
61
+ grok: 'xAI Grok CLI (`grok`), log in once via `grok login`',
49
62
  }
50
63
 
51
64
  // Rows: [name, status, detail]. Nothing here is fatal except "no adapter at all".
@@ -62,7 +75,7 @@ export async function preflight() {
62
75
  const v = viaNode ? version(process.execPath, [entry, '--version']) : version(bin)
63
76
  if (v) { present += 1; rows.push([name, 'ok', `${v} (${viaNode ? entry : bin})`]) } else rows.push([name, 'missing', INSTALL_HINT[name] ?? 'not on PATH'])
64
77
  }
65
- rows.push(['fake adapters', 'ok', 'fake, fake-claude, fake-codex, fake-agy (tests and demo)'])
78
+ rows.push(['fake adapters', 'ok', 'fake, fake-claude, fake-codex, fake-agy, fake-grok (tests and demo)'])
66
79
  return { rows, adapters_present: present }
67
80
  }
68
81
 
@@ -73,13 +86,13 @@ export function printPreflight({ rows }) {
73
86
 
74
87
  export function plannedProcesses({ port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), bind = process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1' } = {}) {
75
88
  const procs = [{
76
- prefix: 'server', bin: process.execPath, argv: [SERVER], env: { BATON_PORT: String(port), BATON_BIND: bind },
89
+ prefix: 'server', bin: process.execPath, argv: [SERVER], env: { LEG_PORT: String(port), LEG_BIND: bind, BATON_PORT: String(port), BATON_BIND: bind },
77
90
  note: 'board + API + scheduler + merge queue',
78
91
  }]
79
92
  const on = enabledSyncs()
80
93
  const syncs = [
81
- { prefix: 'sync:workboard', enabled: on.includes('workboard'), note: 'OpenClaw Workboard mirror (BATON_SYNC_WORKBOARD=1); runs inside the ledger, no extra process' },
82
- { prefix: 'sync:dashclaw', enabled: on.includes('dashclaw'), note: 'DashClaw action recording (BATON_SYNC_DASHCLAW=1 + DASHCLAW_URL + DASHCLAW_API_KEY); runs inside the ledger' },
94
+ { prefix: 'sync:workboard', enabled: on.includes('workboard'), note: 'OpenClaw Workboard mirror (LEG_SYNC_WORKBOARD=1); runs inside the ledger, no extra process' },
95
+ { prefix: 'sync:dashclaw', enabled: on.includes('dashclaw'), note: 'DashClaw action recording (LEG_SYNC_DASHCLAW=1 + DASHCLAW_URL + DASHCLAW_API_KEY); runs inside the ledger' },
83
96
  ]
84
97
  return { procs, syncs }
85
98
  }
@@ -159,21 +172,21 @@ function killActiveAgents() {
159
172
  }
160
173
 
161
174
  export async function up({ dry = false, open = true, port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), bind = process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1' } = {}) {
162
- out('baton', `baton ${VERSION} — home ${home()}`)
175
+ out('leg', `leg ${VERSION} — home ${home()}`)
163
176
  const pf = await preflight()
164
177
  printPreflight(pf)
165
- if (pf.adapters_present === 0) out('baton', 'no real coding-agent CLI found; fake adapters still work for the demo', process.stderr)
178
+ if (pf.adapters_present === 0) out('leg', 'no real coding-agent CLI found; fake adapters still work for the demo', process.stderr)
166
179
  const plan = plannedProcesses({ port, bind })
167
- for (const s of plan.syncs) out('baton', `${s.prefix}: ${s.enabled ? 'on' : 'off'} (${s.note})`)
180
+ for (const s of plan.syncs) out('leg', `${s.prefix}: ${s.enabled ? 'on' : 'off'} (${s.note})`)
168
181
  if (dry) {
169
- out('baton', 'dry run: nothing spawned. Would run:')
170
- for (const p of plan.procs) out('baton', `${p.prefix}: ${JSON.stringify([p.bin, ...p.argv])} env ${JSON.stringify(p.env)}`)
171
- out('baton', `then poll http://${bind}:${port}/api/health, ${open ? 'open the board' : 'not open the board'}, write ${pidfile()}`)
182
+ out('leg', 'dry run: nothing spawned. Would run:')
183
+ for (const p of plan.procs) out('leg', `${p.prefix}: ${JSON.stringify([p.bin, ...p.argv])} env ${JSON.stringify(p.env)}`)
184
+ out('leg', `then poll http://${bind}:${port}/api/health, ${open ? 'open the board' : 'not open the board'}, write ${pidfile()}`)
172
185
  return 0
173
186
  }
174
187
  const existing = readPidfile()
175
188
  if (existing && existing.pid !== process.pid && await boardAlive(existing)) {
176
- out('baton', `already running (pid ${existing.pid}, port ${existing.port}); use \`baton down\` first`, process.stderr)
189
+ out('leg', `already running (pid ${existing.pid}, port ${existing.port}); use \`leg down\` first`, process.stderr)
177
190
  return 1
178
191
  }
179
192
  if (existing) { try { rmSync(pidfile(), { force: true }) } catch {} }
@@ -196,34 +209,34 @@ export async function up({ dry = false, open = true, port = Number(process.env.L
196
209
  await new Promise((r) => setTimeout(r, 250))
197
210
  }
198
211
  if (!ok) {
199
- out('baton', `server not healthy after ${Math.round((Date.now() - t0) / 1000)} s${exited !== null ? ` (exited ${exited})` : ''}; last lines:`, process.stderr)
212
+ out('leg', `server not healthy after ${Math.round((Date.now() - t0) / 1000)} s${exited !== null ? ` (exited ${exited})` : ''}; last lines:`, process.stderr)
200
213
  for (const l of lastLines) out(p.prefix, l, process.stderr)
201
214
  killTree(child.pid)
202
215
  return 1
203
216
  }
204
217
  const url = `http://${bind === '0.0.0.0' ? '127.0.0.1' : bind}:${actualPort}`
205
218
  writeFileSync(pidfile(), JSON.stringify({ pid: process.pid, port: actualPort, bind, children: [child.pid], started_at: new Date().toISOString() }, null, 2) + '\n')
206
- out('baton', `ready ${url} (scheduler max ${MAX_CONCURRENT}, ${ok.cards} card${ok.cards === 1 ? '' : 's'})`)
207
- if (open) out('baton', openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
208
- out('baton', 'Ctrl-C stops everything')
219
+ out('leg', `ready ${url} (scheduler max ${MAX_CONCURRENT}, ${ok.cards} card${ok.cards === 1 ? '' : 's'})`)
220
+ if (open) out('leg', openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
221
+ out('leg', 'Ctrl-C stops everything')
209
222
 
210
223
  return await new Promise((resolvePromise) => {
211
224
  let stopping = false
212
225
  const stop = (why) => {
213
226
  if (stopping) return
214
227
  stopping = true
215
- out('baton', `stopping (${why})`)
228
+ out('leg', `stopping (${why})`)
216
229
  const agents = killActiveAgents()
217
- if (agents) out('baton', `killed ${agents} running agent/supervisor process(es)`)
230
+ if (agents) out('leg', `killed ${agents} running agent/supervisor process(es)`)
218
231
  killTree(child.pid)
219
232
  try { rmSync(pidfile(), { force: true }) } catch {}
220
- out('baton', 'stopped')
233
+ out('leg', 'stopped')
221
234
  resolvePromise(0)
222
235
  }
223
236
  process.on('SIGINT', () => stop('SIGINT'))
224
237
  process.on('SIGTERM', () => stop('SIGTERM'))
225
238
  process.on('SIGBREAK', () => stop('SIGBREAK'))
226
- child.on('exit', (code) => { if (!stopping) { out('baton', `server exited ${code}`, process.stderr); try { rmSync(pidfile(), { force: true }) } catch {} resolvePromise(code === 0 ? 0 : 1) } })
239
+ child.on('exit', (code) => { if (!stopping) { out('leg', `server exited ${code}`, process.stderr); try { rmSync(pidfile(), { force: true }) } catch {} resolvePromise(code === 0 ? 0 : 1) } })
227
240
  })
228
241
  }
229
242
 
@@ -240,10 +253,10 @@ export async function stopBoard() {
240
253
 
241
254
  export async function down() {
242
255
  const pf = readPidfile()
243
- if (!pf) { out('baton', 'not running'); return 0 }
256
+ if (!pf) { out('leg', 'not running'); return 0 }
244
257
  const agents = killActiveAgents()
245
258
  const board = await stopBoard()
246
- out('baton', `stopped (pid ${pf.pid}, port ${pf.port}${agents ? `, ${agents} agent process(es) killed` : ''}${board.stale ? ', stale pidfile' : ''})`)
259
+ out('leg', `stopped (pid ${pf.pid}, port ${pf.port}${agents ? `, ${agents} agent process(es) killed` : ''}${board.stale ? ', stale pidfile' : ''})`)
247
260
  return 0
248
261
  }
249
262
 
@@ -255,14 +268,14 @@ export async function status() {
255
268
  const by = (key) => Object.entries(cards.reduce((m, c) => { m[c[key]] = (m[c[key]] ?? 0) + 1; return m }, {})).map(([k, v]) => `${k}=${v}`).join(' ') || '(none)'
256
269
  if (running) {
257
270
  const up = Math.round((Date.now() - Date.parse(pf.started_at)) / 1000)
258
- out('baton', `running pid ${pf.pid} port ${pf.port} up ${Math.floor(up / 60)}m${up % 60}s http://127.0.0.1:${pf.port}`)
271
+ out('leg', `running pid ${pf.pid} port ${pf.port} up ${Math.floor(up / 60)}m${up % 60}s http://127.0.0.1:${pf.port}`)
259
272
  } else {
260
- out('baton', pf ? `stopped (pid ${pf.pid} is not answering on port ${pf.port}; cleared the stale pidfile)` : 'stopped')
273
+ out('leg', pf ? `stopped (pid ${pf.pid} is not answering on port ${pf.port}; cleared the stale pidfile)` : 'stopped')
261
274
  }
262
275
  const s = schedulerStatus()
263
- out('baton', `scheduler ${s.running ? `running (pid ${s.pid})` : 'stopped'} max concurrent ${MAX_CONCURRENT}`)
264
- out('baton', `cards ${cards.length} by status: ${by('status')}`)
265
- out('baton', `by station: ${by('station')}`)
276
+ out('leg', `scheduler ${s.running ? `running (pid ${s.pid})` : 'stopped'} max concurrent ${MAX_CONCURRENT}`)
277
+ out('leg', `cards ${cards.length} by status: ${by('status')}`)
278
+ out('leg', `by station: ${by('station')}`)
266
279
  return running ? 0 : 3
267
280
  }
268
281
 
package/src/ledger.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
3
- // ledger — the ONLY writer of Baton's card ledger files under $BATON_HOME/cards/<id>/.
3
+ // ledger — the ONLY writer of Leg's card ledger files under $LEG_HOME/cards/<id>/.
4
4
  // Subcommands: create | append | update | sync. Every event carries a validated
5
5
  // actor, the card id, the station and the leg, and lands in that actor's own
6
6
  // events-<actor-key>.jsonl. Importable: readEvents, parseActor, actorKey.
@@ -17,16 +17,16 @@ export const EVENT_TYPES = ['card_created', 'leg_started', 'leg_progress', 'leg_
17
17
  'limit_detected', 'handoff_written', 'leg_resumed', 'station_done', 'bounced', 'landed',
18
18
  'land_warning', 'land_retry', 'blocked_by', 'scheduler_started', 'scheduler_stopped',
19
19
  'approval_needed', 'approved', 'reassigned', 'paused', 'resumed', 'killed', 'done',
20
- 'failed', 'error', 'status']
20
+ 'failed', 'error', 'status', 'harness', 'harness_blocked']
21
21
  export const STATUSES = ['backlog', 'queued', 'running', 'handing_off', 'waiting_human',
22
22
  'needs_approval', 'paused', 'done', 'failed', 'killed']
23
23
  const CLOSED = ['done', 'failed', 'killed']
24
24
  // card.json keys `update --patch` may set (everything else goes through a named flag)
25
25
  export const PATCHABLE = ['pipeline', 'leases', 'land_attempts', 'land_mode', 'test_command', 'title', 'trunk',
26
- 'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url']
26
+ 'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url', 'harness']
27
27
  const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i
28
28
 
29
- export const ROOT = process.env.BATON_HOME || join(homedir(), '.baton')
29
+ export const ROOT = process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg'))
30
30
 
31
31
  function die(code, msg) {
32
32
  process.stderr.write(msg + '\n')
@@ -53,7 +53,7 @@ function need(args, key, allowed) {
53
53
  return v
54
54
  }
55
55
 
56
- // Actor: who wrote the event. {type:'agent', adapter, model?} | {type:'human', id} | {type:'baton'}.
56
+ // Actor: who wrote the event. {type:'agent', adapter, model?} | {type:'human', id} | {type:'leg'} | {type:'baton'} (legacy).
57
57
  // Returns the normalized actor or null when the shape is wrong.
58
58
  export function parseActor(raw) {
59
59
  let a = raw
@@ -156,7 +156,7 @@ async function syncNotify(kind, ev, card) {
156
156
  kind, ev, card, home: ROOT,
157
157
  report: (summary) => {
158
158
  if (!id) return
159
- appendEvent(id, { ts: now(), card_id: id, actor: { type: 'baton' }, station: card?.station ?? '-', leg: card?.leg ?? 0, type: 'status', summary })
159
+ appendEvent(id, { ts: now(), card_id: id, actor: { type: 'leg' }, station: card?.station ?? '-', leg: card?.leg ?? 0, type: 'status', summary })
160
160
  },
161
161
  })
162
162
  } catch {}
package/src/license.mjs CHANGED
@@ -1,34 +1,35 @@
1
1
  // license — the paid-product gate. A license key is a signed, self-contained
2
- // token that Baton checks offline with the public key embedded below; the
2
+ // token that Leg checks offline with the public key embedded below; the
3
3
  // private key never leaves the seller's machine. Two plans:
4
4
  //
5
5
  // personal one-time purchase; every release dated on or before the key's
6
6
  // updates_until activates, later releases refuse the key but the
7
7
  // installed one keeps working (the Sublime Text shape)
8
8
  // team per-seat subscription; the key carries an expiry a few days past
9
- // the billing period, `baton license refresh` fetches a renewed one
9
+ // the billing period, `leg license refresh` fetches a renewed one
10
10
  //
11
- // There is no trial. Baton pays off in the moment a limit lands mid-flow, which
11
+ // There is no trial. Leg pays off in the moment a limit lands mid-flow, which
12
12
  // is not a thing a fortnight of evaluation reliably contains; the risk reversal
13
13
  // is a 30-day money-back guarantee instead, which costs no code and no expiry
14
- // machinery. Key shape: BATON-<base64url payload>.<base64url signature> where
15
- // the signature is Ed25519 over the payload bytes exactly as encoded.
14
+ // machinery. Key shape: LEG-<base64url payload>.<base64url signature> (legacy
15
+ // BATON- prefix still accepted) where the signature is Ed25519 over the payload
16
+ // bytes exactly as encoded.
16
17
  import { createPublicKey, createPrivateKey, verify as cryptoVerify, sign as cryptoSign, createHash } from 'node:crypto'
17
18
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, chmodSync } from 'node:fs'
18
19
  import { join } from 'node:path'
19
20
  import { home } from './store.mjs'
20
21
 
21
22
  export const PUBLIC_KEY_B64 = 'MCowBQYDK2VwAyEAIpVQymHHJAkIrZHv0u4o0bgfFmtW3Crm7uMwYHP53X8='
22
- // The suite has to exercise the gate itself — `baton share on` refusing a
23
- // Personal key, `baton <agent>` refusing nothing at all — and it cannot sign a
23
+ // The suite has to exercise the gate itself — `leg share on` refusing a
24
+ // Personal key, `leg <agent>` refusing nothing at all — and it cannot sign a
24
25
  // key for the real public key, which is the point of the real public key. This
25
26
  // env seam lets a spawned CLI verify against a throwaway pair. It weakens
26
- // nothing: Baton ships as readable JavaScript, so anyone who would set this
27
+ // nothing: Leg ships as readable JavaScript, so anyone who would set this
27
28
  // could edit the constant above instead.
28
29
  const ACTIVE_PUBLIC_KEY = process.env.LEG_PUBLIC_KEY_B64 || process.env.BATON_PUBLIC_KEY_B64 || PUBLIC_KEY_B64
29
30
  // The date this release was cut. A personal key activates when this is on or
30
31
  // before its updates_until. Bumped with every published version.
31
- export const RELEASE_DATE = '2026-09-15'
32
+ export const RELEASE_DATE = '2026-09-16'
32
33
  export const GUARANTEE_DAYS = 30
33
34
  export const SITE = process.env.LEG_SITE || process.env.BATON_SITE || 'https://legcli.com'
34
35
  export const BUY_URL = `${SITE}/#pricing`
@@ -3,7 +3,7 @@
3
3
  // keep the payload with secrets scrubbed, so the docs rows that say
4
4
  // "docs-only" can become "observed-live" with evidence. One file per
5
5
  // (agent, signal); a later arrival never overwrites the first. A payload a
6
- // `baton sessions simulate-limit` produced is marked and never captured.
6
+ // `leg sessions simulate-limit` produced is marked and never captured.
7
7
  // Where: BATON_LIVE_DIR, else this checkout's fixtures/live/ when it exists
8
8
  // (a dev clone), else <BATON_HOME>/live/. In a dev clone the matching docs
9
9
  // rows are flipped in the same call (scripts/live-limits.mjs).
@@ -1,6 +1,6 @@
1
1
  // mergequeue — the land station. One land at a time per repo root, FIFO: the
2
2
  // queue key is the canonical root (two spellings of one path are one queue) and
3
- // the turn itself is a file lock under BATON_HOME, so a `baton card run` CLI
3
+ // the turn itself is a file lock under LEG_HOME, so a `leg card run` CLI
4
4
  // and the board server cannot land into one checkout at the same time.
5
5
  // land(card, worktree):
6
6
  // 1. root must be on <trunk> and clean, else bounce `dirty-trunk` (root untouched)
@@ -57,7 +57,7 @@ export function rootState(repo, trunk) {
57
57
  }
58
58
 
59
59
  // Commit the agents' work so the branch can be rebased and merged. The commit
60
- // skips git hooks (--no-verify): the repo's test command is Baton's gate, and
60
+ // skips git hooks (--no-verify): the repo's test command is Leg's gate, and
61
61
  // interactive commit hooks (linters, wire-dark style checks) belong to humans
62
62
  // typing commits. BATON_COMMIT_VERIFY=1 runs them anyway.
63
63
  export function commitWorktree(worktree, message) {
@@ -65,7 +65,7 @@ export function commitWorktree(worktree, message) {
65
65
  if (!dirty.length) return { committed: false }
66
66
  git(worktree, ['add', '-A'])
67
67
  const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
68
- git(worktree, ['-c', 'user.email=baton@localhost', '-c', 'user.name=baton', 'commit', '-q', ...verify, '-m', message])
68
+ git(worktree, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', message])
69
69
  return { committed: true, files: dirty.length }
70
70
  }
71
71
 
@@ -82,7 +82,7 @@ export function resolveTestCommand(card, worktree) {
82
82
  return { command: null, source: 'none' }
83
83
  }
84
84
 
85
- function runTests(command, worktree) {
85
+ export function runTests(command, worktree) {
86
86
  return runCommandAsync(command, worktree, { timeoutMs: TEST_TIMEOUT_MS, tailLines: 40 })
87
87
  }
88
88
 
@@ -147,7 +147,7 @@ async function landNow(card, worktree, { onWarning = () => {}, allowDirtyRoot =
147
147
  if (checkedOut !== branch) return bounce('worktree-branch', `worktree is on ${checkedOut}, not ${branch}; switch it back before landing`)
148
148
 
149
149
  const busy = operationInProgress(worktree)
150
- if (busy) return bounce('worktree-busy', `a ${busy.replace(/-/g, ' ')} is already in progress in ${worktree}; finish or abort it there before landing (Baton will not touch a rebase it did not start)`)
150
+ if (busy) return bounce('worktree-busy', `a ${busy.replace(/-/g, ' ')} is already in progress in ${worktree}; finish or abort it there before landing (Leg will not touch a rebase it did not start)`)
151
151
 
152
152
  const root = rootState(repo, trunk)
153
153
  if (!root.onTrunk) return bounce('dirty-trunk', `repo root is on ${root.branch}, not ${trunk}; check out ${trunk} and retry`)
@@ -155,7 +155,7 @@ async function landNow(card, worktree, { onWarning = () => {}, allowDirtyRoot =
155
155
  // nature; git's own fast-forward still refuses to overwrite a local change
156
156
  if (root.dirty.length && !allowDirtyRoot) return bounce('dirty-trunk', `repo root has ${root.dirty.length} uncommitted change(s): ${root.dirty.slice(0, 10).join(', ')}`)
157
157
 
158
- const committed = commitWorktree(worktree, `baton: ${card.title ?? card.card_id}`)
158
+ const committed = commitWorktree(worktree, `leg: ${card.title ?? card.card_id}`)
159
159
  const preSha = git(worktree, ['rev-parse', 'HEAD']).stdout.trim()
160
160
  const trunkBefore = trunkHead(repo)
161
161
 
@@ -18,6 +18,7 @@ import {
18
18
  RUNNER, BATON_ACTOR, readCard, ledgerAppend, ledgerUpdate, cardDir, sleep,
19
19
  } from './store.mjs'
20
20
  import { scrub, updateRun } from './runner.mjs'
21
+ import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
21
22
  import * as agentStation from './stations/agent.mjs'
22
23
  import * as testStation from './stations/test.mjs'
23
24
  import * as humanStation from './stations/human.mjs'
@@ -26,13 +27,16 @@ import * as humanStation from './stations/human.mjs'
26
27
  // the merge queue). Each handler gets the orchestrator's helpers as `ops`.
27
28
  const KIND_HANDLERS = { agent: agentStation, test: testStation, human: humanStation }
28
29
 
29
- const POLL_MS = Number(process.env.BATON_POLL_MS || 2000)
30
+ const POLL_MS = Number(process.env.LEG_POLL_MS || process.env.BATON_POLL_MS || 2000)
30
31
  const WAITING = ['done', 'failed', 'killed', 'paused', 'waiting_human', 'needs_approval']
31
32
  // Patchable card keys the chain machine may change; everything else is a
32
33
  // named ledger flag.
33
34
  const PATCH_KEYS = ['pipeline', 'leases', 'land_attempts', 'bounce_reason', 'kill_requested', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'pr_url']
34
35
 
35
- const log = (msg) => { if (process.env.BATON_QUIET !== '1') process.stderr.write(`[baton] ${msg}\n`) }
36
+ const log = (msg) => {
37
+ const quiet = (process.env.LEG_QUIET === '0' || process.env.BATON_QUIET === '0') ? false : (process.env.LEG_QUIET === '1' || process.env.BATON_QUIET === '1')
38
+ if (!quiet) process.stderr.write(`[leg] ${msg}\n`)
39
+ }
36
40
 
37
41
  // Persist a chain transition: card fields via ledger update, events via append.
38
42
  export function apply(id, before, result, actor = BATON_ACTOR) {
@@ -70,7 +74,7 @@ function latestRun(id) {
70
74
  }
71
75
 
72
76
  // A run whose verdict no orchestrator has consumed: the latest run has no
73
- // settled_at. Happens after `baton down` (agents killed, the supervisor wrote
77
+ // settled_at. Happens after `leg down` (agents killed, the supervisor wrote
74
78
  // its verdict, the server that would apply it was already gone) or a crashed
75
79
  // server. runCard re-attaches to it instead of launching a fresh leg.
76
80
  export function unsettledRun(id) {
@@ -176,7 +180,7 @@ async function waitForRun(id, n, { pollMs = POLL_MS } = {}) {
176
180
  function changedFiles(worktree) {
177
181
  const r = spawnSync('git', ['status', '--porcelain'], { cwd: worktree, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
178
182
  if (r.status !== 0) return []
179
- return r.stdout.split(/\r?\n/).filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => f && !f.startsWith('.baton'))
183
+ return r.stdout.split(/\r?\n/).filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => f && !f.startsWith('.baton') && !f.startsWith('.leg'))
180
184
  }
181
185
 
182
186
  function diffStat(worktree) {
@@ -197,6 +201,26 @@ async function runLeg(card, station, worktree) {
197
201
  const prompt = legPrompt({ contractText, resumeText })
198
202
  const promptFile = join(cardDir(card.card_id), `prompt-${station.name}-leg${card.leg}.txt`)
199
203
  writeFileSync(promptFile, prompt)
204
+ // The portable harness for the adapter about to run (src/harness/index.mjs):
205
+ // nothing when the feature is off; otherwise the card keeps the outcome and
206
+ // the ledger says what transferred. Under the strict policy a destination
207
+ // that cannot be made safe is not launched: the leg fails as launch_failed,
208
+ // which never advances the chain, and a human fixes it and reruns.
209
+ const previous = card.leg > 0 ? station.chain[card.leg - 1]?.adapter ?? null : null
210
+ let harness
211
+ try { harness = prepareHarnessForHandoff({ from: previous, to: entry.adapter, sessionId: card.card_id }) } catch (err) { harness = { state: 'error', proceed: true, to: entry.adapter, reason: String(err.message).slice(0, 200), summary: `harness error: ${String(err.message).slice(0, 120)}` } }
212
+ if (harness.state !== 'off') {
213
+ // the card keeps the verdict; the full record (components, dropped items,
214
+ // files, backups) is in $LEG_HOME/harness/history.jsonl, and the ledger's
215
+ // argv is not the place for it
216
+ const { state, target, source = null, policy, fingerprint = null, captured_at = null, synced_at = null, summary = null, proceed, reason = null } = harness
217
+ ledgerUpdate(card.card_id, { patch: { harness: { state, target, source, policy, fingerprint, captured_at, synced_at, summary, proceed, reason, dropped: harness.dropped?.length ?? 0, attention: harness.attention?.length ?? 0 } } })
218
+ const line = harnessLine(harness)
219
+ // the ledger refuses a line that looks like a secret by exiting; evidence
220
+ // about the harness must never be what fails a leg
221
+ if (line) { try { ledgerAppend(card.card_id, { type: harness.proceed ? 'harness' : 'harness_blocked', station: station.name, leg: card.leg, summary: line, body: [...(harness.attention ?? []).map((a) => `attention ${a.component}: ${a.reason}`), ...(harness.dropped ?? []).map((d) => `dropped ${d.component}: ${d.item}: ${d.reason}`)].join('\n') || undefined }) } catch (err) { log(`harness event not recorded: ${scrub(String(err.message)).slice(0, 160)}`) } }
222
+ if (!harness.proceed) return { status: 'failed', outcome: 'launch_failed', handoff: true, signal: 'none', reason: `strict harness policy refused ${entry.adapter}: ${harness.reason ?? harness.state}`, run: null, exit_code: null }
223
+ }
200
224
  const args = ['launch', '--card', card.card_id, '--adapter', entry.adapter, '--prompt-file', promptFile, '--cwd', worktree, '--driver-pid', String(process.pid)]
201
225
  if (entry.mode) args.push('--mode', entry.mode)
202
226
  if (entry.maxTurns) args.push('--max-turns', String(entry.maxTurns))
@@ -1,4 +1,4 @@
1
- // Machine-wide defaults for interactive Baton terminals. A new terminal takes
1
+ // Machine-wide defaults for interactive Leg terminals. A new terminal takes
2
2
  // a copy of these preferences when it starts; later edits do not silently
3
3
  // change terminals that are already running.
4
4
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
@@ -7,11 +7,15 @@ import { home } from './store.mjs'
7
7
  import { writeJsonAtomic, withFileLock } from './fsx.mjs'
8
8
 
9
9
  export const HANDOFF_AGENTS = ['claude', 'codex', 'agy']
10
+ export const ALL_HANDOFF_AGENTS = ['claude', 'codex', 'agy', 'grok']
10
11
 
11
12
  export function validHandoffOrder(value) {
12
- return Array.isArray(value) && value.length === HANDOFF_AGENTS.length &&
13
- new Set(value).size === HANDOFF_AGENTS.length &&
14
- value.every((agent) => HANDOFF_AGENTS.includes(agent))
13
+ if (!Array.isArray(value)) return false
14
+ const set = new Set(value)
15
+ if (set.size !== value.length) return false
16
+ if (value.length === 3 && ['claude', 'codex', 'agy'].every((a) => set.has(a))) return true
17
+ if (value.length === 4 && ['claude', 'codex', 'agy', 'grok'].every((a) => set.has(a))) return true
18
+ return false
15
19
  }
16
20
 
17
21
  export function normalizeHandoffOrder(value) {
@@ -23,24 +27,74 @@ export function requireHandoffOrder(value) {
23
27
  return [...value]
24
28
  }
25
29
 
30
+ // Portable harness (src/harness/): off for every existing install. `enabled`
31
+ // is the explicit consent `leg harness enable` records; `policy` is what an
32
+ // unattended hand-off may do (warn: report only; sync: write managed state
33
+ // when it is safe; strict: refuse a destination that cannot be made safe);
34
+ // `source` is the client whose harness is the one being carried.
35
+ export const HARNESS_POLICIES = ['warn', 'sync', 'strict']
36
+ export const HARNESS_SOURCES = ['claude', 'codex']
37
+ export const HARNESS_DEFAULTS = Object.freeze({ enabled: false, policy: 'warn', source: null })
38
+
39
+ export function normalizeHarness(value) {
40
+ const v = value && typeof value === 'object' ? value : {}
41
+ return {
42
+ enabled: v.enabled === true,
43
+ policy: HARNESS_POLICIES.includes(v.policy) ? v.policy : HARNESS_DEFAULTS.policy,
44
+ source: HARNESS_SOURCES.includes(v.source) ? v.source : null,
45
+ }
46
+ }
47
+
48
+ export function requireHarness(patch, current = HARNESS_DEFAULTS) {
49
+ const next = { ...normalizeHarness(current) }
50
+ if (patch?.enabled !== undefined) next.enabled = Boolean(patch.enabled)
51
+ if (patch?.policy !== undefined) {
52
+ if (!HARNESS_POLICIES.includes(patch.policy)) throw new TypeError(`harness policy must be one of ${HARNESS_POLICIES.join(', ')}`)
53
+ next.policy = patch.policy
54
+ }
55
+ if (patch?.source !== undefined) {
56
+ if (patch.source !== null && !HARNESS_SOURCES.includes(patch.source)) throw new TypeError(`harness source must be one of ${HARNESS_SOURCES.join(', ')}`)
57
+ next.source = patch.source
58
+ }
59
+ return next
60
+ }
61
+
26
62
  export function preferencesFile() { return join(home(), 'preferences.json') }
27
63
 
64
+ export function resolveAutoApprove({ env = process.env, preferences = null, cliFlag = null } = {}) {
65
+ if (cliFlag !== null && cliFlag !== undefined) return Boolean(cliFlag)
66
+ const envVal = env.LEG_AUTO_APPROVE ?? env.BATON_AUTO_APPROVE
67
+ if (envVal !== undefined) return envVal !== '0' && envVal !== 'false' && envVal !== 'off'
68
+ if ((env.LEG_NO_AUTO_APPROVE ?? env.BATON_NO_AUTO_APPROVE) === '1') return false
69
+ const prefs = preferences ?? readPreferences()
70
+ if (typeof prefs?.auto_approve === 'boolean') return prefs.auto_approve
71
+ return true
72
+ }
73
+
28
74
  export function readPreferences() {
29
75
  const file = preferencesFile()
30
- if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS] }
76
+ if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
31
77
  try {
32
78
  const value = JSON.parse(readFileSync(file, 'utf8'))
33
- return { handoff_order: normalizeHandoffOrder(value?.handoff_order) }
79
+ return {
80
+ handoff_order: normalizeHandoffOrder(value?.handoff_order),
81
+ auto_approve: value?.auto_approve !== false,
82
+ harness: normalizeHarness(value?.harness),
83
+ }
34
84
  } catch {
35
- return { handoff_order: [...HANDOFF_AGENTS] }
85
+ return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
36
86
  }
37
87
  }
38
88
 
39
89
  export function writePreferences(patch) {
40
- const order = requireHandoffOrder(patch?.handoff_order)
90
+ const order = patch?.handoff_order !== undefined ? requireHandoffOrder(patch?.handoff_order) : undefined
41
91
  mkdirSync(home(), { recursive: true })
42
92
  return withFileLock(preferencesFile() + '.lock', () => {
43
- const next = { ...readPreferences(), handoff_order: order }
93
+ const current = readPreferences()
94
+ const next = { ...current }
95
+ if (order !== undefined) next.handoff_order = order
96
+ if (patch?.auto_approve !== undefined) next.auto_approve = Boolean(patch.auto_approve)
97
+ if (patch?.harness !== undefined) next.harness = requireHarness(patch.harness, current.harness)
44
98
  writeJsonAtomic(preferencesFile(), next)
45
99
  return next
46
100
  })
package/src/redact.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // redact — the one list of secret shapes. `scrub()` rewrites (logs, bundles,
2
2
  // launcher output); the ledger's assertNoSecrets refuses (src/ledger.mjs).
3
- // The source tooling kept two copies on purpose; Baton keeps one here.
3
+ // The source tooling kept two copies on purpose; Leg keeps one here.
4
4
  // Values the launcher's own process holds for the well-known key variables
5
5
  // are read once at startup and never printed.
6
6
  // Every prefix shape starts at a token boundary: the `sk-` inside
package/src/resume.mjs CHANGED
@@ -1,17 +1,18 @@
1
1
  // resume — the pointer that cannot describe a picture that is no longer true.
2
2
  //
3
- // `.baton/RESUME.md` is the file humans and other agents open by habit. It used
3
+ // `.leg/RESUME.md` is the file humans and other agents open by habit (legacy
4
+ // `.baton/RESUME.md` is still read). It used
4
5
  // to be an unowned convenience copy: written once per hand-off, never touched
5
6
  // again, with no stamp and no expiry, so a normally exited terminal left hours
6
7
  // old text sitting there looking live.
7
8
  //
8
9
  // Two rules fix that, and they are the whole module:
9
- // 1. Every resume file Baton writes carries a stamp of the git state and the
10
+ // 1. Every resume file Leg writes carries a stamp of the git state and the
10
11
  // live terminals it was written against (an HTML comment, invisible in
11
12
  // rendered markdown).
12
13
  // 2. Freshness is never remembered — it is recomputed from git at READ time.
13
14
  // A file cannot lie about HEAD to a reader who re-asks git.
14
- // Baton owns the file: it rewrites it when a session ends and when the board
15
+ // Leg owns the file: it rewrites it when a session ends and when the board
15
16
  // starts, so nothing is left describing a terminal that is gone.
16
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
18
  import { spawnSync } from 'node:child_process'
@@ -24,8 +25,8 @@ export const STAMP_PREFIX = '<!-- leg-resume '
24
25
  export const LEGACY_STAMP_PREFIX = '<!-- baton-resume '
25
26
  const STAMP_SUFFIX = ' -->'
26
27
  const STAMP_VERSION = 1
27
- // Baton's own directories dirty the tree on every write; a reader must not see
28
- // Baton's bookkeeping as the human's work moving on.
28
+ // Leg's own directories dirty the tree on every write; a reader must not see
29
+ // Leg's bookkeeping as the human's work moving on.
29
30
  const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
30
31
  const MAX_REASONS = 5
31
32
 
@@ -81,6 +82,7 @@ export function perSessionFile(cwd, id) {
81
82
  if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
82
83
  return leg
83
84
  }
85
+ export { synthesisFile } from './synthesis.mjs'
84
86
 
85
87
  // An agent started deeper in the tree still finds its checkout's pointer.
86
88
  export function findResume(startDir) {
@@ -117,7 +119,7 @@ export function readStamp(text) {
117
119
  } catch { return null }
118
120
  }
119
121
 
120
- // The text without its stamp: what a human reads and what `baton resume` prints.
122
+ // The text without its stamp: what a human reads and what `leg resume` prints.
121
123
  export function bodyOf(text) {
122
124
  const s = String(text ?? '')
123
125
  return readStamp(s) ? s.slice(s.indexOf('\n') + 1).replace(/^\n+/, '') : s
@@ -200,10 +202,10 @@ export function lastHandoffIn(root, sessions = listSessions()) {
200
202
  }
201
203
 
202
204
  function idleBody(last, live) {
203
- const lines = ['# Baton: nothing in flight', '']
205
+ const lines = ['# Leg: nothing in flight', '']
204
206
  lines.push(live.length
205
207
  ? `No hand-off is waiting to be picked up here. Still live in this checkout: ${live.map((s) => `${s.session_id} (${s.agent})`).join(', ')}.`
206
- : 'No Baton terminal is live in this checkout.')
208
+ : 'No Leg terminal is live in this checkout.')
207
209
  lines.push('')
208
210
  if (last) {
209
211
  const at = last.at ? String(last.at).slice(0, 16).replace('T', ' ') : 'an unrecorded time'
@@ -221,7 +223,7 @@ function idleBody(last, live) {
221
223
  }
222
224
 
223
225
  // The "nothing in flight" pointer. Always writes; the callers that must not
224
- // create a file in a checkout Baton never handed off in check first.
226
+ // create a file in a checkout Leg never handed off in check first.
225
227
  export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
226
228
  if (!root) return null
227
229
  const dir = existsSync(join(root, '.baton')) && !existsSync(join(root, '.leg')) ? join(root, '.baton') : join(root, '.leg')
@@ -235,7 +237,7 @@ export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
235
237
  }
236
238
 
237
239
  // A session ending must not leave its hand-off sitting there looking live.
238
- // Only ever rewrites a pointer that already exists: Baton owns RESUME.md where
240
+ // Only ever rewrites a pointer that already exists: Leg owns RESUME.md where
239
241
  // it wrote one, and creates none in a checkout it never handed off in.
240
242
  export function endSessionPointer(session) {
241
243
  const root = workRoot(session)
@@ -243,7 +245,7 @@ export function endSessionPointer(session) {
243
245
  return writeIdlePointer(root)
244
246
  }
245
247
 
246
- // Board start: every checkout Baton wrote a pointer in gets it recomputed, so a
248
+ // Board start: every checkout Leg wrote a pointer in gets it recomputed, so a
247
249
  // terminal that crashed instead of exiting cannot leave a live-looking hand-off
248
250
  // behind. A checkout whose terminal really is live keeps its hand-off text.
249
251
  // Returns the roots rewritten.
@@ -260,9 +262,9 @@ export function refreshPointers() {
260
262
  const v = resumeVerdict(root, { sessions })
261
263
  if (v.state === 'fresh') continue
262
264
  // The terminal that wrote a hand-off owns it while it is still running: its
263
- // text is the live description, and "the repo moved on" is for `baton resume
265
+ // text is the live description, and "the repo moved on" is for `leg resume
264
266
  // --check` to report, not for the board to overwrite. Anything else — a
265
- // hand-off from a terminal that is gone, a file no Baton stamped — is
267
+ // hand-off from a terminal that is gone, a file no Leg stamped — is
266
268
  // replaced even when some OTHER terminal happens to be live in the
267
269
  // checkout, which is the case that left three day old text sitting there.
268
270
  if (v.session?.active) continue
@@ -284,14 +286,14 @@ function describe(list) { return list.map((s) => `${s.id ?? s.session_id} (${s.a
284
286
  export function resumeVerdict(cwd, { sessions = listSessions() } = {}) {
285
287
  const found = findResume(cwd)
286
288
  if (!found) {
287
- return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .baton/RESUME.md in this checkout'], summary: 'no resume pointer in this checkout', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
289
+ return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .leg/RESUME.md in this checkout'], summary: 'no resume pointer in this checkout', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
288
290
  }
289
291
  const { root, file } = found
290
292
  let text = ''
291
293
  try { text = readFileSync(file, 'utf8') } catch { /* raced a rewrite */ }
292
294
  const stamp = readStamp(text)
293
295
  if (!stamp) {
294
- return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no Baton stamp, so its freshness cannot be checked against git'], summary: 'cannot be checked: no Baton stamp', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
296
+ return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no Leg stamp, so its freshness cannot be checked against git'], summary: 'cannot be checked: no Leg stamp', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
295
297
  }
296
298
 
297
299
  const now = gitState(root)