@ucsandman/legcli 0.8.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 (110) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +21 -12
  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/board-guide.md +13 -0
  12. package/docs/cli-contracts.md +22 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +22 -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/fixtures/verified.json +1 -1
  19. package/package.json +7 -3
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/check-branding.mjs +118 -0
  22. package/scripts/check-claims.mjs +1 -1
  23. package/scripts/license-sign.mjs +1 -1
  24. package/scripts/limits-table.mjs +1 -1
  25. package/scripts/live-limits.mjs +1 -1
  26. package/scripts/npm-publish-gate.mjs +114 -0
  27. package/scripts/probe.mjs +4 -3
  28. package/scripts/seed-fake-cards.mjs +4 -3
  29. package/scripts/seed-floor-board.mjs +5 -4
  30. package/scripts/seed-wes-board.mjs +5 -4
  31. package/scripts/stripe-setup.mjs +1 -1
  32. package/scripts/sync-harness-engine.mjs +159 -0
  33. package/scripts/sync-leg-agents.mjs +127 -0
  34. package/src/accounts.mjs +1 -2
  35. package/src/adapters/codex.mjs +1 -1
  36. package/src/attach.mjs +75 -19
  37. package/src/auth.mjs +2 -2
  38. package/src/board/board.js +3 -3
  39. package/src/board/sessions.js +77 -3
  40. package/src/bundle.mjs +54 -8
  41. package/src/chain.mjs +1 -1
  42. package/src/contract.mjs +4 -3
  43. package/src/fsx.mjs +5 -2
  44. package/src/handoff.mjs +6 -6
  45. package/src/harness/cli.mjs +281 -0
  46. package/src/harness/fingerprint.mjs +68 -0
  47. package/src/harness/index.mjs +407 -0
  48. package/src/harness/registry.mjs +124 -0
  49. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  50. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  51. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  52. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  53. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  54. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  55. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  56. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  57. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  69. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  70. package/src/hook.mjs +49 -49
  71. package/src/land.mjs +7 -35
  72. package/src/launcher.mjs +38 -26
  73. package/src/ledger.mjs +6 -6
  74. package/src/license.mjs +10 -9
  75. package/src/live-capture.mjs +1 -1
  76. package/src/mergequeue.mjs +5 -5
  77. package/src/orchestrator.mjs +28 -4
  78. package/src/preferences.mjs +37 -3
  79. package/src/redact.mjs +1 -1
  80. package/src/resume.mjs +17 -15
  81. package/src/runner.mjs +2 -2
  82. package/src/scheduler.mjs +1 -1
  83. package/src/server.mjs +38 -10
  84. package/src/session-detail.mjs +15 -1
  85. package/src/sessions.mjs +6 -3
  86. package/src/share.mjs +2 -2
  87. package/src/stations/agent.mjs +1 -1
  88. package/src/sync/dashclaw.mjs +4 -4
  89. package/src/synthesis.mjs +165 -0
  90. package/src/taps/agy.mjs +2 -2
  91. package/src/taps/claude-usage.mjs +1 -1
  92. package/src/taps/claude.mjs +170 -170
  93. package/src/taps/codex.mjs +286 -286
  94. package/src/taps/grok.mjs +2 -2
  95. package/src/trust.mjs +205 -36
  96. package/src/usage.mjs +5 -1
  97. package/src/worktree.mjs +5 -4
  98. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  99. package/fixtures/live/agy/err.log +0 -0
  100. package/fixtures/live/agy/out.log +0 -1
  101. package/fixtures/live/agy/supervisor.log +0 -2
  102. package/fixtures/live/claude/err.log +0 -0
  103. package/fixtures/live/claude/out.log +0 -1
  104. package/fixtures/live/claude/supervisor.log +0 -2
  105. package/fixtures/live/codex/err.log +0 -1
  106. package/fixtures/live/codex/out.log +0 -8
  107. package/fixtures/live/codex/supervisor.log +0 -2
  108. package/fixtures/live/grok/err.log +0 -32
  109. package/fixtures/live/grok/out.log +0 -7
  110. package/fixtures/live/grok/supervisor.log +0 -2
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)
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,7 +151,7 @@ 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
  }
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,6 +26,7 @@ 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
31
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
31
32
  import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
@@ -34,7 +35,17 @@ 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' }
@@ -258,6 +269,8 @@ function redactSession(s) {
258
269
  // the branch is already on the worktree chip: naming it again costs nothing
259
270
  // and is what the board's land line reads
260
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,
261
274
  task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
262
275
  land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
263
276
  }
@@ -290,6 +303,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
290
303
  handoff_availability_known: availabilityKnown,
291
304
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
292
305
  active: isActive(s),
306
+ has_synthesis: hasRecentSynthesis(s),
293
307
  overlap: ov.get(s.session_id) ?? [],
294
308
  elapsed_ms: Date.now() - Date.parse(s.started_at),
295
309
  // Older attached Codex processes can retain one parser mistake where an
@@ -352,10 +366,11 @@ function readBody(req) {
352
366
  }
353
367
 
354
368
  function serveStatic(res, urlPath) {
355
- const map = { '/': 'index.html', '/floor': 'floor.html' }
369
+ const map = { '/': 'index.html', '/board': 'index.html', '/board/': 'index.html', '/floor': 'floor.html', '/floor/': 'floor.html' }
356
370
  const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
357
- const file = resolve(BOARD_DIR, rel)
358
- 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')
359
374
  res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
360
375
  res.end(readFileSync(file))
361
376
  }
@@ -460,7 +475,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
460
475
  // ---- the server ----
461
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 } = {}) {
462
477
  // An explicit `share` (tests) is fixed; the real server passes none and reads
463
- // 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
464
479
  // share add|rotate|rm` takes effect on a live board — a new link works at
465
480
  // once and a removed or rotated one stops at once — without a restart.
466
481
  const explicitShare = share !== undefined
@@ -599,7 +614,20 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
599
614
  if (req.method === 'POST' || req.method === 'PATCH') {
600
615
  const body = await readBody(req)
601
616
  try {
602
- 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)
603
631
  sse.broadcast('sessions', (v) => viewFor(v))
604
632
  return send(res, 200, { preferences })
605
633
  } catch (err) {
@@ -814,12 +842,12 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
814
842
  const addr = server.address()
815
843
  log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
816
844
  // A terminal that crashed instead of exiting left its hand-off in
817
- // .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
818
846
  // after a crash, so it is where that gets corrected.
819
847
  setImmediate(() => {
820
848
  try {
821
849
  const touched = refreshPointers()
822
- 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`)
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`)
823
851
  } catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
824
852
  })
825
853
  if (scheduler) {
@@ -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)
@@ -44,7 +44,7 @@ export function listSessions() {
44
44
 
45
45
  export function isActive(s) { return ACTIVE.includes(s?.status) }
46
46
 
47
- // 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
48
48
  // it one (repo stays the main checkout, for grouping and landing), else the repo.
49
49
  export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
50
50
 
@@ -58,10 +58,13 @@ export function createSession({ id, agent, account = 'default', cwd, repo = null
58
58
  task: null, turns: 0,
59
59
  files_touched: [], files_dirty: [], head: null, head_at_start: null,
60
60
  limits: null, limit: null, warning: null,
61
- bundle: null, handoff: null, chain,
61
+ bundle: null, handoff: null, chain, checkpoints: [],
62
62
  handoff_order: normalizeHandoffOrder(handoffOrder), installed,
63
63
  runtime_capabilities: [...new Set(runtimeCapabilities)],
64
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,
65
68
  exit_code: null,
66
69
  }
67
70
  mkdirSync(sessionDir(id), { recursive: true })
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).