@ucsandman/legcli 0.8.0 → 0.10.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 (125) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/NOTICE +8 -0
  3. package/README.md +639 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +43 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +205 -0
  8. package/docs/README.md +5 -1
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +22 -0
  11. package/docs/board-guide.md +33 -1
  12. package/docs/cli-contracts.md +36 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +23 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/docs/history.md +172 -0
  19. package/docs/runtime-tap.md +156 -0
  20. package/fixtures/verified.json +1 -1
  21. package/package.json +7 -3
  22. package/scripts/build-docs-site.mjs +18 -4
  23. package/scripts/check-branding.mjs +118 -0
  24. package/scripts/check-claims.mjs +1 -1
  25. package/scripts/license-sign.mjs +1 -1
  26. package/scripts/limits-table.mjs +1 -1
  27. package/scripts/live-limits.mjs +1 -1
  28. package/scripts/npm-publish-gate.mjs +114 -0
  29. package/scripts/probe.mjs +4 -3
  30. package/scripts/seed-fake-cards.mjs +4 -3
  31. package/scripts/seed-floor-board.mjs +5 -4
  32. package/scripts/seed-wes-board.mjs +5 -4
  33. package/scripts/stripe-setup.mjs +1 -1
  34. package/scripts/sync-harness-engine.mjs +159 -0
  35. package/scripts/sync-leg-agents.mjs +127 -0
  36. package/src/accounts.mjs +6 -4
  37. package/src/adapters/codex.mjs +1 -1
  38. package/src/attach.mjs +125 -23
  39. package/src/auth.mjs +2 -2
  40. package/src/board/board.css +23 -1
  41. package/src/board/board.js +17 -5
  42. package/src/board/history.js +377 -0
  43. package/src/board/index.html +33 -0
  44. package/src/board/sessions.js +95 -7
  45. package/src/bundle.mjs +54 -8
  46. package/src/chain.mjs +1 -1
  47. package/src/contract.mjs +4 -3
  48. package/src/fsx.mjs +5 -2
  49. package/src/handoff.mjs +6 -6
  50. package/src/harness/cli.mjs +281 -0
  51. package/src/harness/fingerprint.mjs +68 -0
  52. package/src/harness/index.mjs +407 -0
  53. package/src/harness/registry.mjs +124 -0
  54. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  55. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  56. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  57. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  74. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  75. package/src/history/cli.mjs +159 -0
  76. package/src/history/common.mjs +119 -0
  77. package/src/history/index.mjs +429 -0
  78. package/src/history/providers/agy.mjs +91 -0
  79. package/src/history/providers/claude.mjs +161 -0
  80. package/src/history/providers/codex.mjs +133 -0
  81. package/src/history/providers/copilot.mjs +94 -0
  82. package/src/history/providers/grok.mjs +138 -0
  83. package/src/history/worktrees.mjs +116 -0
  84. package/src/hook.mjs +49 -49
  85. package/src/land.mjs +7 -35
  86. package/src/launcher.mjs +38 -26
  87. package/src/ledger.mjs +6 -6
  88. package/src/license.mjs +10 -9
  89. package/src/live-capture.mjs +1 -1
  90. package/src/mergequeue.mjs +5 -5
  91. package/src/orchestrator.mjs +28 -4
  92. package/src/preferences.mjs +37 -3
  93. package/src/redact.mjs +24 -6
  94. package/src/resume.mjs +17 -15
  95. package/src/runner.mjs +2 -2
  96. package/src/scheduler.mjs +1 -1
  97. package/src/server.mjs +224 -18
  98. package/src/session-detail.mjs +15 -1
  99. package/src/sessions.mjs +15 -3
  100. package/src/share.mjs +2 -2
  101. package/src/stations/agent.mjs +1 -1
  102. package/src/sync/dashclaw.mjs +4 -4
  103. package/src/synthesis.mjs +165 -0
  104. package/src/taps/agy.mjs +2 -2
  105. package/src/taps/claude-usage.mjs +1 -1
  106. package/src/taps/claude.mjs +177 -170
  107. package/src/taps/codex.mjs +286 -286
  108. package/src/taps/grok.mjs +2 -2
  109. package/src/taps/mod.mjs +340 -0
  110. package/src/trust.mjs +205 -36
  111. package/src/usage.mjs +5 -1
  112. package/src/worktree.mjs +6 -5
  113. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  114. package/fixtures/live/agy/err.log +0 -0
  115. package/fixtures/live/agy/out.log +0 -1
  116. package/fixtures/live/agy/supervisor.log +0 -2
  117. package/fixtures/live/claude/err.log +0 -0
  118. package/fixtures/live/claude/out.log +0 -1
  119. package/fixtures/live/claude/supervisor.log +0 -2
  120. package/fixtures/live/codex/err.log +0 -1
  121. package/fixtures/live/codex/out.log +0 -8
  122. package/fixtures/live/codex/supervisor.log +0 -2
  123. package/fixtures/live/grok/err.log +0 -32
  124. package/fixtures/live/grok/out.log +0 -7
  125. package/fixtures/live/grok/supervisor.log +0 -2
package/src/hook.mjs CHANGED
@@ -1,49 +1,49 @@
1
- #!/usr/bin/env node
2
- // hook — the process Claude Code runs for a Baton session's hooks and status
3
- // line (wired by src/taps/claude.mjs through `--settings`). Reads the JSON
4
- // payload on stdin, updates the session record, exits 0 always: a broken hook
5
- // must never stall the user's session.
6
- // node hook.mjs claude-hook --session <id>
7
- // node hook.mjs claude-statusline --session <id>
8
- // The status line entry records rate_limits when a Claude Code build runs it
9
- // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Baton line.
10
- import { appendFileSync } from 'node:fs'
11
- import { join } from 'node:path'
12
- import { handleHook, handleStatusline } from './taps/claude.mjs'
13
- import { sessionDir } from './sessions.mjs'
14
- import { captureLive } from './live-capture.mjs'
15
-
16
- function readStdin() {
17
- return new Promise((resolve) => {
18
- let d = ''
19
- const t = setTimeout(() => resolve(d), 4000)
20
- process.stdin.setEncoding('utf8')
21
- process.stdin.on('data', (c) => { d += c })
22
- process.stdin.on('end', () => { clearTimeout(t); resolve(d) })
23
- process.stdin.on('error', () => { clearTimeout(t); resolve(d) })
24
- })
25
- }
26
-
27
- const [kind, ...rest] = process.argv.slice(2)
28
- const i = rest.indexOf('--session')
29
- const sessionId = i !== -1 ? rest[i + 1] : null
30
-
31
- try {
32
- const raw = await readStdin()
33
- let payload = {}
34
- try { payload = JSON.parse(raw) } catch {}
35
- if (!sessionId) process.exit(0)
36
- if (kind === 'claude-hook') {
37
- const line = handleHook(sessionId, payload)
38
- try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} ${payload.hook_event_name ?? '?'} ${line}\n`) } catch {}
39
- // the first real StopFailure per error kind is kept as evidence (never a simulated one)
40
- if (payload.hook_event_name === 'StopFailure' && payload.error) {
41
- try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
42
- }
43
- } else if (kind === 'claude-statusline') {
44
- const { text } = handleStatusline(sessionId, payload)
45
- try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
46
- process.stdout.write(text + '\n')
47
- }
48
- } catch {}
49
- process.exit(0)
1
+ #!/usr/bin/env node
2
+ // hook — the process Claude Code runs for a Leg session's hooks and status
3
+ // line (wired by src/taps/claude.mjs through `--settings`). Reads the JSON
4
+ // payload on stdin, updates the session record, exits 0 always: a broken hook
5
+ // must never stall the user's session.
6
+ // node hook.mjs claude-hook --session <id>
7
+ // node hook.mjs claude-statusline --session <id>
8
+ // The status line entry records rate_limits when a Claude Code build runs it
9
+ // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Leg line.
10
+ import { appendFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { handleHook, handleStatusline } from './taps/claude.mjs'
13
+ import { sessionDir } from './sessions.mjs'
14
+ import { captureLive } from './live-capture.mjs'
15
+
16
+ function readStdin() {
17
+ return new Promise((resolve) => {
18
+ let d = ''
19
+ const t = setTimeout(() => resolve(d), 4000)
20
+ process.stdin.setEncoding('utf8')
21
+ process.stdin.on('data', (c) => { d += c })
22
+ process.stdin.on('end', () => { clearTimeout(t); resolve(d) })
23
+ process.stdin.on('error', () => { clearTimeout(t); resolve(d) })
24
+ })
25
+ }
26
+
27
+ const [kind, ...rest] = process.argv.slice(2)
28
+ const i = rest.indexOf('--session')
29
+ const sessionId = i !== -1 ? rest[i + 1] : null
30
+
31
+ try {
32
+ const raw = await readStdin()
33
+ let payload = {}
34
+ try { payload = JSON.parse(raw) } catch {}
35
+ if (!sessionId) process.exit(0)
36
+ if (kind === 'claude-hook') {
37
+ const line = handleHook(sessionId, payload)
38
+ try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} ${payload.hook_event_name ?? '?'} ${line}\n`) } catch {}
39
+ // the first real StopFailure per error kind is kept as evidence (never a simulated one)
40
+ if (payload.hook_event_name === 'StopFailure' && payload.error) {
41
+ try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
42
+ }
43
+ } else if (kind === 'claude-statusline') {
44
+ const { text } = handleStatusline(sessionId, payload)
45
+ try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
46
+ process.stdout.write(text + '\n')
47
+ }
48
+ } catch {}
49
+ process.exit(0)
package/src/land.mjs CHANGED
@@ -213,18 +213,16 @@ export async function applyLandFix(sessionId, action, opts = {}) {
213
213
  if (action === 'commit') {
214
214
  git(wt, ['add', '-A'])
215
215
  const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
216
- const msg = opts.message || 'baton: save work before landing'
217
- const r = git(wt, ['-c', 'user.email=baton@localhost', '-c', 'user.name=baton', 'commit', '-q', ...verify, '-m', msg])
216
+ const msg = opts.message || 'leg: save work before landing'
217
+ const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
218
218
  if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
219
- clearCanLandCache()
220
219
  appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'committed uncommitted work in worktree' })
221
220
  return { ok: true, action: 'commit' }
222
221
  }
223
222
 
224
223
  if (action === 'stash') {
225
- const r = git(wt, ['stash', '-u', '-m', 'baton: stash before landing'])
224
+ const r = git(wt, ['stash', '-u', '-m', 'leg: stash before landing'])
226
225
  if (!r.ok) throw new Error(`git stash failed: ${r.err || r.out}`)
227
- clearCanLandCache()
228
226
  appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'stashed uncommitted changes in worktree' })
229
227
  return { ok: true, action: 'stash' }
230
228
  }
@@ -232,7 +230,6 @@ export async function applyLandFix(sessionId, action, opts = {}) {
232
230
  if (action === 'rebase_now') {
233
231
  const base = s.worktree?.base || 'main'
234
232
  const r = git(wt, ['rebase', base])
235
- clearCanLandCache()
236
233
  appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `started rebase onto ${base} in worktree` })
237
234
  return { ok: r.ok, action: 'rebase_now', detail: r.out || r.err }
238
235
  }
@@ -244,10 +241,9 @@ export async function applyLandFix(sessionId, action, opts = {}) {
244
241
  if (action === 'commit_directly') {
245
242
  git(wt, ['add', '-A'])
246
243
  const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
247
- const msg = opts.message || `baton: commit directly on ${s.branch || s.worktree?.base || 'main'}`
248
- const r = git(wt, ['-c', 'user.email=baton@localhost', '-c', 'user.name=baton', 'commit', '-q', ...verify, '-m', msg])
244
+ const msg = opts.message || `leg: commit directly on ${s.branch || s.worktree?.base || 'main'}`
245
+ const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
249
246
  if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
250
- clearCanLandCache()
251
247
  appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `committed directly on ${s.branch || 'main'}` })
252
248
  return { ok: true, action: 'commit_directly' }
253
249
  }
@@ -256,13 +252,6 @@ export async function applyLandFix(sessionId, action, opts = {}) {
256
252
  }
257
253
 
258
254
  // ---- canLand: The Guarded State Machine ----
259
- const canLandCache = new Map()
260
-
261
- export function clearCanLandCache(key) {
262
- if (key) canLandCache.delete(key)
263
- else canLandCache.clear()
264
- }
265
-
266
255
  export function canLand(worktreeId, opts = {}) {
267
256
  const ctx = resolveWorktreeContext(worktreeId)
268
257
  if (!ctx) {
@@ -301,16 +290,6 @@ export function canLand(worktreeId, opts = {}) {
301
290
  }
302
291
  }
303
292
 
304
- const cacheKey = session?.session_id ? `sess:${session.session_id}` : `path:${path}`
305
- if (!opts.fresh && !opts.testResult && !opts.ignoreRunning && !opts.ignoreInFlight && !opts.ignoreTargetActive) {
306
- const hit = canLandCache.get(cacheKey)
307
- if (hit && Date.now() - hit.ts < (opts.ttlMs ?? 10000)) {
308
- if (!session || hit.sessionStatus === session.status) {
309
- return hit.result
310
- }
311
- }
312
- }
313
-
314
293
  // Guard 3: Worktree is sitting on target branch itself (e.g. working directly on main)
315
294
  if (branch === base) {
316
295
  blockers.push({
@@ -427,14 +406,10 @@ export function canLand(worktreeId, opts = {}) {
427
406
  })
428
407
  }
429
408
 
430
- const res = {
409
+ return {
431
410
  ok: blockers.length === 0,
432
411
  blockers,
433
412
  }
434
- if (!opts.testResult && !opts.ignoreRunning && !opts.ignoreInFlight && !opts.ignoreTargetActive) {
435
- canLandCache.set(cacheKey, { ts: Date.now(), result: res, sessionStatus: session?.status })
436
- }
437
- return res
438
413
  }
439
414
 
440
415
  // Why this session cannot land right now, or null.
@@ -527,7 +502,6 @@ export async function prepareLanding(worktreeId) {
527
502
  try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
528
503
  try { git(repo, ['worktree', 'prune']) } catch {}
529
504
  releaseTargetLock(repo, base, lock.token)
530
- clearCanLandCache()
531
505
  }
532
506
  }
533
507
 
@@ -548,7 +522,7 @@ export async function landSession(session, { by = 'local', autoCommit = true, ig
548
522
  if (autoCommit && existsSync(path)) {
549
523
  ensureExcludeEntries(repo)
550
524
  const task = String(session.task ?? 'terminal session').split('\n')[0].slice(0, 60)
551
- commitWorktree(path, `baton: ${session.agent ?? 'agent'} ${id.split('-').pop()}: ${task}`)
525
+ commitWorktree(path, `leg: ${session.agent ?? 'agent'} ${id.split('-').pop()}: ${task}`)
552
526
  }
553
527
 
554
528
  const cl = canLand(session, { ignoreInFlight: true, ignoreRunning, ignoreTargetActive })
@@ -722,7 +696,6 @@ export async function landSession(session, { by = 'local', autoCommit = true, ig
722
696
  try { git(repo, ['worktree', 'prune']) } catch {}
723
697
  if (lock) releaseTargetLock(repo, base, lock.token)
724
698
  inFlight.delete(id)
725
- clearCanLandCache()
726
699
  drainLandingQueue(repo, base).catch(() => {})
727
700
  }
728
701
  }
@@ -730,7 +703,6 @@ export async function landSession(session, { by = 'local', autoCommit = true, ig
730
703
  // Removing a finished session takes its worktree and branch with it only when
731
704
  // nothing is lost: a clean worktree whose branch is already in its base.
732
705
  export function pruneSessionWorktree(s) {
733
- clearCanLandCache()
734
706
  const wt = s.worktree
735
707
  if (!wt || !existsSync(wt.path)) return { removed: false, reason: 'no worktree on disk' }
736
708
  const status = git(wt.path, ['status', '--porcelain'])
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
 
@@ -74,13 +86,13 @@ export function printPreflight({ rows }) {
74
86
 
75
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' } = {}) {
76
88
  const procs = [{
77
- 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 },
78
90
  note: 'board + API + scheduler + merge queue',
79
91
  }]
80
92
  const on = enabledSyncs()
81
93
  const syncs = [
82
- { prefix: 'sync:workboard', enabled: on.includes('workboard'), note: 'OpenClaw Workboard mirror (BATON_SYNC_WORKBOARD=1); runs inside the ledger, no extra process' },
83
- { 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' },
84
96
  ]
85
97
  return { procs, syncs }
86
98
  }
@@ -160,21 +172,21 @@ function killActiveAgents() {
160
172
  }
161
173
 
162
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' } = {}) {
163
- out('baton', `baton ${VERSION} — home ${home()}`)
175
+ out('leg', `leg ${VERSION} — home ${home()}`)
164
176
  const pf = await preflight()
165
177
  printPreflight(pf)
166
- 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)
167
179
  const plan = plannedProcesses({ port, bind })
168
- 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})`)
169
181
  if (dry) {
170
- out('baton', 'dry run: nothing spawned. Would run:')
171
- for (const p of plan.procs) out('baton', `${p.prefix}: ${JSON.stringify([p.bin, ...p.argv])} env ${JSON.stringify(p.env)}`)
172
- 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()}`)
173
185
  return 0
174
186
  }
175
187
  const existing = readPidfile()
176
188
  if (existing && existing.pid !== process.pid && await boardAlive(existing)) {
177
- 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)
178
190
  return 1
179
191
  }
180
192
  if (existing) { try { rmSync(pidfile(), { force: true }) } catch {} }
@@ -197,34 +209,34 @@ export async function up({ dry = false, open = true, port = Number(process.env.L
197
209
  await new Promise((r) => setTimeout(r, 250))
198
210
  }
199
211
  if (!ok) {
200
- 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)
201
213
  for (const l of lastLines) out(p.prefix, l, process.stderr)
202
214
  killTree(child.pid)
203
215
  return 1
204
216
  }
205
217
  const url = `http://${bind === '0.0.0.0' ? '127.0.0.1' : bind}:${actualPort}`
206
218
  writeFileSync(pidfile(), JSON.stringify({ pid: process.pid, port: actualPort, bind, children: [child.pid], started_at: new Date().toISOString() }, null, 2) + '\n')
207
- out('baton', `ready ${url} (scheduler max ${MAX_CONCURRENT}, ${ok.cards} card${ok.cards === 1 ? '' : 's'})`)
208
- if (open) out('baton', openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
209
- 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')
210
222
 
211
223
  return await new Promise((resolvePromise) => {
212
224
  let stopping = false
213
225
  const stop = (why) => {
214
226
  if (stopping) return
215
227
  stopping = true
216
- out('baton', `stopping (${why})`)
228
+ out('leg', `stopping (${why})`)
217
229
  const agents = killActiveAgents()
218
- if (agents) out('baton', `killed ${agents} running agent/supervisor process(es)`)
230
+ if (agents) out('leg', `killed ${agents} running agent/supervisor process(es)`)
219
231
  killTree(child.pid)
220
232
  try { rmSync(pidfile(), { force: true }) } catch {}
221
- out('baton', 'stopped')
233
+ out('leg', 'stopped')
222
234
  resolvePromise(0)
223
235
  }
224
236
  process.on('SIGINT', () => stop('SIGINT'))
225
237
  process.on('SIGTERM', () => stop('SIGTERM'))
226
238
  process.on('SIGBREAK', () => stop('SIGBREAK'))
227
- 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) } })
228
240
  })
229
241
  }
230
242
 
@@ -241,10 +253,10 @@ export async function stopBoard() {
241
253
 
242
254
  export async function down() {
243
255
  const pf = readPidfile()
244
- if (!pf) { out('baton', 'not running'); return 0 }
256
+ if (!pf) { out('leg', 'not running'); return 0 }
245
257
  const agents = killActiveAgents()
246
258
  const board = await stopBoard()
247
- 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' : ''})`)
248
260
  return 0
249
261
  }
250
262
 
@@ -256,14 +268,14 @@ export async function status() {
256
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)'
257
269
  if (running) {
258
270
  const up = Math.round((Date.now() - Date.parse(pf.started_at)) / 1000)
259
- 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}`)
260
272
  } else {
261
- 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')
262
274
  }
263
275
  const s = schedulerStatus()
264
- out('baton', `scheduler ${s.running ? `running (pid ${s.pid})` : 'stopped'} max concurrent ${MAX_CONCURRENT}`)
265
- out('baton', `cards ${cards.length} by status: ${by('status')}`)
266
- 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')}`)
267
279
  return running ? 0 : 3
268
280
  }
269
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
 
@@ -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))