@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/land.mjs
CHANGED
|
@@ -213,18 +213,16 @@ export async function applyLandFix(sessionId, action, opts = {}) {
|
|
|
213
213
|
if (action === 'commit') {
|
|
214
214
|
git(wt, ['add', '-A'])
|
|
215
215
|
const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
|
|
216
|
-
const msg = opts.message || '
|
|
217
|
-
const r = git(wt, ['-c', 'user.email=
|
|
216
|
+
const msg = opts.message || 'leg: save work before landing'
|
|
217
|
+
const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
|
|
218
218
|
if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
|
|
219
|
-
clearCanLandCache()
|
|
220
219
|
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'committed uncommitted work in worktree' })
|
|
221
220
|
return { ok: true, action: 'commit' }
|
|
222
221
|
}
|
|
223
222
|
|
|
224
223
|
if (action === 'stash') {
|
|
225
|
-
const r = git(wt, ['stash', '-u', '-m', '
|
|
224
|
+
const r = git(wt, ['stash', '-u', '-m', 'leg: stash before landing'])
|
|
226
225
|
if (!r.ok) throw new Error(`git stash failed: ${r.err || r.out}`)
|
|
227
|
-
clearCanLandCache()
|
|
228
226
|
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'stashed uncommitted changes in worktree' })
|
|
229
227
|
return { ok: true, action: 'stash' }
|
|
230
228
|
}
|
|
@@ -232,7 +230,6 @@ export async function applyLandFix(sessionId, action, opts = {}) {
|
|
|
232
230
|
if (action === 'rebase_now') {
|
|
233
231
|
const base = s.worktree?.base || 'main'
|
|
234
232
|
const r = git(wt, ['rebase', base])
|
|
235
|
-
clearCanLandCache()
|
|
236
233
|
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `started rebase onto ${base} in worktree` })
|
|
237
234
|
return { ok: r.ok, action: 'rebase_now', detail: r.out || r.err }
|
|
238
235
|
}
|
|
@@ -244,10 +241,9 @@ export async function applyLandFix(sessionId, action, opts = {}) {
|
|
|
244
241
|
if (action === 'commit_directly') {
|
|
245
242
|
git(wt, ['add', '-A'])
|
|
246
243
|
const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
|
|
247
|
-
const msg = opts.message || `
|
|
248
|
-
const r = git(wt, ['-c', 'user.email=
|
|
244
|
+
const msg = opts.message || `leg: commit directly on ${s.branch || s.worktree?.base || 'main'}`
|
|
245
|
+
const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
|
|
249
246
|
if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
|
|
250
|
-
clearCanLandCache()
|
|
251
247
|
appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `committed directly on ${s.branch || 'main'}` })
|
|
252
248
|
return { ok: true, action: 'commit_directly' }
|
|
253
249
|
}
|
|
@@ -256,13 +252,6 @@ export async function applyLandFix(sessionId, action, opts = {}) {
|
|
|
256
252
|
}
|
|
257
253
|
|
|
258
254
|
// ---- canLand: The Guarded State Machine ----
|
|
259
|
-
const canLandCache = new Map()
|
|
260
|
-
|
|
261
|
-
export function clearCanLandCache(key) {
|
|
262
|
-
if (key) canLandCache.delete(key)
|
|
263
|
-
else canLandCache.clear()
|
|
264
|
-
}
|
|
265
|
-
|
|
266
255
|
export function canLand(worktreeId, opts = {}) {
|
|
267
256
|
const ctx = resolveWorktreeContext(worktreeId)
|
|
268
257
|
if (!ctx) {
|
|
@@ -301,16 +290,6 @@ export function canLand(worktreeId, opts = {}) {
|
|
|
301
290
|
}
|
|
302
291
|
}
|
|
303
292
|
|
|
304
|
-
const cacheKey = session?.session_id ? `sess:${session.session_id}` : `path:${path}`
|
|
305
|
-
if (!opts.fresh && !opts.testResult && !opts.ignoreRunning && !opts.ignoreInFlight && !opts.ignoreTargetActive) {
|
|
306
|
-
const hit = canLandCache.get(cacheKey)
|
|
307
|
-
if (hit && Date.now() - hit.ts < (opts.ttlMs ?? 10000)) {
|
|
308
|
-
if (!session || hit.sessionStatus === session.status) {
|
|
309
|
-
return hit.result
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
293
|
// Guard 3: Worktree is sitting on target branch itself (e.g. working directly on main)
|
|
315
294
|
if (branch === base) {
|
|
316
295
|
blockers.push({
|
|
@@ -427,14 +406,10 @@ export function canLand(worktreeId, opts = {}) {
|
|
|
427
406
|
})
|
|
428
407
|
}
|
|
429
408
|
|
|
430
|
-
|
|
409
|
+
return {
|
|
431
410
|
ok: blockers.length === 0,
|
|
432
411
|
blockers,
|
|
433
412
|
}
|
|
434
|
-
if (!opts.testResult && !opts.ignoreRunning && !opts.ignoreInFlight && !opts.ignoreTargetActive) {
|
|
435
|
-
canLandCache.set(cacheKey, { ts: Date.now(), result: res, sessionStatus: session?.status })
|
|
436
|
-
}
|
|
437
|
-
return res
|
|
438
413
|
}
|
|
439
414
|
|
|
440
415
|
// Why this session cannot land right now, or null.
|
|
@@ -527,7 +502,6 @@ export async function prepareLanding(worktreeId) {
|
|
|
527
502
|
try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
|
|
528
503
|
try { git(repo, ['worktree', 'prune']) } catch {}
|
|
529
504
|
releaseTargetLock(repo, base, lock.token)
|
|
530
|
-
clearCanLandCache()
|
|
531
505
|
}
|
|
532
506
|
}
|
|
533
507
|
|
|
@@ -548,7 +522,7 @@ export async function landSession(session, { by = 'local', autoCommit = true, ig
|
|
|
548
522
|
if (autoCommit && existsSync(path)) {
|
|
549
523
|
ensureExcludeEntries(repo)
|
|
550
524
|
const task = String(session.task ?? 'terminal session').split('\n')[0].slice(0, 60)
|
|
551
|
-
commitWorktree(path, `
|
|
525
|
+
commitWorktree(path, `leg: ${session.agent ?? 'agent'} ${id.split('-').pop()}: ${task}`)
|
|
552
526
|
}
|
|
553
527
|
|
|
554
528
|
const cl = canLand(session, { ignoreInFlight: true, ignoreRunning, ignoreTargetActive })
|
|
@@ -722,7 +696,6 @@ export async function landSession(session, { by = 'local', autoCommit = true, ig
|
|
|
722
696
|
try { git(repo, ['worktree', 'prune']) } catch {}
|
|
723
697
|
if (lock) releaseTargetLock(repo, base, lock.token)
|
|
724
698
|
inFlight.delete(id)
|
|
725
|
-
clearCanLandCache()
|
|
726
699
|
drainLandingQueue(repo, base).catch(() => {})
|
|
727
700
|
}
|
|
728
701
|
}
|
|
@@ -730,7 +703,6 @@ export async function landSession(session, { by = 'local', autoCommit = true, ig
|
|
|
730
703
|
// Removing a finished session takes its worktree and branch with it only when
|
|
731
704
|
// nothing is lost: a clean worktree whose branch is already in its base.
|
|
732
705
|
export function pruneSessionWorktree(s) {
|
|
733
|
-
clearCanLandCache()
|
|
734
706
|
const wt = s.worktree
|
|
735
707
|
if (!wt || !existsSync(wt.path)) return { removed: false, reason: 'no worktree on disk' }
|
|
736
708
|
const status = git(wt.path, ['status', '--porcelain'])
|
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
|
|
|
@@ -74,13 +86,13 @@ export function printPreflight({ rows }) {
|
|
|
74
86
|
|
|
75
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' } = {}) {
|
|
76
88
|
const procs = [{
|
|
77
|
-
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 },
|
|
78
90
|
note: 'board + API + scheduler + merge queue',
|
|
79
91
|
}]
|
|
80
92
|
const on = enabledSyncs()
|
|
81
93
|
const syncs = [
|
|
82
|
-
{ prefix: 'sync:workboard', enabled: on.includes('workboard'), note: 'OpenClaw Workboard mirror (
|
|
83
|
-
{ 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' },
|
|
84
96
|
]
|
|
85
97
|
return { procs, syncs }
|
|
86
98
|
}
|
|
@@ -160,21 +172,21 @@ function killActiveAgents() {
|
|
|
160
172
|
}
|
|
161
173
|
|
|
162
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' } = {}) {
|
|
163
|
-
out('
|
|
175
|
+
out('leg', `leg ${VERSION} — home ${home()}`)
|
|
164
176
|
const pf = await preflight()
|
|
165
177
|
printPreflight(pf)
|
|
166
|
-
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)
|
|
167
179
|
const plan = plannedProcesses({ port, bind })
|
|
168
|
-
for (const s of plan.syncs) out('
|
|
180
|
+
for (const s of plan.syncs) out('leg', `${s.prefix}: ${s.enabled ? 'on' : 'off'} (${s.note})`)
|
|
169
181
|
if (dry) {
|
|
170
|
-
out('
|
|
171
|
-
for (const p of plan.procs) out('
|
|
172
|
-
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()}`)
|
|
173
185
|
return 0
|
|
174
186
|
}
|
|
175
187
|
const existing = readPidfile()
|
|
176
188
|
if (existing && existing.pid !== process.pid && await boardAlive(existing)) {
|
|
177
|
-
out('
|
|
189
|
+
out('leg', `already running (pid ${existing.pid}, port ${existing.port}); use \`leg down\` first`, process.stderr)
|
|
178
190
|
return 1
|
|
179
191
|
}
|
|
180
192
|
if (existing) { try { rmSync(pidfile(), { force: true }) } catch {} }
|
|
@@ -197,34 +209,34 @@ export async function up({ dry = false, open = true, port = Number(process.env.L
|
|
|
197
209
|
await new Promise((r) => setTimeout(r, 250))
|
|
198
210
|
}
|
|
199
211
|
if (!ok) {
|
|
200
|
-
out('
|
|
212
|
+
out('leg', `server not healthy after ${Math.round((Date.now() - t0) / 1000)} s${exited !== null ? ` (exited ${exited})` : ''}; last lines:`, process.stderr)
|
|
201
213
|
for (const l of lastLines) out(p.prefix, l, process.stderr)
|
|
202
214
|
killTree(child.pid)
|
|
203
215
|
return 1
|
|
204
216
|
}
|
|
205
217
|
const url = `http://${bind === '0.0.0.0' ? '127.0.0.1' : bind}:${actualPort}`
|
|
206
218
|
writeFileSync(pidfile(), JSON.stringify({ pid: process.pid, port: actualPort, bind, children: [child.pid], started_at: new Date().toISOString() }, null, 2) + '\n')
|
|
207
|
-
out('
|
|
208
|
-
if (open) out('
|
|
209
|
-
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')
|
|
210
222
|
|
|
211
223
|
return await new Promise((resolvePromise) => {
|
|
212
224
|
let stopping = false
|
|
213
225
|
const stop = (why) => {
|
|
214
226
|
if (stopping) return
|
|
215
227
|
stopping = true
|
|
216
|
-
out('
|
|
228
|
+
out('leg', `stopping (${why})`)
|
|
217
229
|
const agents = killActiveAgents()
|
|
218
|
-
if (agents) out('
|
|
230
|
+
if (agents) out('leg', `killed ${agents} running agent/supervisor process(es)`)
|
|
219
231
|
killTree(child.pid)
|
|
220
232
|
try { rmSync(pidfile(), { force: true }) } catch {}
|
|
221
|
-
out('
|
|
233
|
+
out('leg', 'stopped')
|
|
222
234
|
resolvePromise(0)
|
|
223
235
|
}
|
|
224
236
|
process.on('SIGINT', () => stop('SIGINT'))
|
|
225
237
|
process.on('SIGTERM', () => stop('SIGTERM'))
|
|
226
238
|
process.on('SIGBREAK', () => stop('SIGBREAK'))
|
|
227
|
-
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) } })
|
|
228
240
|
})
|
|
229
241
|
}
|
|
230
242
|
|
|
@@ -241,10 +253,10 @@ export async function stopBoard() {
|
|
|
241
253
|
|
|
242
254
|
export async function down() {
|
|
243
255
|
const pf = readPidfile()
|
|
244
|
-
if (!pf) { out('
|
|
256
|
+
if (!pf) { out('leg', 'not running'); return 0 }
|
|
245
257
|
const agents = killActiveAgents()
|
|
246
258
|
const board = await stopBoard()
|
|
247
|
-
out('
|
|
259
|
+
out('leg', `stopped (pid ${pf.pid}, port ${pf.port}${agents ? `, ${agents} agent process(es) killed` : ''}${board.stale ? ', stale pidfile' : ''})`)
|
|
248
260
|
return 0
|
|
249
261
|
}
|
|
250
262
|
|
|
@@ -256,14 +268,14 @@ export async function status() {
|
|
|
256
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)'
|
|
257
269
|
if (running) {
|
|
258
270
|
const up = Math.round((Date.now() - Date.parse(pf.started_at)) / 1000)
|
|
259
|
-
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}`)
|
|
260
272
|
} else {
|
|
261
|
-
out('
|
|
273
|
+
out('leg', pf ? `stopped (pid ${pf.pid} is not answering on port ${pf.port}; cleared the stale pidfile)` : 'stopped')
|
|
262
274
|
}
|
|
263
275
|
const s = schedulerStatus()
|
|
264
|
-
out('
|
|
265
|
-
out('
|
|
266
|
-
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')}`)
|
|
267
279
|
return running ? 0 : 3
|
|
268
280
|
}
|
|
269
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
|
|
|
@@ -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'
|
|
@@ -27,6 +27,38 @@ export function requireHandoffOrder(value) {
|
|
|
27
27
|
return [...value]
|
|
28
28
|
}
|
|
29
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
|
+
|
|
30
62
|
export function preferencesFile() { return join(home(), 'preferences.json') }
|
|
31
63
|
|
|
32
64
|
export function resolveAutoApprove({ env = process.env, preferences = null, cliFlag = null } = {}) {
|
|
@@ -41,15 +73,16 @@ export function resolveAutoApprove({ env = process.env, preferences = null, cliF
|
|
|
41
73
|
|
|
42
74
|
export function readPreferences() {
|
|
43
75
|
const file = preferencesFile()
|
|
44
|
-
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
|
|
76
|
+
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
|
|
45
77
|
try {
|
|
46
78
|
const value = JSON.parse(readFileSync(file, 'utf8'))
|
|
47
79
|
return {
|
|
48
80
|
handoff_order: normalizeHandoffOrder(value?.handoff_order),
|
|
49
81
|
auto_approve: value?.auto_approve !== false,
|
|
82
|
+
harness: normalizeHarness(value?.harness),
|
|
50
83
|
}
|
|
51
84
|
} catch {
|
|
52
|
-
return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
|
|
85
|
+
return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
|
|
53
86
|
}
|
|
54
87
|
}
|
|
55
88
|
|
|
@@ -61,6 +94,7 @@ export function writePreferences(patch) {
|
|
|
61
94
|
const next = { ...current }
|
|
62
95
|
if (order !== undefined) next.handoff_order = order
|
|
63
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)
|
|
64
98
|
writeJsonAtomic(preferencesFile(), next)
|
|
65
99
|
return next
|
|
66
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
|