@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.
- package/CHANGELOG.md +45 -0
- package/NOTICE +8 -0
- package/README.md +601 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +21 -12
- 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/board-guide.md +13 -0
- package/docs/cli-contracts.md +22 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +22 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +11 -4
- 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 +1 -2
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +75 -19
- package/src/auth.mjs +2 -2
- package/src/board/board.js +3 -3
- package/src/board/sessions.js +77 -3
- 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 +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +1 -1
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +38 -10
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +6 -3
- 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 +2 -2
- 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/attach.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// attach — `
|
|
2
|
-
// this terminal, with
|
|
1
|
+
// attach — `leg claude|codex|agy [args…]`: the normal interactive agent in
|
|
2
|
+
// this terminal, with Leg alongside it. Leg (1) makes sure the board is
|
|
3
3
|
// up and opens it once, (2) registers the session so it shows on the board,
|
|
4
4
|
// (3) taps the agent for usage (claude: hooks + status line via --settings;
|
|
5
5
|
// codex: its rollout file; agy: its log), (4) polls git for the files the
|
|
@@ -28,16 +28,26 @@ import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as
|
|
|
28
28
|
import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
|
|
29
29
|
import { fetchGrokUsage, scanLog as scanGrokLog, promptsSince as grokPromptsSince } from './taps/grok.mjs'
|
|
30
30
|
import { fetchClaudeUsage } from './taps/claude-usage.mjs'
|
|
31
|
-
import { saveSessionBundle, resumePrompt } from './bundle.mjs'
|
|
31
|
+
import { saveSessionBundle, resumePrompt, sessionCommitDelta } from './bundle.mjs'
|
|
32
32
|
import { endSessionPointer } from './resume.mjs'
|
|
33
33
|
import { openBoard, pidfile } from './launcher.mjs'
|
|
34
34
|
import { LAYOUT } from './accounts.mjs'
|
|
35
35
|
import { captureLive } from './live-capture.mjs'
|
|
36
36
|
import { waitForReset, fmtCountdown } from './wait.mjs'
|
|
37
37
|
import { readPreferences, normalizeHandoffOrder, resolveAutoApprove } from './preferences.mjs'
|
|
38
|
+
import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
|
|
38
39
|
|
|
39
40
|
const SRC = dirname(fileURLToPath(import.meta.url))
|
|
40
|
-
|
|
41
|
+
function resolveServer() {
|
|
42
|
+
const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(SRC)
|
|
43
|
+
if (wtMatch) {
|
|
44
|
+
const root = SRC.slice(0, wtMatch.index)
|
|
45
|
+
const mainServer = join(root, 'src', 'server.mjs')
|
|
46
|
+
if (existsSync(mainServer)) return mainServer
|
|
47
|
+
}
|
|
48
|
+
return join(SRC, 'server.mjs')
|
|
49
|
+
}
|
|
50
|
+
const SERVER = resolveServer()
|
|
41
51
|
const POLL_MS = Number(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS || 2000)
|
|
42
52
|
const GIT_EVERY = 3 // polls
|
|
43
53
|
const USAGE_MS = Number(process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS || 60000)
|
|
@@ -81,15 +91,15 @@ export async function ensureBoard({ open = true } = {}) {
|
|
|
81
91
|
if (await health(port, host)) return { url, started: false }
|
|
82
92
|
mkdirSync(home(), { recursive: true })
|
|
83
93
|
const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
|
|
84
|
-
const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
|
|
94
|
+
const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, LEG_PORT: String(port), LEG_BIND: host, LEG_QUIET: '0', BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
|
|
85
95
|
child.unref()
|
|
86
96
|
const t0 = Date.now()
|
|
87
97
|
while (Date.now() - t0 < 15000) {
|
|
88
98
|
const h = await health(port, host)
|
|
89
99
|
if (h) {
|
|
90
100
|
// only claim the pidfile for a child we actually started: under a race,
|
|
91
|
-
// another `
|
|
92
|
-
// dead pid would make `
|
|
101
|
+
// another `leg` won the port and ours died on EADDRINUSE — writing our
|
|
102
|
+
// dead pid would make `leg down` kill nothing and report "not running"
|
|
93
103
|
const ours = h.pid ? h.pid === child.pid : (child.exitCode === null && Boolean(child.pid))
|
|
94
104
|
if (ours) writeFileSync(pidfile(), JSON.stringify({ pid: child.pid, port, bind: host, children: [child.pid], detached: true, started_by: 'attach', started_at: new Date().toISOString() }, null, 2) + '\n')
|
|
95
105
|
if (open) openBoard(url)
|
|
@@ -195,7 +205,7 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
|
|
|
195
205
|
const argv = []
|
|
196
206
|
// viaNode: either an npm entry (codex bin/codex.js) or a BATON_<AGENT>_BIN that names a .mjs (tests)
|
|
197
207
|
if (viaNode) argv.push(entry ?? bin)
|
|
198
|
-
// a leg
|
|
208
|
+
// a leg Leg starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
|
|
199
209
|
// e.g. BATON_CODEX_ARGS="-m gpt-5-mini" to keep a test chain on cheap models
|
|
200
210
|
if (prompt) args = [...(process.env[`LEG_${agent.toUpperCase()}_ARGS`] ?? process.env[`BATON_${agent.toUpperCase()}_ARGS`] ?? '').split(/\s+/).filter(Boolean), ...args]
|
|
201
211
|
if (agent === 'claude') {
|
|
@@ -484,14 +494,14 @@ function messagesFor(agent, s) {
|
|
|
484
494
|
// an order save is consumed here, or the editor sees handing_off and refuses.
|
|
485
495
|
// No eligible choice leaves the session unclaimed so all-out waiting can keep
|
|
486
496
|
// accepting order edits.
|
|
487
|
-
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000) }) {
|
|
497
|
+
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
|
|
488
498
|
let choice = { next: null, out: [] }
|
|
489
499
|
let claimed = false
|
|
490
500
|
const session = updateSession(sid, (current) => {
|
|
491
501
|
const accounts = readAccounts()
|
|
492
502
|
const order = normalizeHandoffOrder(current.handoff_order)
|
|
493
|
-
choice = chooseNext({ agent, account, accounts, installed, order, nowS })
|
|
494
|
-
if (!choice.next && isAvailable(readUsage(agent, account), nowS)) choice = { next: { agent, account }, out: [] }
|
|
503
|
+
choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude })
|
|
504
|
+
if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [] }
|
|
495
505
|
if (!choice.next) return {}
|
|
496
506
|
claimed = true
|
|
497
507
|
return {
|
|
@@ -510,14 +520,29 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
|
|
|
510
520
|
return { choice, claimed, session }
|
|
511
521
|
}
|
|
512
522
|
|
|
523
|
+
// The portable harness, decided before a leg starts (src/harness/index.mjs).
|
|
524
|
+
// Off by default: then this records nothing and changes nothing. On, it
|
|
525
|
+
// carries the source agent's working environment to the agent about to run,
|
|
526
|
+
// per the saved policy, and the session keeps the outcome so the board can say
|
|
527
|
+
// what transferred and what did not. Never throws into the session.
|
|
528
|
+
function prepareLegHarness({ sid, from, to }) {
|
|
529
|
+
let outcome
|
|
530
|
+
try { outcome = prepareHarnessForHandoff({ from, to, sessionId: sid }) } catch (err) { outcome = { state: 'error', policy: 'unknown', proceed: true, to, target: to, reason: String(err.message).slice(0, 200), summary: `harness error: ${String(err.message).slice(0, 120)}` } }
|
|
531
|
+
if (outcome.state === 'off') return outcome
|
|
532
|
+
updateSession(sid, { harness: outcome })
|
|
533
|
+
const line = harnessLine(outcome)
|
|
534
|
+
if (line) { say(line); appendEvent(sid, { type: outcome.proceed ? 'harness' : 'harness_blocked', summary: line, body: outcome.dropped?.length || outcome.attention?.length ? [...(outcome.attention ?? []).map((a) => `attention ${a.component}: ${a.reason}`), ...(outcome.dropped ?? []).map((d) => `dropped ${d.component}: ${d.item}: ${d.reason}`)].join('\n') : undefined }) }
|
|
535
|
+
return outcome
|
|
536
|
+
}
|
|
537
|
+
|
|
513
538
|
// ---- the command ----
|
|
514
539
|
export async function attach(agent, args = [], { open = true } = {}) {
|
|
515
540
|
if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
516
541
|
// the paid gate: a valid key, or no session (exit 4). The bare agent is never
|
|
517
|
-
// affected; only what
|
|
542
|
+
// affected; only what Leg adds is licensed.
|
|
518
543
|
const ent = entitlement()
|
|
519
544
|
if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
|
|
520
|
-
// --no-worktree is
|
|
545
|
+
// --no-worktree is Leg's flag, not the agent's: it never passes through
|
|
521
546
|
const shareCheckout = args.includes('--no-worktree')
|
|
522
547
|
args = args.filter((a) => a !== '--no-worktree')
|
|
523
548
|
let autoApproveCli = null
|
|
@@ -578,6 +603,9 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
578
603
|
let prompt = null
|
|
579
604
|
let legArgs = args
|
|
580
605
|
let exit = 0
|
|
606
|
+
// the first leg is the agent the human chose: its harness is prepared per
|
|
607
|
+
// policy and recorded, never refused (strict applies to hand-offs)
|
|
608
|
+
prepareLegHarness({ sid, from: null, to: agent })
|
|
581
609
|
// unbounded: the 12-leg cap below stops a runaway chain, and the all-out wait
|
|
582
610
|
// bounds a wait; a normal session runs one leg and exits
|
|
583
611
|
for (let leg = 0; ; leg++) {
|
|
@@ -593,10 +621,14 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
593
621
|
// saveSessionBundle writes the notes file before it shells out to chb, so
|
|
594
622
|
// even when chb is missing and the save throws, the context is on disk
|
|
595
623
|
const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
|
|
596
|
-
|
|
624
|
+
// destinations the strict harness policy refused during this hand-off
|
|
625
|
+
const excluded = []
|
|
626
|
+
let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
597
627
|
let choice = claim.choice
|
|
598
628
|
let cancelled = false
|
|
629
|
+
let blocked = false
|
|
599
630
|
while (!choice.next) {
|
|
631
|
+
if (excluded.length && !choice.out.length) { blocked = true; break }
|
|
600
632
|
// every option is out: keep the terminal, count down to the SOONEST reset
|
|
601
633
|
// (the current agent's own wall included — it may be the first back), then
|
|
602
634
|
// start that option from the bundle. Ctrl-C (or End) quits with exit 3.
|
|
@@ -612,7 +644,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
612
644
|
updateSession(sid, { status: 'waiting', all_out: all, waiting: first ? { agent: first.agent, account: first.account, resets_at: first.resets_at, since: new Date().toISOString() } : null }, { event: { type: 'all_out', summary: `every option is out; waiting for ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}` } })
|
|
613
645
|
const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
|
|
614
646
|
if (r2 === 'cancelled') { cancelled = true; break }
|
|
615
|
-
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
|
|
647
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
616
648
|
choice = claim.choice
|
|
617
649
|
}
|
|
618
650
|
if (cancelled) {
|
|
@@ -620,6 +652,24 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
620
652
|
exit = 3
|
|
621
653
|
break
|
|
622
654
|
}
|
|
655
|
+
// strict harness policy: a destination whose harness cannot be made safe is
|
|
656
|
+
// refused and the next option is tried; when none is left the session ends
|
|
657
|
+
// with exit 5 rather than launching an agent without its environment
|
|
658
|
+
while (!blocked && choice.next) {
|
|
659
|
+
const prepared = prepareLegHarness({ sid, from: agent, to: choice.next.agent })
|
|
660
|
+
if (prepared.proceed) break
|
|
661
|
+
excluded.push(choice.next)
|
|
662
|
+
say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
|
|
663
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
664
|
+
choice = claim.choice
|
|
665
|
+
if (!choice.next) blocked = true
|
|
666
|
+
}
|
|
667
|
+
if (blocked) {
|
|
668
|
+
say('every remaining option was refused by the strict harness policy; stopping (exit 5). Fix what needs attention (leg harness status) or relax the policy (leg harness policy sync), then run leg again in this directory.')
|
|
669
|
+
updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: 5, waiting: null }, { event: { type: 'ended', summary: 'strict harness policy refused every destination; stopped (exit 5)' } })
|
|
670
|
+
exit = 5
|
|
671
|
+
break
|
|
672
|
+
}
|
|
623
673
|
const next = choice.next
|
|
624
674
|
// bound the number of hand-offs in one terminal so a chain that limits
|
|
625
675
|
// instantly can never loop forever; stopping is explicit, not a silent exit 0
|
|
@@ -630,9 +680,15 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
630
680
|
break
|
|
631
681
|
}
|
|
632
682
|
appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
683
|
+
if (bundle) {
|
|
684
|
+
prompt = resumePrompt(cur, bundle, next)
|
|
685
|
+
} else {
|
|
686
|
+
const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
|
|
687
|
+
const fallbackAction = delta.isClean && delta.newCommits.length > 0
|
|
688
|
+
? `The previous agent committed changes (${delta.newCommits.length} commit(s)) 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.`
|
|
689
|
+
: 'Check git status and git diff, then continue the work.'
|
|
690
|
+
prompt = `You are taking over an interactive coding session from ${agent}.${existsSync(notesFile) ? ` Read ${notesFile} in this directory first (the previous agent's notes: task, last messages, dirty files).` : ''} ${fallbackAction} The task: ${cur.task ?? 'see the recent changes'}`
|
|
691
|
+
}
|
|
636
692
|
say(`starting ${next.agent}${next.account !== 'default' ? '/' + next.account : ''} in this terminal from the bundle`)
|
|
637
693
|
agent = next.agent; account = next.account; legArgs = []
|
|
638
694
|
// the chain is what comes after the agent now taking over, not after the
|
|
@@ -646,7 +702,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
646
702
|
if (fin && fin.status !== 'ended') updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: exit }, { event: { type: 'ended', summary: `session ended (exit ${exit})` } })
|
|
647
703
|
// Every way out of the loop arrives here: a clean exit, a cancelled wait, the
|
|
648
704
|
// 12-leg cap, an agent that never started. The terminal is gone, so RESUME.md
|
|
649
|
-
// must stop describing it as live —
|
|
705
|
+
// must stop describing it as live — Leg owns that file, and leaving the last
|
|
650
706
|
// hand-off sitting there is exactly the lie this rewrite exists to stop.
|
|
651
707
|
try { endSessionPointer(readSession(sid)) } catch (err) { appendEvent(sid, { type: 'error', summary: `resume pointer not rewritten: ${err.message.slice(0, 160)}` }) }
|
|
652
708
|
try { rmSync(join(sessionDir(sid), 'control.json'), { force: true }) } catch {}
|
package/src/auth.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// auth — who is allowed in. Loopback with no token is open; any other bind
|
|
2
2
|
// address refuses to start without a token, and with a token every /api
|
|
3
|
-
// request needs `Authorization: Bearer <token>` (timing-safe). With `
|
|
3
|
+
// request needs `Authorization: Bearer <token>` (timing-safe). With `leg
|
|
4
4
|
// share` on, each human has their own token and the board knows their name
|
|
5
5
|
// and role (src/share.mjs); a loopback request is still the owner, so the
|
|
6
6
|
// machine's own browser needs nothing.
|
|
@@ -75,7 +75,7 @@ export function authorize({ token, req, url, share = null, bind = null, loopback
|
|
|
75
75
|
return { ok: false, subject: null, person: null }
|
|
76
76
|
}
|
|
77
77
|
// checkBind's invariant, re-applied per request: share.json stops reading as
|
|
78
|
-
// on (`
|
|
78
|
+
// on (`leg share off`, a truncated file) while the shared address is still
|
|
79
79
|
// bound, and nobody but this machine may be the local owner in that window
|
|
80
80
|
if (!token && loopbackOwner === false) return { ok: false, subject: null, person: null }
|
|
81
81
|
if (!token && bind && !isLoopback(bind) && !isLoopback(remoteAddress(req))) return { ok: false, subject: null, person: null }
|
package/src/board/board.js
CHANGED
|
@@ -253,10 +253,10 @@
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
function formatActor(actor) {
|
|
256
|
-
if (!actor) return '
|
|
256
|
+
if (!actor) return 'leg'
|
|
257
257
|
if (actor.type === 'human') return `human:${actor.id || 'local'}`
|
|
258
258
|
if (actor.type === 'agent') return `agent:${actor.adapter || '?'}`
|
|
259
|
-
return '
|
|
259
|
+
return 'leg'
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
function baseAdapterName(name) { return String(name || '').replace(/^fake-/, '') }
|
|
@@ -1042,7 +1042,7 @@
|
|
|
1042
1042
|
for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
|
|
1043
1043
|
// Add fallback agent used to default to the first option in the list, which
|
|
1044
1044
|
// is normally the agent already chosen as First agent: the summary line then
|
|
1045
|
-
// read "
|
|
1045
|
+
// read "Leg tries claude, then agy, then claude", a fallback that cannot
|
|
1046
1046
|
// fire. rebuildDefaultFallbacks already applies this filter.
|
|
1047
1047
|
if (!preferred && !first) {
|
|
1048
1048
|
const used = chosenAdapters(ui)
|
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.
|
|
@@ -775,6 +775,11 @@
|
|
|
775
775
|
if (shared() && s.owner) register.appendChild(el('span', { class: 'chip' }, [isMine(s) ? `${s.owner}, you` : s.owner]))
|
|
776
776
|
if (s.lineage && s.lineage.from) register.appendChild(el('span', { class: 'chip' }, [`from ${s.lineage.from}`]))
|
|
777
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]))
|
|
778
783
|
body.appendChild(register)
|
|
779
784
|
|
|
780
785
|
if (s.hidden) body.appendChild(el('p', { class: 'term-prompt term-prompt--empty' }, ['prompt hidden']))
|
|
@@ -1147,6 +1152,71 @@
|
|
|
1147
1152
|
return el('p', { class: cls, title: 'freshness is recomputed from git on every poll; leg resume --check' }, [text])
|
|
1148
1153
|
}
|
|
1149
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
|
+
|
|
1150
1220
|
function section(title, note, body) {
|
|
1151
1221
|
const s = el('section', { class: 'detail-section' }, [
|
|
1152
1222
|
el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
|
|
@@ -1256,6 +1326,9 @@
|
|
|
1256
1326
|
if (s.bundle) next.appendChild(el('p', { class: 'blocker' }, [`bundle ${s.bundle.id}${s.bundle.at ? `, saved ${whenAgo(s.bundle.at)}` : ''}`]))
|
|
1257
1327
|
if (d && d.resume) next.appendChild(resumeLine(d.resume))
|
|
1258
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))
|
|
1259
1332
|
putScroll(box, inner)
|
|
1260
1333
|
putFocus(box, focus)
|
|
1261
1334
|
}
|
|
@@ -1502,7 +1575,8 @@
|
|
|
1502
1575
|
}
|
|
1503
1576
|
|
|
1504
1577
|
window.addEventListener('leg:sessions', (e) => render(e.detail));
|
|
1505
|
-
window.addEventListener('
|
|
1578
|
+
window.addEventListener('leg:sessions', (e) => render(e.detail));
|
|
1579
|
+
window.addEventListener('baton:sessions', (e) => render(e.detail)); // legacy alias
|
|
1506
1580
|
document.addEventListener('keydown', (e) => {
|
|
1507
1581
|
if (e.key !== 'Escape') return
|
|
1508
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)}`) }
|