@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/runner.mjs CHANGED
@@ -129,7 +129,7 @@ function errTail(path, lines = 10) {
129
129
 
130
130
  function killTree(pid, log) {
131
131
  if ((process.env.LEG_SKIP_KILL || process.env.BATON_SKIP_KILL) === '1') { // test seam: unkillable agent
132
- log('BATON_SKIP_KILL=1: killTree skipped')
132
+ log('LEG_SKIP_KILL=1: killTree skipped')
133
133
  return
134
134
  }
135
135
  if (process.platform === 'win32') {
@@ -151,14 +151,14 @@ function gitHead(cwd) {
151
151
  function gitDiff(cwd, headAtStart) {
152
152
  const r = spawnSync('git', ['status', '--porcelain'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
153
153
  if (r.status !== 0) return null
154
- const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.baton[\\/]/.test(l)).length
154
+ const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.(baton|leg)[\\/]/.test(l)).length
155
155
  const head = gitHead(cwd)
156
156
  return { changed: files > 0 || (headAtStart !== null && head !== headAtStart), files, head_at_start: headAtStart, head }
157
157
  }
158
158
 
159
159
  // Fallback when the cwd is not a git repo (tests, ad-hoc dirs): a shallow
160
160
  // mtime snapshot, so "wrote a file but no DONE" still reads as incomplete.
161
- const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees'])
161
+ const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees', '.leg', '.leg-worktrees'])
162
162
  function fsSnapshot(cwd, depth = 3) {
163
163
  const out = new Map()
164
164
  const walk = (dir, rel, d) => {
package/src/scheduler.mjs CHANGED
@@ -46,7 +46,7 @@ export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set()
46
46
 
47
47
  export function pidfile() { return join(home(), 'scheduler.pid') }
48
48
 
49
- export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'baton' } } = {}) {
49
+ export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'leg' } } = {}) {
50
50
  const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
51
51
 
52
52
  async function tick() {
package/src/server.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  // server — the board's HTTP + SSE backend. Ledger-backed: every handler reads
3
3
  // card.json / events-*.jsonl on demand (no module-level card store), so a
4
4
  // restart shows the same board and a second process sees the same truth.
5
- // BATON_BIND (127.0.0.1) + BATON_PORT (4747) + BATON_TOKEN are the
6
- // multiplayer seams (src/auth.mjs).
5
+ // LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
6
+ // multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
7
7
  import http from 'node:http'
8
8
  import { spawnSync } from 'node:child_process'
9
9
  import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
@@ -26,15 +26,26 @@ import { scrub } from './runner.mjs'
26
26
  import { resolveChb } from './handoff.mjs'
27
27
  import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
28
28
  import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
29
+ import { hasRecentSynthesis } from './synthesis.mjs'
29
30
  import { refreshPointers } from './resume.mjs'
30
- import { landSession, landBlocker, landingNow, pruneSessionWorktree } from './land.mjs'
31
+ import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
31
32
  import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
32
33
  import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
33
34
  import { readCodexUsage } from './taps/codex.mjs'
34
35
  import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
35
36
 
36
37
  const SELF = fileURLToPath(import.meta.url)
37
- const BOARD_DIR = join(dirname(SELF), 'board')
38
+ export function resolveBoardDir() {
39
+ const dir = join(dirname(SELF), 'board')
40
+ if (existsSync(dir)) return dir
41
+ const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(dirname(SELF))
42
+ if (wtMatch) {
43
+ const root = dirname(SELF).slice(0, wtMatch.index)
44
+ const fallback = join(root, 'src', 'board')
45
+ if (existsSync(fallback)) return fallback
46
+ }
47
+ return dir
48
+ }
38
49
  const VERSION = JSON.parse(readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8')).version
39
50
  const DEFAULT_ORDER = ['plan', 'build', 'review', 'test', 'land']
40
51
  const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2' }
@@ -191,16 +202,17 @@ export async function detectTools({ refresh = false } = {}) {
191
202
  return !r.error && r.status === 0
192
203
  }
193
204
  const agents = {}
194
- for (const name of ['claude', 'codex', 'agy']) {
205
+ for (const name of ['claude', 'codex', 'agy', 'grok']) {
195
206
  try {
196
- const { bin, viaNode, entry } = (await getAdapter(name)).resolve()
207
+ const a = name === 'grok' ? (await import('./adapters/grok.mjs')).default : await getAdapter(name)
208
+ const { bin, viaNode, entry } = a.resolve()
197
209
  const target = viaNode ? (entry ?? bin) : bin
198
210
  agents[name] = /[\\/]/.test(target) ? existsSync(target) : probe(target)
199
211
  } catch { agents[name] = false }
200
212
  }
201
213
  let chb = false
202
214
  try { resolveChb(); chb = true } catch {}
203
- toolsCache = { ...agents, grok: probe('grok'), chb, git: probe('git') }
215
+ toolsCache = { ...agents, chb, git: probe('git') }
204
216
  return toolsCache
205
217
  }
206
218
 
@@ -257,6 +269,8 @@ function redactSession(s) {
257
269
  // the branch is already on the worktree chip: naming it again costs nothing
258
270
  // and is what the board's land line reads
259
271
  land: s.land ? { state: s.land.state, branch: s.land.branch ?? null, base: s.land.base ?? null, sha: s.land.sha ?? null, reason: s.land.reason ?? null } : null,
272
+ // the chip's word only: paths, dropped items and attention text stay on this machine
273
+ harness: s.harness ? { state: s.harness.state, target: s.harness.target ?? null } : null,
260
274
  task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
261
275
  land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
262
276
  }
@@ -279,6 +293,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
279
293
  const preferredNext = chain[0] ?? null
280
294
  const availabilityKnown = Boolean(s.installed)
281
295
  const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
296
+ const can = s.worktree ? canLand(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
282
297
  return {
283
298
  ...s,
284
299
  handoff_order: handoffOrder,
@@ -288,6 +303,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
288
303
  handoff_availability_known: availabilityKnown,
289
304
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
290
305
  active: isActive(s),
306
+ has_synthesis: hasRecentSynthesis(s),
291
307
  overlap: ov.get(s.session_id) ?? [],
292
308
  elapsed_ms: Date.now() - Date.parse(s.started_at),
293
309
  // Older attached Codex processes can retain one parser mistake where an
@@ -296,7 +312,8 @@ export function sessionsView({ viewer = null, share = null } = {}) {
296
312
  files: [...new Set([...(s.files_touched ?? []), ...(s.files_dirty ?? [])])].filter(visibleSessionFile),
297
313
  // a 'landing' left behind by a board restart is no longer in flight
298
314
  land: land?.state === 'landing' && !landingNow(s.session_id) ? { ...land, state: 'interrupted' } : land,
299
- land_blocker: s.worktree ? landBlocker(s) : null,
315
+ can_land: can,
316
+ land_blocker: s.worktree ? (can.ok ? null : can.blockers[0]?.message) : null,
300
317
  }
301
318
  })
302
319
  const accounts = []
@@ -349,10 +366,11 @@ function readBody(req) {
349
366
  }
350
367
 
351
368
  function serveStatic(res, urlPath) {
352
- const map = { '/': 'index.html', '/floor': 'floor.html' }
369
+ const map = { '/': 'index.html', '/board': 'index.html', '/board/': 'index.html', '/floor': 'floor.html', '/floor/': 'floor.html' }
353
370
  const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
354
- const file = resolve(BOARD_DIR, rel)
355
- if (!file.startsWith(BOARD_DIR + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
371
+ const boardDir = resolveBoardDir()
372
+ const file = resolve(boardDir, rel)
373
+ if (!file.startsWith(boardDir + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
356
374
  res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
357
375
  res.end(readFileSync(file))
358
376
  }
@@ -457,7 +475,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
457
475
  // ---- the server ----
458
476
  export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
459
477
  // An explicit `share` (tests) is fixed; the real server passes none and reads
460
- // share.json from disk, re-reading it per request (mtime-cached) so `baton
478
+ // share.json from disk, re-reading it per request (mtime-cached) so `leg
461
479
  // share add|rotate|rm` takes effect on a live board — a new link works at
462
480
  // once and a removed or rotated one stops at once — without a restart.
463
481
  const explicitShare = share !== undefined
@@ -563,7 +581,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
563
581
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
564
582
  if (guest) return send(res, 200, { ok: true, version: VERSION, you })
565
583
  const cards = listCards()
566
- return send(res, 200, { ok: true, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
584
+ return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
567
585
  }
568
586
  if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
569
587
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
@@ -596,7 +614,20 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
596
614
  if (req.method === 'POST' || req.method === 'PATCH') {
597
615
  const body = await readBody(req)
598
616
  try {
599
- const preferences = writePreferences({ handoff_order: requireHandoffOrder(body.handoff_order) })
617
+ const patch = {}
618
+ if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
619
+ if (body.harness !== undefined) {
620
+ // the board may narrow the policy or turn the feature off; turning
621
+ // it on is the first-run consent flow, which shows what will be
622
+ // written before it writes (leg harness enable)
623
+ if (body.harness?.enabled === true) return send(res, 400, { error: 'turn the portable harness on from a terminal: leg harness enable shows what it will write before it writes it' })
624
+ const rank = ['warn', 'sync', 'strict']
625
+ const current = readPreferences().harness
626
+ if (body.harness?.policy !== undefined && rank.indexOf(body.harness.policy) > rank.indexOf(current.policy)) return send(res, 400, { error: `the board may only narrow the harness policy (now ${current.policy}); widen it from a terminal: leg harness policy ${body.harness.policy}` })
627
+ patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
628
+ }
629
+ if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
630
+ const preferences = writePreferences(patch)
600
631
  sse.broadcast('sessions', (v) => viewFor(v))
601
632
  return send(res, 200, { preferences })
602
633
  } catch (err) {
@@ -687,9 +718,25 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
687
718
  }
688
719
  }
689
720
  if (req.method === 'POST' && parts[3] === 'land') {
721
+ if (parts[4] === 'prepare') {
722
+ const cl = canLand(sess)
723
+ if (!cl.ok) return send(res, 409, { ok: false, error: cl.blockers[0].message, blockers: cl.blockers })
724
+ const prep = await prepareLanding(sess, { by: actor.id })
725
+ return send(res, prep.ok ? 200 : 409, prep)
726
+ }
727
+ if (parts[4] === 'fix') {
728
+ const body = await readBody(req)
729
+ try {
730
+ const r = await applyLandFix(sess.session_id, body?.action, { by: actor.id, message: body?.message })
731
+ try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {}
732
+ return send(res, 200, r)
733
+ } catch (err) {
734
+ return send(res, 400, { ok: false, error: err.message })
735
+ }
736
+ }
690
737
  const why = landBlocker(sess)
691
738
  if (why) return send(res, 409, { error: why })
692
- landSession(sess, { by: actor.id })
739
+ landSession(sess, { by: actor.id, autoCommit: true })
693
740
  .catch((err) => log(`land ${id}: ${err.message}`))
694
741
  .finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
695
742
  log(`land requested for ${id} by ${actor.id}`)
@@ -795,12 +842,14 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
795
842
  const addr = server.address()
796
843
  log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
797
844
  // A terminal that crashed instead of exiting left its hand-off in
798
- // .baton/RESUME.md looking live. The board is the thing that starts
845
+ // .leg/RESUME.md looking live. The board is the thing that starts
799
846
  // after a crash, so it is where that gets corrected.
800
- try {
801
- const touched = refreshPointers()
802
- if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Baton stamped it`)
803
- } catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
847
+ setImmediate(() => {
848
+ try {
849
+ const touched = refreshPointers()
850
+ if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Leg stamped it`)
851
+ } catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
852
+ })
804
853
  if (scheduler) {
805
854
  sched = createScheduler()
806
855
  sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
@@ -11,6 +11,7 @@ import { scrub } from './redact.mjs'
11
11
  import { transcriptTail as claudeTail } from './taps/claude.mjs'
12
12
  import { transcriptTail as codexTail } from './taps/codex.mjs'
13
13
  import { resumeVerdict, verdictForBoard } from './resume.mjs'
14
+ import { readHistory } from './harness/index.mjs'
14
15
 
15
16
  export const MESSAGE_LIMIT = 8
16
17
  export const DIFF_MAX_LINES = 400
@@ -41,7 +42,7 @@ export function sessionMessages(session, limit = MESSAGE_LIMIT) {
41
42
  if (!path) return []
42
43
  const tail = session.agent === 'claude' ? claudeTail(path, limit)
43
44
  : session.agent === 'codex' ? codexTail(path, limit)
44
- : [] // agy keeps no transcript Baton can read
45
+ : [] // agy keeps no transcript Leg can read
45
46
  return tail.map((m) => ({ role: m.role === 'user' ? 'user' : 'assistant', text: scrub(m.text), ts: m.ts ?? null }))
46
47
  }
47
48
 
@@ -116,6 +117,18 @@ function resumeFor(session) {
116
117
  try { return verdictForBoard(resumeVerdict(root)) } catch { return null }
117
118
  }
118
119
 
120
+ // The harness the leg now running was given: what the session recorded when
121
+ // the leg started, plus every capture, sync and decision the harness trail
122
+ // holds for this session. Paths in the attention list are local; a guest never
123
+ // reaches the drawer.
124
+ function harnessFor(session) {
125
+ const h = session.harness ?? null
126
+ let history = []
127
+ try { history = readHistory(200).filter((r) => r.session_id === session.session_id).slice(-20) } catch { /* the drawer renders without the trail */ }
128
+ if (!h && !history.length) return null
129
+ return { ...(h ?? {}), history }
130
+ }
131
+
119
132
  export function sessionDetail(session) {
120
133
  return {
121
134
  session_id: session.session_id,
@@ -124,6 +137,7 @@ export function sessionDetail(session) {
124
137
  events: readEvents(session.session_id).slice(-EVENT_LIMIT),
125
138
  bundle: session.bundle ?? null,
126
139
  resume: resumeFor(session),
140
+ harness: harnessFor(session),
127
141
  ts: new Date().toISOString(),
128
142
  }
129
143
  }
package/src/sessions.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // sessions — the store behind `baton claude|codex|agy`. One directory per
1
+ // sessions — the store behind `leg claude|codex|agy`. One directory per
2
2
  // interactive terminal session under $BATON_HOME/sessions/<id>/:
3
3
  // session.json the live record the board renders (atomic writes)
4
4
  // events.jsonl timeline (started, turn, warning, limit, handoff, ended)
@@ -11,9 +11,10 @@ import { randomBytes } from 'node:crypto'
11
11
  import { home } from './store.mjs'
12
12
  import { writeJsonAtomic, withFileLock } from './fsx.mjs'
13
13
  import { scrub } from './redact.mjs'
14
- import { HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
14
+ import { HANDOFF_AGENTS, ALL_HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
15
15
 
16
16
  export const AGENTS = HANDOFF_AGENTS
17
+ export const SUPERVISED_AGENTS = ALL_HANDOFF_AGENTS
17
18
  export const HANDOFF_ORDER_CAPABILITY = 'handoff_order_v1'
18
19
  export const SESSION_STATUSES = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting', 'handed_off', 'ended', 'lost']
19
20
  const ACTIVE = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting']
@@ -43,7 +44,7 @@ export function listSessions() {
43
44
 
44
45
  export function isActive(s) { return ACTIVE.includes(s?.status) }
45
46
 
46
- // The checkout this session's files live in: its own worktree when Baton gave
47
+ // The checkout this session's files live in: its own worktree when Leg gave
47
48
  // it one (repo stays the main checkout, for grouping and landing), else the repo.
48
49
  export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
49
50
 
@@ -57,10 +58,13 @@ export function createSession({ id, agent, account = 'default', cwd, repo = null
57
58
  task: null, turns: 0,
58
59
  files_touched: [], files_dirty: [], head: null, head_at_start: null,
59
60
  limits: null, limit: null, warning: null,
60
- bundle: null, handoff: null, chain,
61
+ bundle: null, handoff: null, chain, checkpoints: [],
61
62
  handoff_order: normalizeHandoffOrder(handoffOrder), installed,
62
63
  runtime_capabilities: [...new Set(runtimeCapabilities)],
63
64
  lineage: { from: null, to: null },
65
+ // the portable-harness outcome for the leg now running (src/harness/index.mjs);
66
+ // null until the feature is enabled and a leg has been prepared
67
+ harness: null,
64
68
  exit_code: null,
65
69
  }
66
70
  mkdirSync(sessionDir(id), { recursive: true })
@@ -160,7 +164,7 @@ export function readLandings() {
160
164
  return readFileSync(landingsFile(), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
161
165
  }
162
166
 
163
- function pidAlive(pid) {
167
+ export function pidAlive(pid) {
164
168
  if (!pid) return false
165
169
  // EPERM means the process exists but is not ours to signal (e.g. an elevated
166
170
  // terminal): it is alive. Only ESRCH ("no such process") means gone.
package/src/share.mjs CHANGED
@@ -1,7 +1,7 @@
1
- // share — optional multiplayer for the board, off until `baton share on`
1
+ // share — optional multiplayer for the board, off until `leg share on`
2
2
  // writes $BATON_HOME/share.json. With it on, the board binds the Tailscale or
3
3
  // LAN address, every human has a name and their own token (kept as a sha256
4
- // hash: a token is printed once and `baton share rotate` issues a new one),
4
+ // hash: a token is printed once and `leg share rotate` issues a new one),
5
5
  // and every session belongs to the human whose terminal started it
6
6
  // (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
7
7
  // open and `BATON_TOKEN` is the only token.
@@ -1,4 +1,4 @@
1
- // Station kind: agent. One chain leg runs here (or, after `baton down` or a
1
+ // Station kind: agent. One chain leg runs here (or, after `leg down` or a
2
2
  // crashed server, the orchestrator re-attaches to the run it left behind),
3
3
  // the run is settled, and its verdict is applied to the card. `ops` are the
4
4
  // orchestrator's helpers; this module never imports the orchestrator.
@@ -1,5 +1,5 @@
1
1
  // dashclaw sync — records every ledger event as a DashClaw action over native
2
- // http/https (LESSONS 07-12: no global fetch). Off unless BATON_SYNC_DASHCLAW=1
2
+ // http/https (LESSONS 07-12: no global fetch). Off unless LEG_SYNC_DASHCLAW=1
3
3
  // and DASHCLAW_URL + DASHCLAW_API_KEY are set. Field names come from
4
4
  // DashClaw's validator (app/lib/validate.js ACTION_RECORD_SCHEMA): agent_id,
5
5
  // action_type and declared_goal are required; status is one of running,
@@ -26,11 +26,11 @@ const STATUS_BY_TYPE = {
26
26
 
27
27
  // One ledger event → one action record (the request body).
28
28
  export function actionForEvent(ev, card = null) {
29
- const actor = ev.actor ?? { type: 'baton' }
30
- const agent = actor.type === 'agent' ? `baton/${actor.adapter}` : actor.type === 'human' ? `baton/human:${actor.id}` : 'baton'
29
+ const actor = ev.actor ?? { type: 'leg' }
30
+ const agent = actor.type === 'agent' ? `leg/${actor.adapter}` : actor.type === 'human' ? `leg/human:${actor.id}` : 'leg'
31
31
  const body = {
32
32
  agent_id: agent,
33
- action_type: `baton_${ev.type}`,
33
+ action_type: `leg_${ev.type}`,
34
34
  declared_goal: String(ev.summary ?? ev.type).slice(0, 2000),
35
35
  status: STATUS_BY_TYPE[ev.type] ?? 'completed',
36
36
  reversible: true,
@@ -0,0 +1,165 @@
1
+ // synthesis — the agent-maintained judgment record included in the handoff bundle.
2
+ // Spec: Leg Handoff Synthesis Layer — build spec v1
3
+ // File: .leg/SYNTHESIS-<session-id>.md
4
+ import { existsSync, readFileSync, statSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+
7
+ export const MAX_SYNTHESIS_BYTES = 4096
8
+ export const TRUNCATED_MARKER = '[synthesis truncated]'
9
+ export const INVALID_HEADER_PREFIX = '[synthesis header invalid, rendering body as-is]'
10
+ export const SYNTHESIS_POINTER_PARAGRAPH = "Read the Synthesis section first if present. Treat 'Ruled out' as settled: do not retry a ruled-out approach unless you have new evidence it was wrong. Start from the top-ranked Next step unless the repo state contradicts it."
11
+
12
+ export const SYNTHESIS_SECTIONS = [
13
+ '## Ruled out',
14
+ '## Decisions',
15
+ '## Next steps',
16
+ '## Open questions',
17
+ ]
18
+
19
+ // The synthesis file path for a session in its checkout/worktree root
20
+ export function synthesisFile(cwd, id) {
21
+ if (!cwd || !id) return null
22
+ const leg = join(cwd, '.leg', `SYNTHESIS-${id}.md`)
23
+ const baton = join(cwd, '.baton', `SYNTHESIS-${id}.md`)
24
+ if (existsSync(leg)) return leg
25
+ if (existsSync(baton)) return baton
26
+ if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
27
+ return leg
28
+ }
29
+
30
+ // Safely read the synthesis file; returns null if absent, empty, or unreadable
31
+ export function readSynthesis(cwd, id) {
32
+ if (!cwd || !id) return null
33
+ const file = synthesisFile(cwd, id)
34
+ try {
35
+ if (!existsSync(file)) return null
36
+ const text = readFileSync(file, 'utf8')
37
+ if (!text || !text.trim()) return null
38
+ return text
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ // The standing directive for Leg per-session instructions
45
+ export function synthesisDirective(sessionId = '<session-id>') {
46
+ return `Maintain .leg/SYNTHESIS-${sessionId}.md using the schema in section 4. Update it whenever you rule out an approach, make a consequential decision, or change direction. Keep each section to 5 bullets max, one line per bullet.`
47
+ }
48
+
49
+ // Validates the 2-line header per schema v1:
50
+ // synthesis_version: 1
51
+ // session: <session-id> updated: <ISO-8601 UTC>
52
+ export function validateSynthesisHeader(text, { sessionId = null } = {}) {
53
+ if (typeof text !== 'string') return { valid: false, reason: 'content is not a string' }
54
+ const lines = text.split(/\r?\n/)
55
+ if (lines.length < 2) return { valid: false, reason: 'fewer than 2 lines' }
56
+
57
+ const l0 = lines[0].trim()
58
+ if (!/^synthesis_version:\s*1$/.test(l0)) {
59
+ return { valid: false, reason: 'missing or invalid synthesis_version: 1' }
60
+ }
61
+
62
+ const l1 = lines[1].trim()
63
+ const m = /^session:\s*(\S+)\s+updated:\s*(\S+)$/.exec(l1)
64
+ if (!m) {
65
+ return { valid: false, reason: 'missing or invalid session/updated header line' }
66
+ }
67
+
68
+ const [, sessId, updated] = m
69
+ if (sessionId && sessId !== sessionId) {
70
+ return { valid: false, reason: `session mismatch: expected ${sessionId}, got ${sessId}` }
71
+ }
72
+
73
+ const parsed = Date.parse(updated)
74
+ if (Number.isNaN(parsed) || !/(?:Z|[+-]00:?00)$/i.test(updated)) {
75
+ return { valid: false, reason: 'updated timestamp is not valid ISO-8601 UTC' }
76
+ }
77
+
78
+ return { valid: true, version: 1, session: sessId, updated }
79
+ }
80
+
81
+ // Validates the full schema v1: header + optional sections in fixed order with max 5 bullets each
82
+ export function validateSynthesis(text, { sessionId = null } = {}) {
83
+ const header = validateSynthesisHeader(text, { sessionId })
84
+ const errors = []
85
+ if (!header.valid) {
86
+ errors.push(header.reason)
87
+ }
88
+
89
+ if (typeof text !== 'string') {
90
+ return { valid: false, headerValid: false, errors: ['content is not a string'] }
91
+ }
92
+
93
+ const lines = text.split(/\r?\n/)
94
+ let lastSectionIdx = -1
95
+ let currentSection = null
96
+ let currentBullets = 0
97
+
98
+ for (let i = 2; i < lines.length; i++) {
99
+ const line = lines[i].trim()
100
+ if (line.startsWith('## ')) {
101
+ const idx = SYNTHESIS_SECTIONS.indexOf(line)
102
+ if (idx === -1) {
103
+ errors.push(`unknown section: ${line}`)
104
+ } else if (idx <= lastSectionIdx) {
105
+ errors.push(`section out of order: ${line}`)
106
+ } else {
107
+ lastSectionIdx = idx
108
+ }
109
+ currentSection = line
110
+ currentBullets = 0
111
+ } else if (line.startsWith('- ')) {
112
+ currentBullets++
113
+ if (currentBullets > 5) {
114
+ errors.push(`section ${currentSection || 'unknown'} exceeds 5 bullets`)
115
+ }
116
+ }
117
+ }
118
+
119
+ return {
120
+ valid: errors.length === 0,
121
+ headerValid: header.valid,
122
+ errors,
123
+ session: header.session ?? null,
124
+ updated: header.updated ?? null,
125
+ }
126
+ }
127
+
128
+ // Inlines synthesis into the ## Synthesis section with size cap (4 KB) and malformed header handling
129
+ export function formatSynthesisSection(rawText, { sessionId = null } = {}) {
130
+ if (!rawText || !rawText.trim()) return ''
131
+
132
+ let content = rawText
133
+ const buf = Buffer.from(content, 'utf8')
134
+ if (buf.length > MAX_SYNTHESIS_BYTES) {
135
+ const sliced = buf.subarray(0, MAX_SYNTHESIS_BYTES).toString('utf8')
136
+ const sep = sliced.endsWith('\n') ? '' : '\n'
137
+ content = `${sliced}${sep}${TRUNCATED_MARKER}`
138
+ }
139
+
140
+ const header = validateSynthesisHeader(content, { sessionId })
141
+ if (!header.valid) {
142
+ content = `${INVALID_HEADER_PREFIX}\n${content}`
143
+ }
144
+
145
+ return `## Synthesis\n\n${content.trim()}`
146
+ }
147
+
148
+ // True if .leg/SYNTHESIS-<session-id>.md exists, is non-empty, and was modified within the last 3 checkpoints
149
+ export function hasRecentSynthesis(session) {
150
+ if (!session) return false
151
+ const root = session.worktree?.path ?? session.repo ?? session.cwd ?? null
152
+ if (!root || !session.session_id) return false
153
+ const file = synthesisFile(root, session.session_id)
154
+ try {
155
+ if (!existsSync(file)) return false
156
+ const st = statSync(file)
157
+ if (!st.isFile() || st.size === 0) return false
158
+ const checkpoints = session.checkpoints ?? []
159
+ if (checkpoints.length < 3) return true
160
+ const threshold = new Date(checkpoints[checkpoints.length - 3]).getTime()
161
+ return st.mtimeMs >= threshold
162
+ } catch {
163
+ return false
164
+ }
165
+ }
package/src/taps/agy.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // agy tap — Antigravity CLI 1.2.0 is a closed Go binary with no hooks and no
2
- // usage percentage on any surface Baton can read (its own status line fetches
2
+ // usage percentage on any surface Leg can read (its own status line fetches
3
3
  // a quota summary from the backend; the value is not written anywhere). What
4
4
  // it does give:
5
- // --log-file <path> one log per Baton session; the wall shows up as
5
+ // --log-file <path> one log per Leg session; the wall shows up as
6
6
  // RESOURCE_EXHAUSTED / "it resets in <d>" / "out of quota"
7
7
  // (strings present in agy.exe; docs-only until hit live)
8
8
  // ~/.gemini/antigravity-cli/history.jsonl
@@ -3,7 +3,7 @@
3
3
  // Why not the status line: Claude Code 2.1.268 renders its built-in status
4
4
  // line and does not run a custom `statusLine` command passed via --settings
5
5
  // or a project settings file (verified 2026-09-11 with an `echo` command at
6
- // both levels; hooks from the same --settings file do run). So Baton asks the
6
+ // both levels; hooks from the same --settings file do run). So Leg asks the
7
7
  // usage endpoint directly with the OAuth token Claude Code stored at login.
8
8
  // The token is read by this process only, sent only to api.anthropic.com,
9
9
  // and never written anywhere (the ledger scrubs bearer tokens regardless).