@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.
- package/CHANGELOG.md +57 -0
- package/NOTICE +8 -0
- package/README.md +601 -558
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +64 -34
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +71 -0
- package/docs/README.md +2 -0
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +21 -0
- package/docs/adapters.md +17 -3
- package/docs/board-guide.md +13 -0
- package/docs/cli-contracts.md +57 -3
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +42 -2
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/fixtures/limits/grok/grok-rate-limit.json +11 -0
- package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
- package/fixtures/verified.json +1 -1
- package/package.json +8 -4
- package/scripts/build-docs-site.mjs +15 -7
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +10 -2
- package/src/adapters/codex.mjs +1 -1
- package/src/adapters/grok.mjs +4 -7
- package/src/attach.mjs +162 -37
- package/src/auth.mjs +2 -2
- package/src/board/board.css +45 -17
- package/src/board/board.js +4 -4
- package/src/board/floor.js +2 -2
- package/src/board/sessions.js +181 -38
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +660 -47
- package/src/launcher.mjs +40 -27
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +6 -6
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +63 -9
- package/src/redact.mjs +1 -1
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +3 -3
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +69 -20
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +9 -5
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +170 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +251 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +5 -4
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
package/src/board/sessions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Terminals lane: the window rail (5h/7d per login), the sessions started with
|
|
2
|
-
// `
|
|
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 `
|
|
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
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
'
|
|
866
|
-
'
|
|
867
|
-
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
905
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
|
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
|
-
|
|
1127
|
-
box.appendChild(
|
|
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('
|
|
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
|
|
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([
|
|
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
|
|
71
|
-
|
|
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
|
|
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 `
|
|
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
|
|
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})
|
|
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', '
|
|
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
|
-
// .
|
|
3
|
-
// .
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 = `
|
|
154
|
-
const title = `
|
|
155
|
-
const save = chb(['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', '
|
|
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)}`) }
|