@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/launcher.mjs
CHANGED
|
@@ -16,7 +16,19 @@ import { schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
|
|
|
16
16
|
import { enabledSyncs } from './sync/index.mjs'
|
|
17
17
|
|
|
18
18
|
const SRC = dirname(fileURLToPath(import.meta.url))
|
|
19
|
-
|
|
19
|
+
function resolveServer() {
|
|
20
|
+
if (process.env.LEG_SERVER_SCRIPT || process.env.BATON_SERVER_SCRIPT) {
|
|
21
|
+
return process.env.LEG_SERVER_SCRIPT || process.env.BATON_SERVER_SCRIPT
|
|
22
|
+
}
|
|
23
|
+
const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(SRC)
|
|
24
|
+
if (wtMatch) {
|
|
25
|
+
const root = SRC.slice(0, wtMatch.index)
|
|
26
|
+
const mainServer = join(root, 'src', 'server.mjs')
|
|
27
|
+
if (existsSync(mainServer)) return mainServer
|
|
28
|
+
}
|
|
29
|
+
return join(SRC, 'server.mjs')
|
|
30
|
+
}
|
|
31
|
+
const SERVER = resolveServer()
|
|
20
32
|
const VERSION = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8')).version
|
|
21
33
|
const HEALTH_TIMEOUT_MS = Number(process.env.LEG_HEALTH_TIMEOUT_MS || process.env.BATON_HEALTH_TIMEOUT_MS || 20000)
|
|
22
34
|
|
|
@@ -46,6 +58,7 @@ const INSTALL_HINT = {
|
|
|
46
58
|
claude: 'https://claude.com/claude-code (then `claude` to log in)',
|
|
47
59
|
codex: 'npm i -g @openai/codex (then `codex login`)',
|
|
48
60
|
agy: 'Antigravity CLI (`agy`), log in once interactively',
|
|
61
|
+
grok: 'xAI Grok CLI (`grok`), log in once via `grok login`',
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
// Rows: [name, status, detail]. Nothing here is fatal except "no adapter at all".
|
|
@@ -62,7 +75,7 @@ export async function preflight() {
|
|
|
62
75
|
const v = viaNode ? version(process.execPath, [entry, '--version']) : version(bin)
|
|
63
76
|
if (v) { present += 1; rows.push([name, 'ok', `${v} (${viaNode ? entry : bin})`]) } else rows.push([name, 'missing', INSTALL_HINT[name] ?? 'not on PATH'])
|
|
64
77
|
}
|
|
65
|
-
rows.push(['fake adapters', 'ok', 'fake, fake-claude, fake-codex, fake-agy (tests and demo)'])
|
|
78
|
+
rows.push(['fake adapters', 'ok', 'fake, fake-claude, fake-codex, fake-agy, fake-grok (tests and demo)'])
|
|
66
79
|
return { rows, adapters_present: present }
|
|
67
80
|
}
|
|
68
81
|
|
|
@@ -73,13 +86,13 @@ export function printPreflight({ rows }) {
|
|
|
73
86
|
|
|
74
87
|
export function plannedProcesses({ port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), bind = process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1' } = {}) {
|
|
75
88
|
const procs = [{
|
|
76
|
-
prefix: 'server', bin: process.execPath, argv: [SERVER], env: { BATON_PORT: String(port), BATON_BIND: bind },
|
|
89
|
+
prefix: 'server', bin: process.execPath, argv: [SERVER], env: { LEG_PORT: String(port), LEG_BIND: bind, BATON_PORT: String(port), BATON_BIND: bind },
|
|
77
90
|
note: 'board + API + scheduler + merge queue',
|
|
78
91
|
}]
|
|
79
92
|
const on = enabledSyncs()
|
|
80
93
|
const syncs = [
|
|
81
|
-
{ prefix: 'sync:workboard', enabled: on.includes('workboard'), note: 'OpenClaw Workboard mirror (
|
|
82
|
-
{ prefix: 'sync:dashclaw', enabled: on.includes('dashclaw'), note: 'DashClaw action recording (
|
|
94
|
+
{ prefix: 'sync:workboard', enabled: on.includes('workboard'), note: 'OpenClaw Workboard mirror (LEG_SYNC_WORKBOARD=1); runs inside the ledger, no extra process' },
|
|
95
|
+
{ prefix: 'sync:dashclaw', enabled: on.includes('dashclaw'), note: 'DashClaw action recording (LEG_SYNC_DASHCLAW=1 + DASHCLAW_URL + DASHCLAW_API_KEY); runs inside the ledger' },
|
|
83
96
|
]
|
|
84
97
|
return { procs, syncs }
|
|
85
98
|
}
|
|
@@ -159,21 +172,21 @@ function killActiveAgents() {
|
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
export async function up({ dry = false, open = true, port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), bind = process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1' } = {}) {
|
|
162
|
-
out('
|
|
175
|
+
out('leg', `leg ${VERSION} — home ${home()}`)
|
|
163
176
|
const pf = await preflight()
|
|
164
177
|
printPreflight(pf)
|
|
165
|
-
if (pf.adapters_present === 0) out('
|
|
178
|
+
if (pf.adapters_present === 0) out('leg', 'no real coding-agent CLI found; fake adapters still work for the demo', process.stderr)
|
|
166
179
|
const plan = plannedProcesses({ port, bind })
|
|
167
|
-
for (const s of plan.syncs) out('
|
|
180
|
+
for (const s of plan.syncs) out('leg', `${s.prefix}: ${s.enabled ? 'on' : 'off'} (${s.note})`)
|
|
168
181
|
if (dry) {
|
|
169
|
-
out('
|
|
170
|
-
for (const p of plan.procs) out('
|
|
171
|
-
out('
|
|
182
|
+
out('leg', 'dry run: nothing spawned. Would run:')
|
|
183
|
+
for (const p of plan.procs) out('leg', `${p.prefix}: ${JSON.stringify([p.bin, ...p.argv])} env ${JSON.stringify(p.env)}`)
|
|
184
|
+
out('leg', `then poll http://${bind}:${port}/api/health, ${open ? 'open the board' : 'not open the board'}, write ${pidfile()}`)
|
|
172
185
|
return 0
|
|
173
186
|
}
|
|
174
187
|
const existing = readPidfile()
|
|
175
188
|
if (existing && existing.pid !== process.pid && await boardAlive(existing)) {
|
|
176
|
-
out('
|
|
189
|
+
out('leg', `already running (pid ${existing.pid}, port ${existing.port}); use \`leg down\` first`, process.stderr)
|
|
177
190
|
return 1
|
|
178
191
|
}
|
|
179
192
|
if (existing) { try { rmSync(pidfile(), { force: true }) } catch {} }
|
|
@@ -196,34 +209,34 @@ export async function up({ dry = false, open = true, port = Number(process.env.L
|
|
|
196
209
|
await new Promise((r) => setTimeout(r, 250))
|
|
197
210
|
}
|
|
198
211
|
if (!ok) {
|
|
199
|
-
out('
|
|
212
|
+
out('leg', `server not healthy after ${Math.round((Date.now() - t0) / 1000)} s${exited !== null ? ` (exited ${exited})` : ''}; last lines:`, process.stderr)
|
|
200
213
|
for (const l of lastLines) out(p.prefix, l, process.stderr)
|
|
201
214
|
killTree(child.pid)
|
|
202
215
|
return 1
|
|
203
216
|
}
|
|
204
217
|
const url = `http://${bind === '0.0.0.0' ? '127.0.0.1' : bind}:${actualPort}`
|
|
205
218
|
writeFileSync(pidfile(), JSON.stringify({ pid: process.pid, port: actualPort, bind, children: [child.pid], started_at: new Date().toISOString() }, null, 2) + '\n')
|
|
206
|
-
out('
|
|
207
|
-
if (open) out('
|
|
208
|
-
out('
|
|
219
|
+
out('leg', `ready ${url} (scheduler max ${MAX_CONCURRENT}, ${ok.cards} card${ok.cards === 1 ? '' : 's'})`)
|
|
220
|
+
if (open) out('leg', openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
|
|
221
|
+
out('leg', 'Ctrl-C stops everything')
|
|
209
222
|
|
|
210
223
|
return await new Promise((resolvePromise) => {
|
|
211
224
|
let stopping = false
|
|
212
225
|
const stop = (why) => {
|
|
213
226
|
if (stopping) return
|
|
214
227
|
stopping = true
|
|
215
|
-
out('
|
|
228
|
+
out('leg', `stopping (${why})`)
|
|
216
229
|
const agents = killActiveAgents()
|
|
217
|
-
if (agents) out('
|
|
230
|
+
if (agents) out('leg', `killed ${agents} running agent/supervisor process(es)`)
|
|
218
231
|
killTree(child.pid)
|
|
219
232
|
try { rmSync(pidfile(), { force: true }) } catch {}
|
|
220
|
-
out('
|
|
233
|
+
out('leg', 'stopped')
|
|
221
234
|
resolvePromise(0)
|
|
222
235
|
}
|
|
223
236
|
process.on('SIGINT', () => stop('SIGINT'))
|
|
224
237
|
process.on('SIGTERM', () => stop('SIGTERM'))
|
|
225
238
|
process.on('SIGBREAK', () => stop('SIGBREAK'))
|
|
226
|
-
child.on('exit', (code) => { if (!stopping) { out('
|
|
239
|
+
child.on('exit', (code) => { if (!stopping) { out('leg', `server exited ${code}`, process.stderr); try { rmSync(pidfile(), { force: true }) } catch {} resolvePromise(code === 0 ? 0 : 1) } })
|
|
227
240
|
})
|
|
228
241
|
}
|
|
229
242
|
|
|
@@ -240,10 +253,10 @@ export async function stopBoard() {
|
|
|
240
253
|
|
|
241
254
|
export async function down() {
|
|
242
255
|
const pf = readPidfile()
|
|
243
|
-
if (!pf) { out('
|
|
256
|
+
if (!pf) { out('leg', 'not running'); return 0 }
|
|
244
257
|
const agents = killActiveAgents()
|
|
245
258
|
const board = await stopBoard()
|
|
246
|
-
out('
|
|
259
|
+
out('leg', `stopped (pid ${pf.pid}, port ${pf.port}${agents ? `, ${agents} agent process(es) killed` : ''}${board.stale ? ', stale pidfile' : ''})`)
|
|
247
260
|
return 0
|
|
248
261
|
}
|
|
249
262
|
|
|
@@ -255,14 +268,14 @@ export async function status() {
|
|
|
255
268
|
const by = (key) => Object.entries(cards.reduce((m, c) => { m[c[key]] = (m[c[key]] ?? 0) + 1; return m }, {})).map(([k, v]) => `${k}=${v}`).join(' ') || '(none)'
|
|
256
269
|
if (running) {
|
|
257
270
|
const up = Math.round((Date.now() - Date.parse(pf.started_at)) / 1000)
|
|
258
|
-
out('
|
|
271
|
+
out('leg', `running pid ${pf.pid} port ${pf.port} up ${Math.floor(up / 60)}m${up % 60}s http://127.0.0.1:${pf.port}`)
|
|
259
272
|
} else {
|
|
260
|
-
out('
|
|
273
|
+
out('leg', pf ? `stopped (pid ${pf.pid} is not answering on port ${pf.port}; cleared the stale pidfile)` : 'stopped')
|
|
261
274
|
}
|
|
262
275
|
const s = schedulerStatus()
|
|
263
|
-
out('
|
|
264
|
-
out('
|
|
265
|
-
out('
|
|
276
|
+
out('leg', `scheduler ${s.running ? `running (pid ${s.pid})` : 'stopped'} max concurrent ${MAX_CONCURRENT}`)
|
|
277
|
+
out('leg', `cards ${cards.length} by status: ${by('status')}`)
|
|
278
|
+
out('leg', `by station: ${by('station')}`)
|
|
266
279
|
return running ? 0 : 3
|
|
267
280
|
}
|
|
268
281
|
|
package/src/ledger.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
|
|
3
|
-
// ledger — the ONLY writer of
|
|
3
|
+
// ledger — the ONLY writer of Leg's card ledger files under $LEG_HOME/cards/<id>/.
|
|
4
4
|
// Subcommands: create | append | update | sync. Every event carries a validated
|
|
5
5
|
// actor, the card id, the station and the leg, and lands in that actor's own
|
|
6
6
|
// events-<actor-key>.jsonl. Importable: readEvents, parseActor, actorKey.
|
|
@@ -17,16 +17,16 @@ export const EVENT_TYPES = ['card_created', 'leg_started', 'leg_progress', 'leg_
|
|
|
17
17
|
'limit_detected', 'handoff_written', 'leg_resumed', 'station_done', 'bounced', 'landed',
|
|
18
18
|
'land_warning', 'land_retry', 'blocked_by', 'scheduler_started', 'scheduler_stopped',
|
|
19
19
|
'approval_needed', 'approved', 'reassigned', 'paused', 'resumed', 'killed', 'done',
|
|
20
|
-
'failed', 'error', 'status']
|
|
20
|
+
'failed', 'error', 'status', 'harness', 'harness_blocked']
|
|
21
21
|
export const STATUSES = ['backlog', 'queued', 'running', 'handing_off', 'waiting_human',
|
|
22
22
|
'needs_approval', 'paused', 'done', 'failed', 'killed']
|
|
23
23
|
const CLOSED = ['done', 'failed', 'killed']
|
|
24
24
|
// card.json keys `update --patch` may set (everything else goes through a named flag)
|
|
25
25
|
export const PATCHABLE = ['pipeline', 'leases', 'land_attempts', 'land_mode', 'test_command', 'title', 'trunk',
|
|
26
|
-
'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url']
|
|
26
|
+
'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url', 'harness']
|
|
27
27
|
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i
|
|
28
28
|
|
|
29
|
-
export const ROOT = process.env.BATON_HOME || join(homedir(), '.baton')
|
|
29
|
+
export const ROOT = process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg'))
|
|
30
30
|
|
|
31
31
|
function die(code, msg) {
|
|
32
32
|
process.stderr.write(msg + '\n')
|
|
@@ -53,7 +53,7 @@ function need(args, key, allowed) {
|
|
|
53
53
|
return v
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Actor: who wrote the event. {type:'agent', adapter, model?} | {type:'human', id} | {type:'baton'}.
|
|
56
|
+
// Actor: who wrote the event. {type:'agent', adapter, model?} | {type:'human', id} | {type:'leg'} | {type:'baton'} (legacy).
|
|
57
57
|
// Returns the normalized actor or null when the shape is wrong.
|
|
58
58
|
export function parseActor(raw) {
|
|
59
59
|
let a = raw
|
|
@@ -156,7 +156,7 @@ async function syncNotify(kind, ev, card) {
|
|
|
156
156
|
kind, ev, card, home: ROOT,
|
|
157
157
|
report: (summary) => {
|
|
158
158
|
if (!id) return
|
|
159
|
-
appendEvent(id, { ts: now(), card_id: id, actor: { type: '
|
|
159
|
+
appendEvent(id, { ts: now(), card_id: id, actor: { type: 'leg' }, station: card?.station ?? '-', leg: card?.leg ?? 0, type: 'status', summary })
|
|
160
160
|
},
|
|
161
161
|
})
|
|
162
162
|
} catch {}
|
package/src/license.mjs
CHANGED
|
@@ -1,34 +1,35 @@
|
|
|
1
1
|
// license — the paid-product gate. A license key is a signed, self-contained
|
|
2
|
-
// token that
|
|
2
|
+
// token that Leg checks offline with the public key embedded below; the
|
|
3
3
|
// private key never leaves the seller's machine. Two plans:
|
|
4
4
|
//
|
|
5
5
|
// personal one-time purchase; every release dated on or before the key's
|
|
6
6
|
// updates_until activates, later releases refuse the key but the
|
|
7
7
|
// installed one keeps working (the Sublime Text shape)
|
|
8
8
|
// team per-seat subscription; the key carries an expiry a few days past
|
|
9
|
-
// the billing period, `
|
|
9
|
+
// the billing period, `leg license refresh` fetches a renewed one
|
|
10
10
|
//
|
|
11
|
-
// There is no trial.
|
|
11
|
+
// There is no trial. Leg pays off in the moment a limit lands mid-flow, which
|
|
12
12
|
// is not a thing a fortnight of evaluation reliably contains; the risk reversal
|
|
13
13
|
// is a 30-day money-back guarantee instead, which costs no code and no expiry
|
|
14
|
-
// machinery. Key shape:
|
|
15
|
-
// the signature is Ed25519 over the payload
|
|
14
|
+
// machinery. Key shape: LEG-<base64url payload>.<base64url signature> (legacy
|
|
15
|
+
// BATON- prefix still accepted) where the signature is Ed25519 over the payload
|
|
16
|
+
// bytes exactly as encoded.
|
|
16
17
|
import { createPublicKey, createPrivateKey, verify as cryptoVerify, sign as cryptoSign, createHash } from 'node:crypto'
|
|
17
18
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, chmodSync } from 'node:fs'
|
|
18
19
|
import { join } from 'node:path'
|
|
19
20
|
import { home } from './store.mjs'
|
|
20
21
|
|
|
21
22
|
export const PUBLIC_KEY_B64 = 'MCowBQYDK2VwAyEAIpVQymHHJAkIrZHv0u4o0bgfFmtW3Crm7uMwYHP53X8='
|
|
22
|
-
// The suite has to exercise the gate itself — `
|
|
23
|
-
// Personal key, `
|
|
23
|
+
// The suite has to exercise the gate itself — `leg share on` refusing a
|
|
24
|
+
// Personal key, `leg <agent>` refusing nothing at all — and it cannot sign a
|
|
24
25
|
// key for the real public key, which is the point of the real public key. This
|
|
25
26
|
// env seam lets a spawned CLI verify against a throwaway pair. It weakens
|
|
26
|
-
// nothing:
|
|
27
|
+
// nothing: Leg ships as readable JavaScript, so anyone who would set this
|
|
27
28
|
// could edit the constant above instead.
|
|
28
29
|
const ACTIVE_PUBLIC_KEY = process.env.LEG_PUBLIC_KEY_B64 || process.env.BATON_PUBLIC_KEY_B64 || PUBLIC_KEY_B64
|
|
29
30
|
// The date this release was cut. A personal key activates when this is on or
|
|
30
31
|
// before its updates_until. Bumped with every published version.
|
|
31
|
-
export const RELEASE_DATE = '2026-09-
|
|
32
|
+
export const RELEASE_DATE = '2026-09-16'
|
|
32
33
|
export const GUARANTEE_DAYS = 30
|
|
33
34
|
export const SITE = process.env.LEG_SITE || process.env.BATON_SITE || 'https://legcli.com'
|
|
34
35
|
export const BUY_URL = `${SITE}/#pricing`
|
package/src/live-capture.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// keep the payload with secrets scrubbed, so the docs rows that say
|
|
4
4
|
// "docs-only" can become "observed-live" with evidence. One file per
|
|
5
5
|
// (agent, signal); a later arrival never overwrites the first. A payload a
|
|
6
|
-
// `
|
|
6
|
+
// `leg sessions simulate-limit` produced is marked and never captured.
|
|
7
7
|
// Where: BATON_LIVE_DIR, else this checkout's fixtures/live/ when it exists
|
|
8
8
|
// (a dev clone), else <BATON_HOME>/live/. In a dev clone the matching docs
|
|
9
9
|
// rows are flipped in the same call (scripts/live-limits.mjs).
|
package/src/mergequeue.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// mergequeue — the land station. One land at a time per repo root, FIFO: the
|
|
2
2
|
// queue key is the canonical root (two spellings of one path are one queue) and
|
|
3
|
-
// the turn itself is a file lock under
|
|
3
|
+
// the turn itself is a file lock under LEG_HOME, so a `leg card run` CLI
|
|
4
4
|
// and the board server cannot land into one checkout at the same time.
|
|
5
5
|
// land(card, worktree):
|
|
6
6
|
// 1. root must be on <trunk> and clean, else bounce `dirty-trunk` (root untouched)
|
|
@@ -57,7 +57,7 @@ export function rootState(repo, trunk) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Commit the agents' work so the branch can be rebased and merged. The commit
|
|
60
|
-
// skips git hooks (--no-verify): the repo's test command is
|
|
60
|
+
// skips git hooks (--no-verify): the repo's test command is Leg's gate, and
|
|
61
61
|
// interactive commit hooks (linters, wire-dark style checks) belong to humans
|
|
62
62
|
// typing commits. BATON_COMMIT_VERIFY=1 runs them anyway.
|
|
63
63
|
export function commitWorktree(worktree, message) {
|
|
@@ -65,7 +65,7 @@ export function commitWorktree(worktree, message) {
|
|
|
65
65
|
if (!dirty.length) return { committed: false }
|
|
66
66
|
git(worktree, ['add', '-A'])
|
|
67
67
|
const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
|
|
68
|
-
git(worktree, ['-c', 'user.email=
|
|
68
|
+
git(worktree, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', message])
|
|
69
69
|
return { committed: true, files: dirty.length }
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -82,7 +82,7 @@ export function resolveTestCommand(card, worktree) {
|
|
|
82
82
|
return { command: null, source: 'none' }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function runTests(command, worktree) {
|
|
85
|
+
export function runTests(command, worktree) {
|
|
86
86
|
return runCommandAsync(command, worktree, { timeoutMs: TEST_TIMEOUT_MS, tailLines: 40 })
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -147,7 +147,7 @@ async function landNow(card, worktree, { onWarning = () => {}, allowDirtyRoot =
|
|
|
147
147
|
if (checkedOut !== branch) return bounce('worktree-branch', `worktree is on ${checkedOut}, not ${branch}; switch it back before landing`)
|
|
148
148
|
|
|
149
149
|
const busy = operationInProgress(worktree)
|
|
150
|
-
if (busy) return bounce('worktree-busy', `a ${busy.replace(/-/g, ' ')} is already in progress in ${worktree}; finish or abort it there before landing (
|
|
150
|
+
if (busy) return bounce('worktree-busy', `a ${busy.replace(/-/g, ' ')} is already in progress in ${worktree}; finish or abort it there before landing (Leg will not touch a rebase it did not start)`)
|
|
151
151
|
|
|
152
152
|
const root = rootState(repo, trunk)
|
|
153
153
|
if (!root.onTrunk) return bounce('dirty-trunk', `repo root is on ${root.branch}, not ${trunk}; check out ${trunk} and retry`)
|
|
@@ -155,7 +155,7 @@ async function landNow(card, worktree, { onWarning = () => {}, allowDirtyRoot =
|
|
|
155
155
|
// nature; git's own fast-forward still refuses to overwrite a local change
|
|
156
156
|
if (root.dirty.length && !allowDirtyRoot) return bounce('dirty-trunk', `repo root has ${root.dirty.length} uncommitted change(s): ${root.dirty.slice(0, 10).join(', ')}`)
|
|
157
157
|
|
|
158
|
-
const committed = commitWorktree(worktree, `
|
|
158
|
+
const committed = commitWorktree(worktree, `leg: ${card.title ?? card.card_id}`)
|
|
159
159
|
const preSha = git(worktree, ['rev-parse', 'HEAD']).stdout.trim()
|
|
160
160
|
const trunkBefore = trunkHead(repo)
|
|
161
161
|
|
package/src/orchestrator.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
RUNNER, BATON_ACTOR, readCard, ledgerAppend, ledgerUpdate, cardDir, sleep,
|
|
19
19
|
} from './store.mjs'
|
|
20
20
|
import { scrub, updateRun } from './runner.mjs'
|
|
21
|
+
import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
|
|
21
22
|
import * as agentStation from './stations/agent.mjs'
|
|
22
23
|
import * as testStation from './stations/test.mjs'
|
|
23
24
|
import * as humanStation from './stations/human.mjs'
|
|
@@ -26,13 +27,16 @@ import * as humanStation from './stations/human.mjs'
|
|
|
26
27
|
// the merge queue). Each handler gets the orchestrator's helpers as `ops`.
|
|
27
28
|
const KIND_HANDLERS = { agent: agentStation, test: testStation, human: humanStation }
|
|
28
29
|
|
|
29
|
-
const POLL_MS = Number(process.env.BATON_POLL_MS || 2000)
|
|
30
|
+
const POLL_MS = Number(process.env.LEG_POLL_MS || process.env.BATON_POLL_MS || 2000)
|
|
30
31
|
const WAITING = ['done', 'failed', 'killed', 'paused', 'waiting_human', 'needs_approval']
|
|
31
32
|
// Patchable card keys the chain machine may change; everything else is a
|
|
32
33
|
// named ledger flag.
|
|
33
34
|
const PATCH_KEYS = ['pipeline', 'leases', 'land_attempts', 'bounce_reason', 'kill_requested', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'pr_url']
|
|
34
35
|
|
|
35
|
-
const log = (msg) => {
|
|
36
|
+
const log = (msg) => {
|
|
37
|
+
const quiet = (process.env.LEG_QUIET === '0' || process.env.BATON_QUIET === '0') ? false : (process.env.LEG_QUIET === '1' || process.env.BATON_QUIET === '1')
|
|
38
|
+
if (!quiet) process.stderr.write(`[leg] ${msg}\n`)
|
|
39
|
+
}
|
|
36
40
|
|
|
37
41
|
// Persist a chain transition: card fields via ledger update, events via append.
|
|
38
42
|
export function apply(id, before, result, actor = BATON_ACTOR) {
|
|
@@ -70,7 +74,7 @@ function latestRun(id) {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
// A run whose verdict no orchestrator has consumed: the latest run has no
|
|
73
|
-
// settled_at. Happens after `
|
|
77
|
+
// settled_at. Happens after `leg down` (agents killed, the supervisor wrote
|
|
74
78
|
// its verdict, the server that would apply it was already gone) or a crashed
|
|
75
79
|
// server. runCard re-attaches to it instead of launching a fresh leg.
|
|
76
80
|
export function unsettledRun(id) {
|
|
@@ -176,7 +180,7 @@ async function waitForRun(id, n, { pollMs = POLL_MS } = {}) {
|
|
|
176
180
|
function changedFiles(worktree) {
|
|
177
181
|
const r = spawnSync('git', ['status', '--porcelain'], { cwd: worktree, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
178
182
|
if (r.status !== 0) return []
|
|
179
|
-
return r.stdout.split(/\r?\n/).filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => f && !f.startsWith('.baton'))
|
|
183
|
+
return r.stdout.split(/\r?\n/).filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => f && !f.startsWith('.baton') && !f.startsWith('.leg'))
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
function diffStat(worktree) {
|
|
@@ -197,6 +201,26 @@ async function runLeg(card, station, worktree) {
|
|
|
197
201
|
const prompt = legPrompt({ contractText, resumeText })
|
|
198
202
|
const promptFile = join(cardDir(card.card_id), `prompt-${station.name}-leg${card.leg}.txt`)
|
|
199
203
|
writeFileSync(promptFile, prompt)
|
|
204
|
+
// The portable harness for the adapter about to run (src/harness/index.mjs):
|
|
205
|
+
// nothing when the feature is off; otherwise the card keeps the outcome and
|
|
206
|
+
// the ledger says what transferred. Under the strict policy a destination
|
|
207
|
+
// that cannot be made safe is not launched: the leg fails as launch_failed,
|
|
208
|
+
// which never advances the chain, and a human fixes it and reruns.
|
|
209
|
+
const previous = card.leg > 0 ? station.chain[card.leg - 1]?.adapter ?? null : null
|
|
210
|
+
let harness
|
|
211
|
+
try { harness = prepareHarnessForHandoff({ from: previous, to: entry.adapter, sessionId: card.card_id }) } catch (err) { harness = { state: 'error', proceed: true, to: entry.adapter, reason: String(err.message).slice(0, 200), summary: `harness error: ${String(err.message).slice(0, 120)}` } }
|
|
212
|
+
if (harness.state !== 'off') {
|
|
213
|
+
// the card keeps the verdict; the full record (components, dropped items,
|
|
214
|
+
// files, backups) is in $LEG_HOME/harness/history.jsonl, and the ledger's
|
|
215
|
+
// argv is not the place for it
|
|
216
|
+
const { state, target, source = null, policy, fingerprint = null, captured_at = null, synced_at = null, summary = null, proceed, reason = null } = harness
|
|
217
|
+
ledgerUpdate(card.card_id, { patch: { harness: { state, target, source, policy, fingerprint, captured_at, synced_at, summary, proceed, reason, dropped: harness.dropped?.length ?? 0, attention: harness.attention?.length ?? 0 } } })
|
|
218
|
+
const line = harnessLine(harness)
|
|
219
|
+
// the ledger refuses a line that looks like a secret by exiting; evidence
|
|
220
|
+
// about the harness must never be what fails a leg
|
|
221
|
+
if (line) { try { ledgerAppend(card.card_id, { type: harness.proceed ? 'harness' : 'harness_blocked', station: station.name, leg: card.leg, summary: line, body: [...(harness.attention ?? []).map((a) => `attention ${a.component}: ${a.reason}`), ...(harness.dropped ?? []).map((d) => `dropped ${d.component}: ${d.item}: ${d.reason}`)].join('\n') || undefined }) } catch (err) { log(`harness event not recorded: ${scrub(String(err.message)).slice(0, 160)}`) } }
|
|
222
|
+
if (!harness.proceed) return { status: 'failed', outcome: 'launch_failed', handoff: true, signal: 'none', reason: `strict harness policy refused ${entry.adapter}: ${harness.reason ?? harness.state}`, run: null, exit_code: null }
|
|
223
|
+
}
|
|
200
224
|
const args = ['launch', '--card', card.card_id, '--adapter', entry.adapter, '--prompt-file', promptFile, '--cwd', worktree, '--driver-pid', String(process.pid)]
|
|
201
225
|
if (entry.mode) args.push('--mode', entry.mode)
|
|
202
226
|
if (entry.maxTurns) args.push('--max-turns', String(entry.maxTurns))
|
package/src/preferences.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Machine-wide defaults for interactive
|
|
1
|
+
// Machine-wide defaults for interactive Leg terminals. A new terminal takes
|
|
2
2
|
// a copy of these preferences when it starts; later edits do not silently
|
|
3
3
|
// change terminals that are already running.
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
@@ -7,11 +7,15 @@ import { home } from './store.mjs'
|
|
|
7
7
|
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
8
8
|
|
|
9
9
|
export const HANDOFF_AGENTS = ['claude', 'codex', 'agy']
|
|
10
|
+
export const ALL_HANDOFF_AGENTS = ['claude', 'codex', 'agy', 'grok']
|
|
10
11
|
|
|
11
12
|
export function validHandoffOrder(value) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
if (!Array.isArray(value)) return false
|
|
14
|
+
const set = new Set(value)
|
|
15
|
+
if (set.size !== value.length) return false
|
|
16
|
+
if (value.length === 3 && ['claude', 'codex', 'agy'].every((a) => set.has(a))) return true
|
|
17
|
+
if (value.length === 4 && ['claude', 'codex', 'agy', 'grok'].every((a) => set.has(a))) return true
|
|
18
|
+
return false
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export function normalizeHandoffOrder(value) {
|
|
@@ -23,24 +27,74 @@ export function requireHandoffOrder(value) {
|
|
|
23
27
|
return [...value]
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
// Portable harness (src/harness/): off for every existing install. `enabled`
|
|
31
|
+
// is the explicit consent `leg harness enable` records; `policy` is what an
|
|
32
|
+
// unattended hand-off may do (warn: report only; sync: write managed state
|
|
33
|
+
// when it is safe; strict: refuse a destination that cannot be made safe);
|
|
34
|
+
// `source` is the client whose harness is the one being carried.
|
|
35
|
+
export const HARNESS_POLICIES = ['warn', 'sync', 'strict']
|
|
36
|
+
export const HARNESS_SOURCES = ['claude', 'codex']
|
|
37
|
+
export const HARNESS_DEFAULTS = Object.freeze({ enabled: false, policy: 'warn', source: null })
|
|
38
|
+
|
|
39
|
+
export function normalizeHarness(value) {
|
|
40
|
+
const v = value && typeof value === 'object' ? value : {}
|
|
41
|
+
return {
|
|
42
|
+
enabled: v.enabled === true,
|
|
43
|
+
policy: HARNESS_POLICIES.includes(v.policy) ? v.policy : HARNESS_DEFAULTS.policy,
|
|
44
|
+
source: HARNESS_SOURCES.includes(v.source) ? v.source : null,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requireHarness(patch, current = HARNESS_DEFAULTS) {
|
|
49
|
+
const next = { ...normalizeHarness(current) }
|
|
50
|
+
if (patch?.enabled !== undefined) next.enabled = Boolean(patch.enabled)
|
|
51
|
+
if (patch?.policy !== undefined) {
|
|
52
|
+
if (!HARNESS_POLICIES.includes(patch.policy)) throw new TypeError(`harness policy must be one of ${HARNESS_POLICIES.join(', ')}`)
|
|
53
|
+
next.policy = patch.policy
|
|
54
|
+
}
|
|
55
|
+
if (patch?.source !== undefined) {
|
|
56
|
+
if (patch.source !== null && !HARNESS_SOURCES.includes(patch.source)) throw new TypeError(`harness source must be one of ${HARNESS_SOURCES.join(', ')}`)
|
|
57
|
+
next.source = patch.source
|
|
58
|
+
}
|
|
59
|
+
return next
|
|
60
|
+
}
|
|
61
|
+
|
|
26
62
|
export function preferencesFile() { return join(home(), 'preferences.json') }
|
|
27
63
|
|
|
64
|
+
export function resolveAutoApprove({ env = process.env, preferences = null, cliFlag = null } = {}) {
|
|
65
|
+
if (cliFlag !== null && cliFlag !== undefined) return Boolean(cliFlag)
|
|
66
|
+
const envVal = env.LEG_AUTO_APPROVE ?? env.BATON_AUTO_APPROVE
|
|
67
|
+
if (envVal !== undefined) return envVal !== '0' && envVal !== 'false' && envVal !== 'off'
|
|
68
|
+
if ((env.LEG_NO_AUTO_APPROVE ?? env.BATON_NO_AUTO_APPROVE) === '1') return false
|
|
69
|
+
const prefs = preferences ?? readPreferences()
|
|
70
|
+
if (typeof prefs?.auto_approve === 'boolean') return prefs.auto_approve
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
28
74
|
export function readPreferences() {
|
|
29
75
|
const file = preferencesFile()
|
|
30
|
-
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS] }
|
|
76
|
+
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
|
|
31
77
|
try {
|
|
32
78
|
const value = JSON.parse(readFileSync(file, 'utf8'))
|
|
33
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
handoff_order: normalizeHandoffOrder(value?.handoff_order),
|
|
81
|
+
auto_approve: value?.auto_approve !== false,
|
|
82
|
+
harness: normalizeHarness(value?.harness),
|
|
83
|
+
}
|
|
34
84
|
} catch {
|
|
35
|
-
return { handoff_order: [...HANDOFF_AGENTS] }
|
|
85
|
+
return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
|
|
36
86
|
}
|
|
37
87
|
}
|
|
38
88
|
|
|
39
89
|
export function writePreferences(patch) {
|
|
40
|
-
const order = requireHandoffOrder(patch?.handoff_order)
|
|
90
|
+
const order = patch?.handoff_order !== undefined ? requireHandoffOrder(patch?.handoff_order) : undefined
|
|
41
91
|
mkdirSync(home(), { recursive: true })
|
|
42
92
|
return withFileLock(preferencesFile() + '.lock', () => {
|
|
43
|
-
const
|
|
93
|
+
const current = readPreferences()
|
|
94
|
+
const next = { ...current }
|
|
95
|
+
if (order !== undefined) next.handoff_order = order
|
|
96
|
+
if (patch?.auto_approve !== undefined) next.auto_approve = Boolean(patch.auto_approve)
|
|
97
|
+
if (patch?.harness !== undefined) next.harness = requireHarness(patch.harness, current.harness)
|
|
44
98
|
writeJsonAtomic(preferencesFile(), next)
|
|
45
99
|
return next
|
|
46
100
|
})
|
package/src/redact.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// redact — the one list of secret shapes. `scrub()` rewrites (logs, bundles,
|
|
2
2
|
// launcher output); the ledger's assertNoSecrets refuses (src/ledger.mjs).
|
|
3
|
-
// The source tooling kept two copies on purpose;
|
|
3
|
+
// The source tooling kept two copies on purpose; Leg keeps one here.
|
|
4
4
|
// Values the launcher's own process holds for the well-known key variables
|
|
5
5
|
// are read once at startup and never printed.
|
|
6
6
|
// Every prefix shape starts at a token boundary: the `sk-` inside
|
package/src/resume.mjs
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// resume — the pointer that cannot describe a picture that is no longer true.
|
|
2
2
|
//
|
|
3
|
-
// `.
|
|
3
|
+
// `.leg/RESUME.md` is the file humans and other agents open by habit (legacy
|
|
4
|
+
// `.baton/RESUME.md` is still read). It used
|
|
4
5
|
// to be an unowned convenience copy: written once per hand-off, never touched
|
|
5
6
|
// again, with no stamp and no expiry, so a normally exited terminal left hours
|
|
6
7
|
// old text sitting there looking live.
|
|
7
8
|
//
|
|
8
9
|
// Two rules fix that, and they are the whole module:
|
|
9
|
-
// 1. Every resume file
|
|
10
|
+
// 1. Every resume file Leg writes carries a stamp of the git state and the
|
|
10
11
|
// live terminals it was written against (an HTML comment, invisible in
|
|
11
12
|
// rendered markdown).
|
|
12
13
|
// 2. Freshness is never remembered — it is recomputed from git at READ time.
|
|
13
14
|
// A file cannot lie about HEAD to a reader who re-asks git.
|
|
14
|
-
//
|
|
15
|
+
// Leg owns the file: it rewrites it when a session ends and when the board
|
|
15
16
|
// starts, so nothing is left describing a terminal that is gone.
|
|
16
17
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
18
|
import { spawnSync } from 'node:child_process'
|
|
@@ -24,8 +25,8 @@ export const STAMP_PREFIX = '<!-- leg-resume '
|
|
|
24
25
|
export const LEGACY_STAMP_PREFIX = '<!-- baton-resume '
|
|
25
26
|
const STAMP_SUFFIX = ' -->'
|
|
26
27
|
const STAMP_VERSION = 1
|
|
27
|
-
//
|
|
28
|
-
//
|
|
28
|
+
// Leg's own directories dirty the tree on every write; a reader must not see
|
|
29
|
+
// Leg's bookkeeping as the human's work moving on.
|
|
29
30
|
const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
|
|
30
31
|
const MAX_REASONS = 5
|
|
31
32
|
|
|
@@ -81,6 +82,7 @@ export function perSessionFile(cwd, id) {
|
|
|
81
82
|
if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
|
|
82
83
|
return leg
|
|
83
84
|
}
|
|
85
|
+
export { synthesisFile } from './synthesis.mjs'
|
|
84
86
|
|
|
85
87
|
// An agent started deeper in the tree still finds its checkout's pointer.
|
|
86
88
|
export function findResume(startDir) {
|
|
@@ -117,7 +119,7 @@ export function readStamp(text) {
|
|
|
117
119
|
} catch { return null }
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
// The text without its stamp: what a human reads and what `
|
|
122
|
+
// The text without its stamp: what a human reads and what `leg resume` prints.
|
|
121
123
|
export function bodyOf(text) {
|
|
122
124
|
const s = String(text ?? '')
|
|
123
125
|
return readStamp(s) ? s.slice(s.indexOf('\n') + 1).replace(/^\n+/, '') : s
|
|
@@ -200,10 +202,10 @@ export function lastHandoffIn(root, sessions = listSessions()) {
|
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
function idleBody(last, live) {
|
|
203
|
-
const lines = ['#
|
|
205
|
+
const lines = ['# Leg: nothing in flight', '']
|
|
204
206
|
lines.push(live.length
|
|
205
207
|
? `No hand-off is waiting to be picked up here. Still live in this checkout: ${live.map((s) => `${s.session_id} (${s.agent})`).join(', ')}.`
|
|
206
|
-
: 'No
|
|
208
|
+
: 'No Leg terminal is live in this checkout.')
|
|
207
209
|
lines.push('')
|
|
208
210
|
if (last) {
|
|
209
211
|
const at = last.at ? String(last.at).slice(0, 16).replace('T', ' ') : 'an unrecorded time'
|
|
@@ -221,7 +223,7 @@ function idleBody(last, live) {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// The "nothing in flight" pointer. Always writes; the callers that must not
|
|
224
|
-
// create a file in a checkout
|
|
226
|
+
// create a file in a checkout Leg never handed off in check first.
|
|
225
227
|
export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
|
|
226
228
|
if (!root) return null
|
|
227
229
|
const dir = existsSync(join(root, '.baton')) && !existsSync(join(root, '.leg')) ? join(root, '.baton') : join(root, '.leg')
|
|
@@ -235,7 +237,7 @@ export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
|
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
// A session ending must not leave its hand-off sitting there looking live.
|
|
238
|
-
// Only ever rewrites a pointer that already exists:
|
|
240
|
+
// Only ever rewrites a pointer that already exists: Leg owns RESUME.md where
|
|
239
241
|
// it wrote one, and creates none in a checkout it never handed off in.
|
|
240
242
|
export function endSessionPointer(session) {
|
|
241
243
|
const root = workRoot(session)
|
|
@@ -243,7 +245,7 @@ export function endSessionPointer(session) {
|
|
|
243
245
|
return writeIdlePointer(root)
|
|
244
246
|
}
|
|
245
247
|
|
|
246
|
-
// Board start: every checkout
|
|
248
|
+
// Board start: every checkout Leg wrote a pointer in gets it recomputed, so a
|
|
247
249
|
// terminal that crashed instead of exiting cannot leave a live-looking hand-off
|
|
248
250
|
// behind. A checkout whose terminal really is live keeps its hand-off text.
|
|
249
251
|
// Returns the roots rewritten.
|
|
@@ -260,9 +262,9 @@ export function refreshPointers() {
|
|
|
260
262
|
const v = resumeVerdict(root, { sessions })
|
|
261
263
|
if (v.state === 'fresh') continue
|
|
262
264
|
// The terminal that wrote a hand-off owns it while it is still running: its
|
|
263
|
-
// text is the live description, and "the repo moved on" is for `
|
|
265
|
+
// text is the live description, and "the repo moved on" is for `leg resume
|
|
264
266
|
// --check` to report, not for the board to overwrite. Anything else — a
|
|
265
|
-
// hand-off from a terminal that is gone, a file no
|
|
267
|
+
// hand-off from a terminal that is gone, a file no Leg stamped — is
|
|
266
268
|
// replaced even when some OTHER terminal happens to be live in the
|
|
267
269
|
// checkout, which is the case that left three day old text sitting there.
|
|
268
270
|
if (v.session?.active) continue
|
|
@@ -284,14 +286,14 @@ function describe(list) { return list.map((s) => `${s.id ?? s.session_id} (${s.a
|
|
|
284
286
|
export function resumeVerdict(cwd, { sessions = listSessions() } = {}) {
|
|
285
287
|
const found = findResume(cwd)
|
|
286
288
|
if (!found) {
|
|
287
|
-
return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .
|
|
289
|
+
return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .leg/RESUME.md in this checkout'], summary: 'no resume pointer in this checkout', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
|
|
288
290
|
}
|
|
289
291
|
const { root, file } = found
|
|
290
292
|
let text = ''
|
|
291
293
|
try { text = readFileSync(file, 'utf8') } catch { /* raced a rewrite */ }
|
|
292
294
|
const stamp = readStamp(text)
|
|
293
295
|
if (!stamp) {
|
|
294
|
-
return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no
|
|
296
|
+
return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no Leg stamp, so its freshness cannot be checked against git'], summary: 'cannot be checked: no Leg stamp', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
|
|
295
297
|
}
|
|
296
298
|
|
|
297
299
|
const now = gitState(root)
|