@ucsandman/legcli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -558
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +64 -34
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/adapters.md +17 -3
  12. package/docs/board-guide.md +13 -0
  13. package/docs/cli-contracts.md +57 -3
  14. package/docs/concepts.md +42 -3
  15. package/docs/configuration.md +42 -2
  16. package/docs/faq.md +19 -0
  17. package/docs/getting-started.md +272 -251
  18. package/docs/harness.md +319 -0
  19. package/fixtures/limits/grok/grok-rate-limit.json +11 -0
  20. package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
  21. package/fixtures/verified.json +1 -1
  22. package/package.json +8 -4
  23. package/scripts/build-docs-site.mjs +15 -7
  24. package/scripts/check-branding.mjs +118 -0
  25. package/scripts/check-claims.mjs +1 -1
  26. package/scripts/license-sign.mjs +1 -1
  27. package/scripts/limits-table.mjs +1 -1
  28. package/scripts/live-limits.mjs +1 -1
  29. package/scripts/npm-publish-gate.mjs +114 -0
  30. package/scripts/probe.mjs +4 -3
  31. package/scripts/seed-fake-cards.mjs +4 -3
  32. package/scripts/seed-floor-board.mjs +5 -4
  33. package/scripts/seed-wes-board.mjs +5 -4
  34. package/scripts/stripe-setup.mjs +1 -1
  35. package/scripts/sync-harness-engine.mjs +159 -0
  36. package/scripts/sync-leg-agents.mjs +127 -0
  37. package/src/accounts.mjs +10 -2
  38. package/src/adapters/codex.mjs +1 -1
  39. package/src/adapters/grok.mjs +4 -7
  40. package/src/attach.mjs +162 -37
  41. package/src/auth.mjs +2 -2
  42. package/src/board/board.css +45 -17
  43. package/src/board/board.js +4 -4
  44. package/src/board/floor.js +2 -2
  45. package/src/board/sessions.js +181 -38
  46. package/src/bundle.mjs +54 -8
  47. package/src/chain.mjs +1 -1
  48. package/src/contract.mjs +4 -3
  49. package/src/fsx.mjs +5 -2
  50. package/src/handoff.mjs +6 -6
  51. package/src/harness/cli.mjs +281 -0
  52. package/src/harness/fingerprint.mjs +68 -0
  53. package/src/harness/index.mjs +407 -0
  54. package/src/harness/registry.mjs +124 -0
  55. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  56. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  57. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  58. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  74. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  75. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  76. package/src/hook.mjs +49 -49
  77. package/src/land.mjs +660 -47
  78. package/src/launcher.mjs +40 -27
  79. package/src/ledger.mjs +6 -6
  80. package/src/license.mjs +10 -9
  81. package/src/live-capture.mjs +1 -1
  82. package/src/mergequeue.mjs +6 -6
  83. package/src/orchestrator.mjs +28 -4
  84. package/src/preferences.mjs +63 -9
  85. package/src/redact.mjs +1 -1
  86. package/src/resume.mjs +17 -15
  87. package/src/runner.mjs +3 -3
  88. package/src/scheduler.mjs +1 -1
  89. package/src/server.mjs +69 -20
  90. package/src/session-detail.mjs +15 -1
  91. package/src/sessions.mjs +9 -5
  92. package/src/share.mjs +2 -2
  93. package/src/stations/agent.mjs +1 -1
  94. package/src/sync/dashclaw.mjs +4 -4
  95. package/src/synthesis.mjs +165 -0
  96. package/src/taps/agy.mjs +2 -2
  97. package/src/taps/claude-usage.mjs +1 -1
  98. package/src/taps/claude.mjs +170 -170
  99. package/src/taps/codex.mjs +286 -286
  100. package/src/taps/grok.mjs +251 -0
  101. package/src/trust.mjs +205 -36
  102. package/src/usage.mjs +5 -1
  103. package/src/worktree.mjs +5 -4
  104. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  105. package/fixtures/live/agy/err.log +0 -0
  106. package/fixtures/live/agy/out.log +0 -1
  107. package/fixtures/live/agy/supervisor.log +0 -2
  108. package/fixtures/live/claude/err.log +0 -0
  109. package/fixtures/live/claude/out.log +0 -1
  110. package/fixtures/live/claude/supervisor.log +0 -2
  111. package/fixtures/live/codex/err.log +0 -1
  112. package/fixtures/live/codex/out.log +0 -8
  113. package/fixtures/live/codex/supervisor.log +0 -2
  114. package/fixtures/live/grok/err.log +0 -32
  115. package/fixtures/live/grok/out.log +0 -7
  116. package/fixtures/live/grok/supervisor.log +0 -2
@@ -1,7 +1,7 @@
1
1
  // Terminals lane: the window rail (5h/7d per login), the sessions started with
2
- // `baton claude|codex|agy`, overlap flags (two live sessions editing the same
2
+ // `leg claude|codex|agy`, overlap flags (two live sessions editing the same
3
3
  // file), and what has landed on trunk. Data: /api/sessions, pushed as the SSE
4
- // `sessions` event (board.js re-dispatches it as `baton:sessions`).
4
+ // `sessions` event (board.js re-dispatches it as `leg:sessions`).
5
5
  //
6
6
  // The design is .design/BOARD-DESIGN.md sections 6.1 to 6.6; the ids, class
7
7
  // names and frozen source shapes are .design/BUILD-CONTRACT.md section 6.1.
@@ -22,7 +22,7 @@
22
22
  handing_off: ['handing off', 'warn'], waiting: ['waiting for reset', 'warn'], handed_off: ['handed off', 'idle'], ended: ['ended', 'idle'], lost: ['lost', 'danger'],
23
23
  }
24
24
  const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
25
- const IDS = ['claude', 'codex', 'agy', 'fake']
25
+ const IDS = ['claude', 'codex', 'agy', 'grok', 'fake']
26
26
 
27
27
  let view = null
28
28
  const NO_BRANCH_BLOCKER = 'this terminal works in the checkout itself: there is no branch of its own to land'
@@ -643,15 +643,15 @@
643
643
  // A result that belongs to a terminal is written into that terminal's sentence
644
644
  // slot, where the reader is already looking. Only a result that belongs to no
645
645
  // object on this page goes to the one system message.
646
- async function act(id, action, btn) {
646
+ async function act(id, action, btn, body = null) {
647
647
  btn.disabled = true
648
648
  actionNotes.delete(id)
649
649
  try {
650
650
  if (action.startsWith('requests/')) {
651
- await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
651
+ await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
652
652
  actionNotes.set(id, { at: Date.now(), tone: 'ok', text: action.endsWith('approve') ? 'approved; this terminal hands off in a few seconds' : 'the request was dismissed' })
653
653
  } else if (action === 'request-handoff') {
654
- await api(`/api/sessions/${encodeURIComponent(id)}/request-handoff`, { method: 'POST' })
654
+ await api(`/api/sessions/${encodeURIComponent(id)}/request-handoff`, { method: 'POST', body })
655
655
  sysMessage('asked; the owner of that terminal decides', 'ok')
656
656
  } else if (action === 'remove') {
657
657
  const r = await api(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
@@ -660,9 +660,10 @@
660
660
  await api(`/api/sessions/${encodeURIComponent(id)}?force=1&keep_worktree=1`, { method: 'DELETE' })
661
661
  sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
662
662
  } else {
663
- await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
663
+ await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
664
664
  if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'hand-off requested; this terminal switches agents in a few seconds' })
665
665
  else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
666
+ else if (action === 'land/fix') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'applied fix' })
666
667
  }
667
668
  refresh()
668
669
  } catch (err) {
@@ -774,6 +775,11 @@
774
775
  if (shared() && s.owner) register.appendChild(el('span', { class: 'chip' }, [isMine(s) ? `${s.owner}, you` : s.owner]))
775
776
  if (s.lineage && s.lineage.from) register.appendChild(el('span', { class: 'chip' }, [`from ${s.lineage.from}`]))
776
777
  if (s.worktree) register.appendChild(el('span', { class: 'chip' }, [`own worktree, from ${s.worktree.base || 'a detached HEAD'}`]))
778
+ if (s.has_synthesis) register.appendChild(el('span', { class: 'chip', title: 'synthesis record active' }, ['synthesis']))
779
+ // the portable harness, one word: what this leg's client received from the
780
+ // source harness (src/harness/index.mjs STATES); nothing when the feature is off
781
+ const hb = harnessBadge(s.harness)
782
+ if (hb) register.appendChild(el('span', { class: hb.cls, title: hb.title }, [hb.text]))
777
783
  body.appendChild(register)
778
784
 
779
785
  if (s.hidden) body.appendChild(el('p', { class: 'term-prompt term-prompt--empty' }, ['prompt hidden']))
@@ -849,25 +855,63 @@
849
855
  // G10: the order is Land, Hand off now, Details, End, and it never reflows
850
856
  // by availability. A button that does not apply is omitted, never moved.
851
857
  const landing = Boolean(s.land && s.land.state === 'landing')
852
- const blocker = s.worktree ? s.land_blocker : NO_BRANCH_BLOCKER
853
- // G10 is disabled-with-its-reason, so the reason is attached to the control
854
- // as well as printed: the title used to be on the inverse condition, giving
855
- // the tooltip to the button that explains itself and none to the one that
856
- // needs it, and nothing connected the sentence below to the button above.
858
+ const cl = s.can_land || (s.worktree ? (s.land_blocker ? { ok: false, blockers: [{ code: 'legacy', message: s.land_blocker }] } : { ok: true, blockers: [] }) : { ok: false, blockers: [{ code: 'no_worktree', message: NO_BRANCH_BLOCKER }] })
859
+ const isOnTarget = cl.blockers && cl.blockers.some((b) => b.code === 'on_target_branch')
860
+ const targetLocked = cl.blockers && cl.blockers.find((b) => b.code === 'target_locked')
861
+ const blocker = !cl.ok ? (cl.blockers[0]?.message || NO_BRANCH_BLOCKER) : null
857
862
  const blockerId = blocker ? `land-blocker-${s.session_id}` : null
858
- // Land is the primary action only when it can actually run. A disabled
859
- // button painted in the one accent colour spends the loudest thing in the
860
- // design on something the reader cannot do.
861
- const land = el('button', {
862
- type: 'button',
863
- class: `btn ${blocker ? 'btn-secondary' : 'btn-primary'}${landing ? ' is-loading' : ''}`,
864
- disabled: blocker || landing ? '' : null,
865
- 'data-focus-key': `land:${s.session_id}`,
866
- 'aria-describedby': blockerId,
867
- title: blocker || `commit this terminal's work on ${s.worktree.branch}, rebase it onto ${s.worktree.base}, run the tests, fast-forward ${s.worktree.base}; a bounce says why`,
868
- }, [landing ? 'Landing…' : 'Land'])
869
- land.addEventListener('click', () => act(s.session_id, 'land', land))
870
- actions.appendChild(land)
863
+
864
+ if (isOnTarget) {
865
+ const commitDirect = el('button', {
866
+ type: 'button',
867
+ class: 'btn btn-secondary',
868
+ 'data-focus-key': `commit-direct:${s.session_id}`,
869
+ title: `Commit directly on ${s.branch || s.worktree?.base || 'main'}`,
870
+ }, ['Commit directly'])
871
+ commitDirect.addEventListener('click', () => act(s.session_id, 'land/fix', commitDirect, { action: 'commit_directly' }))
872
+ actions.appendChild(commitDirect)
873
+ } else {
874
+ const landLabel = landing ? 'Landing…' : targetLocked ? targetLocked.message : 'Land'
875
+ const landDisabled = !cl.ok || landing
876
+ const land = el('button', {
877
+ type: 'button',
878
+ class: `btn ${landDisabled ? 'btn-secondary' : 'btn-primary'}${landing ? ' is-loading' : ''}`,
879
+ disabled: landDisabled ? '' : null,
880
+ 'data-focus-key': `land:${s.session_id}`,
881
+ 'aria-describedby': blockerId,
882
+ title: blocker || (s.worktree ? `commit work on ${s.worktree.branch}, rebase onto ${s.worktree.base}, test, and fast-forward ${s.worktree.base}` : 'Land'),
883
+ }, [landLabel])
884
+ if (!landDisabled) {
885
+ land.addEventListener('click', async () => {
886
+ land.disabled = true
887
+ land.classList.add('is-loading')
888
+ land.textContent = 'Preparing…'
889
+ try {
890
+ const res = await api(`/api/sessions/${encodeURIComponent(s.session_id)}/land/prepare`, { method: 'POST' })
891
+ land.classList.remove('is-loading')
892
+ if (!res.ok) {
893
+ actionNotes.set(s.session_id, { at: Date.now(), tone: 'danger', text: res.error || 'Prepare failed' })
894
+ refresh()
895
+ return
896
+ }
897
+ const statText = res.diff_stat || `${res.files?.length || 0} files changed`
898
+ const question = `Prepared: ${statText} · tests green. Land onto ${s.worktree?.base || 'main'} and ship to GitHub?`
899
+ pendingConfirm = {
900
+ id: s.session_id,
901
+ question,
902
+ verb: 'Land',
903
+ action: 'land',
904
+ }
905
+ renderSessions(view)
906
+ } catch (err) {
907
+ land.classList.remove('is-loading')
908
+ actionNotes.set(s.session_id, { at: Date.now(), tone: 'danger', text: err.message })
909
+ refresh()
910
+ }
911
+ })
912
+ }
913
+ actions.appendChild(land)
914
+ }
871
915
  if (s.active) {
872
916
  const h = el('button', { type: 'button', class: `btn ${blocker ? 'btn-primary' : 'btn-secondary'}`, title: 'save the bundle, stop this agent, start the next option in the same terminal', 'data-focus-key': `handoff:${s.session_id}` }, ['Hand off now'])
873
917
  h.addEventListener('click', () => act(s.session_id, 'handoff', h))
@@ -901,8 +945,33 @@
901
945
  }
902
946
  row.appendChild(actions)
903
947
  term.appendChild(row)
904
- // the reason a disabled control is disabled is printed, never left in a title
905
- if (blocker) term.appendChild(el('p', { class: 'blocker', id: blockerId }, [blocker]))
948
+
949
+ // Why can't I land expander or fallback blocker message
950
+ if (!cl.ok && !isOnTarget && s.worktree) {
951
+ const list = el('ul', { class: 'blocker-list' })
952
+ for (const b of cl.blockers) {
953
+ const item = el('li', { class: 'blocker-item' })
954
+ item.appendChild(el('span', { class: 'blocker-msg' }, [b.message]))
955
+ const fixes = b.fixes || (b.fix ? [b.fix] : [])
956
+ if (fixes.length) {
957
+ const grp = el('span', { class: 'blocker-fixes' })
958
+ for (const f of fixes) {
959
+ const fBtn = el('button', { type: 'button', class: 'btn btn-sm btn-secondary', title: f.label }, [f.label])
960
+ fBtn.addEventListener('click', () => act(s.session_id, 'land/fix', fBtn, { action: f.action, target_session: f.target_session }))
961
+ grp.appendChild(fBtn)
962
+ }
963
+ item.appendChild(grp)
964
+ }
965
+ list.appendChild(item)
966
+ }
967
+ const expander = el('details', { class: 'why-cant-land', id: blockerId }, [
968
+ el('summary', { class: 'why-cant-land-summary' }, ["Why can't I land?"]),
969
+ list,
970
+ ])
971
+ term.appendChild(expander)
972
+ } else if (blocker) {
973
+ term.appendChild(el('p', { class: 'blocker', id: blockerId }, [blocker]))
974
+ }
906
975
  return term
907
976
  }
908
977
 
@@ -1014,9 +1083,12 @@
1014
1083
  }
1015
1084
 
1016
1085
  function messageRow(m, key) {
1017
- return el('div', { class: 'turn drawer-msg' }, [
1018
- el('span', { class: 'turn-role drawer-msg-role' }, [m.role === 'user' ? 'human' : 'agent']),
1019
- m.ts ? el('span', { class: 'turn-when drawer-msg-when' }, [whenAgo(m.ts)]) : null,
1086
+ const isUser = m.role === 'user'
1087
+ return el('div', { class: `turn drawer-msg ${isUser ? 'is-human' : 'is-agent'}` }, [
1088
+ el('div', { class: 'drawer-msg-head' }, [
1089
+ el('span', { class: `turn-role drawer-msg-role ${isUser ? '' : 'chip-state-ok'}` }, [isUser ? 'human' : 'agent']),
1090
+ m.ts ? el('span', { class: 'turn-when drawer-msg-when' }, [whenAgo(m.ts)]) : null,
1091
+ ]),
1020
1092
  // every box that can scroll carries a key, so where the reader had
1021
1093
  // scrolled to survives the rebuild three seconds later
1022
1094
  el('p', { 'data-scroll-key': `msg:${key}` }, [m.text]),
@@ -1024,7 +1096,7 @@
1024
1096
  }
1025
1097
 
1026
1098
  function fileRow(f) {
1027
- const wrap = el('div', {})
1099
+ const wrap = el('div', { class: 'drawer-file-item' })
1028
1100
  const pre = el('pre', { class: 'drawer-diff', hidden: '', 'data-scroll-key': `diff:${f.path}` })
1029
1101
  const row = el('button', { type: 'button', class: 'btn btn-text file-row', 'aria-expanded': 'false', 'data-focus-key': `file:${f.path}` }, [
1030
1102
  el('span', { class: 'mono', title: f.path }, [f.path]),
@@ -1080,6 +1152,71 @@
1080
1152
  return el('p', { class: cls, title: 'freshness is recomputed from git on every poll; leg resume --check' }, [text])
1081
1153
  }
1082
1154
 
1155
+ // ---- the portable harness ----
1156
+ // Every word here comes from the outcome the session recorded when the leg
1157
+ // started (src/harness/index.mjs prepareHarnessForHandoff), never from a guess.
1158
+ const HARNESS_WORD = { synced: 'harness synced', partial: 'harness partial', stale: 'harness stale', attention: 'harness attention', blocked: 'harness refused', error: 'harness error', unsupported: 'harness unsupported', source: 'harness source' }
1159
+ function harnessBadge(h) {
1160
+ if (!h || h.state === 'off' || h.state === 'same-client') return null
1161
+ const text = HARNESS_WORD[h.state] || `harness ${h.state}`
1162
+ const cls = h.state === 'synced' || h.state === 'partial' || h.state === 'source' ? 'chip chip-state-ok' : h.state === 'unsupported' ? 'chip' : 'chip is-stale'
1163
+ return { text, cls, title: h.summary || text }
1164
+ }
1165
+
1166
+ function harnessSection(s, d) {
1167
+ const h = (d && d.harness) || s.harness
1168
+ if (!h || h.state === 'off') return null
1169
+ const box = el('div', { class: 'detail-section' })
1170
+ const src = h.source ? `${h.source}` : 'unknown'
1171
+ const captured = h.captured_at ? `captured ${whenAgo(h.captured_at)}` : 'not captured'
1172
+ const head = h.state === 'same-client'
1173
+ ? `${h.to} to ${h.to}: same client, same harness`
1174
+ : h.state === 'source' ? `${h.target} is the source of the harness; nothing to carry`
1175
+ : `source ${src}, ${captured}${h.synced_at ? `, synced ${whenAgo(h.synced_at)}` : ''}${h.policy ? `, policy ${h.policy}` : ''}`
1176
+ box.appendChild(el('div', { class: 'well' }, [
1177
+ el('div', {}, [head]),
1178
+ h.summary && h.state !== 'same-client' && h.state !== 'source' ? el('p', { class: `sentence ${h.state === 'synced' || h.state === 'partial' ? 'tone-ok' : h.state === 'unsupported' ? 'tone-muted' : 'tone-warn'}` }, [h.summary]) : null,
1179
+ h.reason && (h.state === 'blocked' || h.state === 'error' || h.state === 'unsupported') ? el('p', { class: 'blocker' }, [h.reason]) : null,
1180
+ ]))
1181
+ if (h.components) {
1182
+ const rows = el('div', { class: 'drawer-timeline' })
1183
+ for (const [name, c] of Object.entries(h.components)) {
1184
+ const count = c.total !== null && c.total !== undefined ? `${c.carried} / ${c.total}` : ''
1185
+ rows.appendChild(el('div', { class: 'turn timeline-item' }, [
1186
+ el('span', { class: 'mono turn-when' }, [name]),
1187
+ el('span', { class: 'turn-role' }, [c.state]),
1188
+ el('p', { class: 'timeline-summary' }, [`${count}${c.note ? `${count ? ' · ' : ''}${c.note}` : ''}`]),
1189
+ ]))
1190
+ }
1191
+ box.appendChild(rows)
1192
+ }
1193
+ const dropped = h.dropped || []
1194
+ const attention = h.attention || []
1195
+ if (attention.length) {
1196
+ const list = el('div', {})
1197
+ for (const a of attention) list.appendChild(el('p', { class: 'blocker' }, [`${a.component}: ${a.file ? `${a.file}: ` : ''}${a.reason}`]))
1198
+ box.appendChild(el('div', { class: 'detail-section' }, [el('div', {}, ['Needs you']), list]))
1199
+ }
1200
+ if (dropped.length) {
1201
+ const list = el('div', {})
1202
+ for (const dr of dropped) list.appendChild(el('p', { class: 'sentence tone-muted' }, [`${dr.component}: ${dr.item}${dr.excluded ? ' (excluded by policy)' : ''}. ${dr.reason}`]))
1203
+ box.appendChild(el('div', { class: 'detail-section' }, [el('div', {}, [`Dropped (${dropped.length})`]), list]))
1204
+ }
1205
+ const history = (d && d.harness && d.harness.history) || []
1206
+ if (history.length) {
1207
+ const list = el('div', { class: 'drawer-timeline' })
1208
+ for (const r of history.slice(-8).reverse()) {
1209
+ list.appendChild(el('div', { class: 'turn timeline-item' }, [
1210
+ el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(r.ts))]),
1211
+ el('span', { class: 'turn-role' }, [r.op]),
1212
+ el('p', { class: 'timeline-summary' }, [r.op === 'apply' ? `${r.source} to ${r.target}: ${r.state}, ${r.written || 0} written, ${(r.backups || []).length} backed up` : r.op === 'capture' ? `${r.source} captured` : `${r.from || 'start'} to ${r.to}: ${r.state}${r.proceed === false ? ', refused' : ''}`]),
1213
+ ]))
1214
+ }
1215
+ box.appendChild(list)
1216
+ }
1217
+ return box
1218
+ }
1219
+
1083
1220
  function section(title, note, body) {
1084
1221
  const s = el('section', { class: 'detail-section' }, [
1085
1222
  el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
@@ -1115,16 +1252,18 @@
1115
1252
  return
1116
1253
  }
1117
1254
  const [label] = STATUS[s.status] || [s.status]
1118
- const controls = el('div', { class: 'cap-line' }, [
1255
+ const left = el('div', { class: 'detail-brand' }, [
1256
+ el('span', { class: `dot id-${idOf(s.agent)}` }),
1119
1257
  el('span', { class: `acct-name chip-id-${idOf(s.agent)}` }, [s.agent]),
1120
- el('span', { class: 'chip' }, [tail(s.session_id)]),
1258
+ el('span', { class: 'chip mono' }, [tail(s.session_id)]),
1259
+ el('span', { class: s.active ? 'chip chip-state-ok' : 'chip is-stale' }, [s.active ? (label || 'running') : (label || s.status)]),
1121
1260
  ])
1122
1261
  const pause = el('button', { type: 'button', class: 'btn btn-secondary', id: 'session-drawer-pause', 'data-focus-key': 'drawer-pause' }, [drawer.paused ? 'Resume updates' : 'Pause updates'])
1123
1262
  pause.addEventListener('click', () => { drawer.paused = !drawer.paused; if (!drawer.paused) loadDrawer(); else renderDrawer() })
1124
1263
  const close = el('button', { type: 'button', class: 'btn btn-secondary', id: 'session-drawer-close', 'data-focus-key': 'drawer-close' }, ['Close'])
1125
1264
  close.addEventListener('click', closeSessionDrawer)
1126
- controls.append(pause, close)
1127
- box.appendChild(controls)
1265
+ const header = el('div', { class: 'detail-masthead' }, [left, el('div', { class: 'detail-ctrls' }, [pause, close])])
1266
+ box.appendChild(header)
1128
1267
  if (drawer.error) box.appendChild(el('p', { class: 'sentence tone-danger' }, [drawer.error]))
1129
1268
 
1130
1269
  const last = d && d.messages ? [...d.messages].reverse().find((m) => m.role === 'assistant') : null
@@ -1171,10 +1310,10 @@
1171
1310
  // header on the same panel printing the same instant as 11:04 PM. The
1172
1311
  // summary is a block, as board.js:754 builds the same row, or the kind
1173
1312
  // word and the sentence render glued: `lostrunner pid 999002 is gone`.
1174
- timeline.appendChild(el('div', { class: 'turn' }, [
1313
+ timeline.appendChild(el('div', { class: 'turn timeline-item' }, [
1175
1314
  el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(e.ts))]),
1176
1315
  el('span', { class: 'turn-role' }, [e.type]),
1177
- el('p', {}, [e.summary || '']),
1316
+ el('p', { class: 'timeline-summary' }, [e.summary || '']),
1178
1317
  ]))
1179
1318
  }
1180
1319
  if (!events.length) timeline.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'nothing recorded yet' : 'reading the timeline']))
@@ -1187,6 +1326,9 @@
1187
1326
  if (s.bundle) next.appendChild(el('p', { class: 'blocker' }, [`bundle ${s.bundle.id}${s.bundle.at ? `, saved ${whenAgo(s.bundle.at)}` : ''}`]))
1188
1327
  if (d && d.resume) next.appendChild(resumeLine(d.resume))
1189
1328
  box.appendChild(section('What happens next', '', next))
1329
+
1330
+ const harness = harnessSection(s, d)
1331
+ if (harness) box.appendChild(section('Harness', 'the working environment this leg was given', harness))
1190
1332
  putScroll(box, inner)
1191
1333
  putFocus(box, focus)
1192
1334
  }
@@ -1433,7 +1575,8 @@
1433
1575
  }
1434
1576
 
1435
1577
  window.addEventListener('leg:sessions', (e) => render(e.detail));
1436
- window.addEventListener('baton:sessions', (e) => render(e.detail));
1578
+ window.addEventListener('leg:sessions', (e) => render(e.detail));
1579
+ window.addEventListener('baton:sessions', (e) => render(e.detail)); // legacy alias
1437
1580
  document.addEventListener('keydown', (e) => {
1438
1581
  if (e.key !== 'Escape') return
1439
1582
  if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
package/src/bundle.mjs CHANGED
@@ -9,6 +9,7 @@ import { chb, ensureExcluded } from './handoff.mjs'
9
9
  import { scrub } from './redact.mjs'
10
10
  import { updateSession, workRoot } from './sessions.mjs'
11
11
  import { perSessionFile, writeHandoffPointer } from './resume.mjs'
12
+ import { readSynthesis, formatSynthesisSection, synthesisDirective, SYNTHESIS_POINTER_PARAGRAPH } from './synthesis.mjs'
12
13
 
13
14
  const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
14
15
  const bullets = (items) => items.filter(Boolean).map((x) => `- ${String(x).replace(/\r?\n/g, ' ').trim()}`)
@@ -21,12 +22,31 @@ function git(cwd, args) {
21
22
 
22
23
  export function slugFor(session) { return `leg-${session.session_id}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80) }
23
24
 
25
+ export function sessionCommitDelta(cwd, session) {
26
+ if (!cwd) return { isClean: false, dirty: [], newCommits: [] }
27
+ const dirty = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '')).filter((f) => !LEG_DIRS.test(f)).slice(0, 60)
28
+ const isClean = dirty.length === 0
29
+ let newCommits = []
30
+ if (isClean && session?.head_at_start) {
31
+ const head = git(cwd, ['rev-parse', 'HEAD']).trim()
32
+ if (head && head !== session.head_at_start) {
33
+ const raw = git(cwd, ['log', '--oneline', `${session.head_at_start}..${head}`])
34
+ if (raw) newCommits = raw.split('\n').filter(Boolean)
35
+ }
36
+ }
37
+ return { isClean, dirty, newCommits }
38
+ }
39
+
24
40
  // Notes in the CLI's section vocabulary; see src/handoff.mjs buildNotes.
25
41
  export function sessionNotes(session, { messages = [], why = 'handoff' } = {}) {
26
42
  const cwd = workRoot(session)
27
43
  const stat = git(cwd, ['diff', '--stat'])
28
- const dirty = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '')).filter((f) => !LEG_DIRS.test(f)).slice(0, 60)
44
+ const delta = sessionCommitDelta(cwd, session)
45
+ const dirty = delta.dirty
29
46
  const recent = git(cwd, ['log', '--oneline', '-5'])
47
+ const opportunity = delta.isClean && delta.newCommits.length > 0
48
+ ? `Next agent: read this bundle. The previous agent committed changes (${delta.newCommits.length} commit(s): ${delta.newCommits.slice(0, 3).join(' | ')}) and left a clean working tree. Check git log to verify whether the task is already satisfied before doing redundant work. Do not ask the human to restate the task.`
49
+ : 'Next agent: read this bundle, inspect `git status` and `git diff`, continue the task from the last agent message, and do not ask the human to restate the task.'
30
50
  const lines = [
31
51
  '## Scope', '',
32
52
  `Task: ${session.task ?? '(no prompt recorded yet; read the transcript)'}`,
@@ -39,7 +59,10 @@ export function sessionNotes(session, { messages = [], why = 'handoff' } = {}) {
39
59
  ...bullets((session.files_touched ?? []).slice(0, 50).map((f) => `Edited this session: ${f}`)),
40
60
  ...bullets(recent ? [`Recent commits: ${recent.replace(/\n/g, ' | ')}`] : []),
41
61
  '', '## Opportunities', '',
42
- ...bullets(['Next agent: read this bundle, inspect `git status` and `git diff`, continue the task from the last agent message, and do not ask the human to restate the task.']),
62
+ ...bullets([
63
+ opportunity,
64
+ synthesisDirective(session.session_id),
65
+ ]),
43
66
  '', '## Open questions', '',
44
67
  ...bullets([`Why the previous agent stopped: ${why}`, session.limit?.detail ? `Limit text: ${session.limit.detail}` : null]),
45
68
  '', '## Evidence anchors', '',
@@ -67,8 +90,18 @@ export function saveSessionBundle(session, { messages = [], why = 'checkpoint' }
67
90
  if (r.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${r.status}): ${scrub(r.stderr || r.stdout).slice(0, 400)}`)
68
91
  let out
69
92
  try { out = JSON.parse(r.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(r.stdout).slice(0, 200)}`) }
70
- const bundle = { id: out.bundle_id, path: join(cwd, '.context-handoffs', out.bundle_id), notes: notesPath, quality: out.quality ?? null, updated_at: new Date().toISOString(), why }
71
- updateSession(session.session_id, { bundle })
93
+ const nowIso = new Date().toISOString()
94
+ const bundle = { id: out.bundle_id, path: join(cwd, '.context-handoffs', out.bundle_id), notes: notesPath, quality: out.quality ?? null, updated_at: nowIso, why }
95
+ if (why === 'checkpoint') {
96
+ const checkpoints = [...(session.checkpoints ?? []), nowIso].slice(-20)
97
+ session.checkpoints = checkpoints
98
+ updateSession(session.session_id, (cur) => ({
99
+ bundle,
100
+ checkpoints: [...(cur?.checkpoints ?? []), nowIso].slice(-20),
101
+ }))
102
+ } else {
103
+ updateSession(session.session_id, { bundle })
104
+ }
72
105
  return bundle
73
106
  }
74
107
 
@@ -82,22 +115,35 @@ export function resumePrompt(session, bundle, next) {
82
115
  if (r.status === 0) loaded = r.stdout
83
116
  } catch {}
84
117
  const header = `# Leg handoff\n\nPrevious agent: ${session.agent} (${session.account}). Reason: ${session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'}${session.limit?.detail ? `, ${session.limit.detail}` : ''}.\nNext agent: ${next.agent} (${next.account}).\nBundle: ${bundle.path}\n\n`
85
- const body = header + (loaded || readFileSync(bundle.notes, 'utf8'))
118
+ const bundleDump = loaded || readFileSync(bundle.notes, 'utf8')
119
+ let synthesisSection = ''
120
+ try {
121
+ const rawSyn = readSynthesis(cwd, session.session_id)
122
+ if (rawSyn) {
123
+ synthesisSection = formatSynthesisSection(rawSyn, { sessionId: session.session_id })
124
+ }
125
+ } catch {}
126
+ const body = header + (synthesisSection ? `${synthesisSection}\n\n` : '') + bundleDump
86
127
  // src/resume.mjs owns both files: the per-session one so two sessions sharing
87
128
  // one checkout (--no-worktree, or two started in the same instant) never
88
129
  // overwrite each other's handoff, and RESUME.md, the copy everyone opens.
89
130
  // Both are stamped with the git state and the live terminals they describe,
90
- // so `baton resume --check` can tell a reader when they stopped being true.
131
+ // so `leg resume --check` can tell a reader when they stopped being true.
91
132
  const why = session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'
92
133
  writeHandoffPointer(session, body, { bundle, why })
93
134
  const perSession = perSessionFile(cwd, session.session_id)
94
135
  const task = session.task ? `\n\nThe task, as the human first stated it: ${session.task.slice(0, 700)}` : ''
136
+ const delta = sessionCommitDelta(cwd, session)
137
+ const actionText = delta.isClean && delta.newCommits.length > 0
138
+ ? `. The previous agent committed changes (${delta.newCommits.length} commit(s): ${delta.newCommits.slice(0, 2).join(' | ')}) and left a clean working tree. Check git log and verify whether the task is already complete before doing redundant work; continue only if work remains.`
139
+ : ', check git status and git diff, then continue the work from where it stopped.'
95
140
  // the absolute path: the next agent is spawned in the session's cwd, which is
96
- // a subdirectory of the work root whenever Baton was started in one
97
- return `You are taking over an interactive coding session from ${session.agent}, which hit its usage limit. Read ${perSession} (the context handoff bundle is at ${bundle.path}), check git status and git diff, then continue the work from where it stopped. Do not ask the human to restate the task.${task}`
141
+ // a subdirectory of the work root whenever Leg was started in one
142
+ return `You are taking over an interactive coding session from ${session.agent}, which hit its usage limit. Read ${perSession} (the context handoff bundle is at ${bundle.path})${actionText} Do not ask the human to restate the task.${task}\n\n${SYNTHESIS_POINTER_PARAGRAPH}`
98
143
  }
99
144
 
100
145
  // Freshness, never existence: src/resume.mjs recomputes it from git at read
101
146
  // time. `resumeFileExists` used to live here and answered "a file is on disk",
102
147
  // which every caller then read as "the handoff it describes is still true".
103
148
  export { resumeVerdict } from './resume.mjs'
149
+ export { synthesisFile, readSynthesis, validateSynthesis, validateSynthesisHeader, formatSynthesisSection, hasRecentSynthesis, synthesisDirective, SYNTHESIS_POINTER_PARAGRAPH } from './synthesis.mjs'
package/src/chain.mjs CHANGED
@@ -19,7 +19,7 @@ export const TRANSITIONS = [
19
19
  ['running', 'leg:completed', 'waiting_human', 'agent leg completed and a human station is next'],
20
20
  ['running', 'leg:handoff', 'handing_off', 'limit | incomplete | no_progress | stalled | failed and the chain has a next leg'],
21
21
  ['running', 'leg:handoff', 'failed', 'same outcomes with the chain exhausted'],
22
- ['running', 'leg:auth_failed', 'failed', 'Baton/environment fault; no advance; human fixes and clicks Rerun'],
22
+ ['running', 'leg:auth_failed', 'failed', 'Leg/environment fault; no advance; human fixes and clicks Rerun'],
23
23
  ['running', 'leg:launch_failed', 'failed', 'same'],
24
24
  ['running', 'leg:killed', 'killed', 'the leg was killed from the board'],
25
25
  ['handing_off', 'bundle_written', 'queued', 'next leg (leg+1) queued at the same station'],
package/src/contract.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // contract — the per-leg prompt. Every CLI gets the same contract file at
2
- // .baton/CONTRACT.md in the worktree (CLI-agnostic completion: write
3
- // .baton/DONE when finished). Leg 1 prompt = the contract; a later leg or a
2
+ // .leg/CONTRACT.md in the worktree (CLI-agnostic completion: write
3
+ // .leg/DONE when finished). Leg 1 prompt = the contract; a later leg or a
4
4
  // resume = the handoff bundle's resume text + the contract.
5
5
  import { mkdirSync, writeFileSync } from 'node:fs'
6
6
  import { join } from 'node:path'
@@ -44,8 +44,9 @@ export async function renderContract({ card, station, leg, entry, worktree, resu
44
44
  '',
45
45
  `- Work only inside this directory: ${worktree}. It is a git worktree on its own branch; commit as you go or leave changes uncommitted, both are fine.`,
46
46
  '- Keep .leg/PROGRESS.md updated as you go: one line per meaningful step, newest last. It is how the next agent (or a human) picks up if you stop early.',
47
+ '- Maintain .leg/SYNTHESIS-<session-id>.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
  '- Do not push, do not create remotes, do not open pull requests, do not change git config.',
48
- '- Do not touch anything under .leg/ except PROGRESS.md and DONE.',
49
+ '- Do not touch anything under .leg/ except PROGRESS.md, SYNTHESIS-*.md, and DONE.',
49
50
  '- No interactive prompt will be answered; if you need a permission you do not have, write what you need to .leg/PROGRESS.md and stop.',
50
51
  '',
51
52
  '## Finish',
package/src/fsx.mjs CHANGED
@@ -16,7 +16,7 @@ export function canonPath(p) {
16
16
  return process.platform === 'win32' ? out.toLowerCase() : out
17
17
  }
18
18
 
19
- // The real, long-form path (case preserved): what Baton stores and hands to
19
+ // The real, long-form path (case preserved): what Leg stores and hands to
20
20
  // git, so a short or symlinked input never leaks into card.json or worktrees.
21
21
  export function realPath(p) {
22
22
  let base = resolve(p)
@@ -39,7 +39,9 @@ const sleepSync = (ms) => { const t = Date.now() + ms; while (Date.now() < t) {
39
39
  // poller) holds it at a time. A lock older than staleMs (a crashed holder) is
40
40
  // stolen. If it cannot be acquired within the budget, fn runs anyway rather
41
41
  // than hang the caller (a Claude Code hook must never block the user's turn).
42
- export function withFileLock(lockPath, fn, { retries = 60, waitMs = 20, staleMs = 5000 } = {}) {
42
+ // `mustHold`: a caller for whom running unlocked is worse than not running
43
+ // (two harness applies would tear one ownership record) gets a throw instead.
44
+ export function withFileLock(lockPath, fn, { retries = 60, waitMs = 20, staleMs = 5000, mustHold = false } = {}) {
43
45
  let fd = null
44
46
  for (let i = 0; i < retries; i++) {
45
47
  try { fd = openSync(lockPath, 'wx'); break } catch (err) {
@@ -51,6 +53,7 @@ export function withFileLock(lockPath, fn, { retries = 60, waitMs = 20, staleMs
51
53
  sleepSync(waitMs)
52
54
  }
53
55
  }
56
+ if (fd === null && mustHold) throw new Error(`could not take ${lockPath} within ${Math.round(retries * waitMs / 1000)} s; another Leg process holds it`)
54
57
  try { return fn() } finally { if (fd !== null) { try { closeSync(fd) } catch {} try { unlinkSync(lockPath) } catch {} } }
55
58
  }
56
59
 
package/src/handoff.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // handoff — the context-handoff-bundle seam. Baton never re-implements the
1
+ // handoff — the context-handoff-bundle seam. Leg never re-implements the
2
2
  // bundle format: it writes a structured notes file, calls the CLI as an argv
3
3
  // subprocess (`save --repo-local` inside the worktree so the next agent finds
4
4
  // the bundle in its cwd), validates the bundle, and later `load`s the resume.
@@ -68,9 +68,9 @@ function bullets(items) {
68
68
  }
69
69
 
70
70
  // Notes in the CLI's own section vocabulary (Scope / Findings / Opportunities /
71
- // Open questions / Evidence anchors) carrying Baton's four parts: Task, Done so
71
+ // Open questions / Evidence anchors) carrying Leg's four parts: Task, Done so
72
72
  // far, Diff, Open findings. Anything not under a known heading is dropped by
73
- // the parser, so every Baton line lives under one of those five.
73
+ // the parser, so every Leg line lives under one of those five.
74
74
  export function buildNotes({ card, station, leg, entry, run, progress = '', lastMessage = null, diff = null, diffStat = '', changedFiles = [], extra = [] }) {
75
75
  const outcome = run?.outcome ?? 'handoff'
76
76
  const signal = run?.signal && run.signal !== 'none' ? ` (${run.signal})` : ''
@@ -150,9 +150,9 @@ export function writeHandoff({ card, station, leg, entry, run, worktree, runDir,
150
150
  const notesPath = join(legDir, `handoff-${station.name}-leg${leg}.md`)
151
151
  writeFileSync(notesPath, notes)
152
152
  if (card.repo) ensureExcluded(card.repo, '.context-handoffs/')
153
- const slug = `baton-${card.card_id}-${station.name}-leg${leg}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80)
154
- const title = `baton ${card.card_id} ${station.name} leg ${leg} ${entry?.adapter ?? run?.adapter ?? 'agent'}`
155
- const save = chb(['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'baton'], { cwd: worktree })
153
+ const slug = `leg-${card.card_id}-${station.name}-leg${leg}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80)
154
+ const title = `leg ${card.card_id} ${station.name} leg ${leg} ${entry?.adapter ?? run?.adapter ?? 'agent'}`
155
+ const save = chb(['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'leg'], { cwd: worktree })
156
156
  if (save.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${save.status}): ${scrub(save.stderr || save.stdout).slice(0, 500)}`)
157
157
  let out
158
158
  try { out = JSON.parse(save.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(save.stdout).slice(0, 300)}`) }