@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/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)
|
package/src/runner.mjs
CHANGED
|
@@ -129,7 +129,7 @@ function errTail(path, lines = 10) {
|
|
|
129
129
|
|
|
130
130
|
function killTree(pid, log) {
|
|
131
131
|
if ((process.env.LEG_SKIP_KILL || process.env.BATON_SKIP_KILL) === '1') { // test seam: unkillable agent
|
|
132
|
-
log('
|
|
132
|
+
log('LEG_SKIP_KILL=1: killTree skipped')
|
|
133
133
|
return
|
|
134
134
|
}
|
|
135
135
|
if (process.platform === 'win32') {
|
|
@@ -151,7 +151,7 @@ function gitHead(cwd) {
|
|
|
151
151
|
function gitDiff(cwd, headAtStart) {
|
|
152
152
|
const r = spawnSync('git', ['status', '--porcelain'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
153
153
|
if (r.status !== 0) return null
|
|
154
|
-
const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.baton[\\/]/.test(l)).length
|
|
154
|
+
const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.(baton|leg)[\\/]/.test(l)).length
|
|
155
155
|
const head = gitHead(cwd)
|
|
156
156
|
return { changed: files > 0 || (headAtStart !== null && head !== headAtStart), files, head_at_start: headAtStart, head }
|
|
157
157
|
}
|
package/src/scheduler.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set()
|
|
|
46
46
|
|
|
47
47
|
export function pidfile() { return join(home(), 'scheduler.pid') }
|
|
48
48
|
|
|
49
|
-
export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: '
|
|
49
|
+
export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'leg' } } = {}) {
|
|
50
50
|
const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
|
|
51
51
|
|
|
52
52
|
async function tick() {
|
package/src/server.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// server — the board's HTTP + SSE backend. Ledger-backed: every handler reads
|
|
3
3
|
// card.json / events-*.jsonl on demand (no module-level card store), so a
|
|
4
4
|
// restart shows the same board and a second process sees the same truth.
|
|
5
|
-
//
|
|
6
|
-
// multiplayer seams (src/auth.mjs).
|
|
5
|
+
// LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
|
|
6
|
+
// multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
|
|
7
7
|
import http from 'node:http'
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
9
|
import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
@@ -26,6 +26,7 @@ import { scrub } from './runner.mjs'
|
|
|
26
26
|
import { resolveChb } from './handoff.mjs'
|
|
27
27
|
import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
|
|
28
28
|
import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
|
|
29
|
+
import { hasRecentSynthesis } from './synthesis.mjs'
|
|
29
30
|
import { refreshPointers } from './resume.mjs'
|
|
30
31
|
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
31
32
|
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
@@ -34,7 +35,17 @@ import { readCodexUsage } from './taps/codex.mjs'
|
|
|
34
35
|
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
|
|
35
36
|
|
|
36
37
|
const SELF = fileURLToPath(import.meta.url)
|
|
37
|
-
|
|
38
|
+
export function resolveBoardDir() {
|
|
39
|
+
const dir = join(dirname(SELF), 'board')
|
|
40
|
+
if (existsSync(dir)) return dir
|
|
41
|
+
const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(dirname(SELF))
|
|
42
|
+
if (wtMatch) {
|
|
43
|
+
const root = dirname(SELF).slice(0, wtMatch.index)
|
|
44
|
+
const fallback = join(root, 'src', 'board')
|
|
45
|
+
if (existsSync(fallback)) return fallback
|
|
46
|
+
}
|
|
47
|
+
return dir
|
|
48
|
+
}
|
|
38
49
|
const VERSION = JSON.parse(readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8')).version
|
|
39
50
|
const DEFAULT_ORDER = ['plan', 'build', 'review', 'test', 'land']
|
|
40
51
|
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2' }
|
|
@@ -258,6 +269,8 @@ function redactSession(s) {
|
|
|
258
269
|
// the branch is already on the worktree chip: naming it again costs nothing
|
|
259
270
|
// and is what the board's land line reads
|
|
260
271
|
land: s.land ? { state: s.land.state, branch: s.land.branch ?? null, base: s.land.base ?? null, sha: s.land.sha ?? null, reason: s.land.reason ?? null } : null,
|
|
272
|
+
// the chip's word only: paths, dropped items and attention text stay on this machine
|
|
273
|
+
harness: s.harness ? { state: s.harness.state, target: s.harness.target ?? null } : null,
|
|
261
274
|
task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
|
|
262
275
|
land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
|
|
263
276
|
}
|
|
@@ -290,6 +303,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
290
303
|
handoff_availability_known: availabilityKnown,
|
|
291
304
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
292
305
|
active: isActive(s),
|
|
306
|
+
has_synthesis: hasRecentSynthesis(s),
|
|
293
307
|
overlap: ov.get(s.session_id) ?? [],
|
|
294
308
|
elapsed_ms: Date.now() - Date.parse(s.started_at),
|
|
295
309
|
// Older attached Codex processes can retain one parser mistake where an
|
|
@@ -352,10 +366,11 @@ function readBody(req) {
|
|
|
352
366
|
}
|
|
353
367
|
|
|
354
368
|
function serveStatic(res, urlPath) {
|
|
355
|
-
const map = { '/': 'index.html', '/floor': 'floor.html' }
|
|
369
|
+
const map = { '/': 'index.html', '/board': 'index.html', '/board/': 'index.html', '/floor': 'floor.html', '/floor/': 'floor.html' }
|
|
356
370
|
const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
|
|
357
|
-
const
|
|
358
|
-
|
|
371
|
+
const boardDir = resolveBoardDir()
|
|
372
|
+
const file = resolve(boardDir, rel)
|
|
373
|
+
if (!file.startsWith(boardDir + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
|
|
359
374
|
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
|
|
360
375
|
res.end(readFileSync(file))
|
|
361
376
|
}
|
|
@@ -460,7 +475,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
|
|
|
460
475
|
// ---- the server ----
|
|
461
476
|
export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
|
|
462
477
|
// An explicit `share` (tests) is fixed; the real server passes none and reads
|
|
463
|
-
// share.json from disk, re-reading it per request (mtime-cached) so `
|
|
478
|
+
// share.json from disk, re-reading it per request (mtime-cached) so `leg
|
|
464
479
|
// share add|rotate|rm` takes effect on a live board — a new link works at
|
|
465
480
|
// once and a removed or rotated one stops at once — without a restart.
|
|
466
481
|
const explicitShare = share !== undefined
|
|
@@ -599,7 +614,20 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
599
614
|
if (req.method === 'POST' || req.method === 'PATCH') {
|
|
600
615
|
const body = await readBody(req)
|
|
601
616
|
try {
|
|
602
|
-
const
|
|
617
|
+
const patch = {}
|
|
618
|
+
if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
|
|
619
|
+
if (body.harness !== undefined) {
|
|
620
|
+
// the board may narrow the policy or turn the feature off; turning
|
|
621
|
+
// it on is the first-run consent flow, which shows what will be
|
|
622
|
+
// written before it writes (leg harness enable)
|
|
623
|
+
if (body.harness?.enabled === true) return send(res, 400, { error: 'turn the portable harness on from a terminal: leg harness enable shows what it will write before it writes it' })
|
|
624
|
+
const rank = ['warn', 'sync', 'strict']
|
|
625
|
+
const current = readPreferences().harness
|
|
626
|
+
if (body.harness?.policy !== undefined && rank.indexOf(body.harness.policy) > rank.indexOf(current.policy)) return send(res, 400, { error: `the board may only narrow the harness policy (now ${current.policy}); widen it from a terminal: leg harness policy ${body.harness.policy}` })
|
|
627
|
+
patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
|
|
628
|
+
}
|
|
629
|
+
if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
|
|
630
|
+
const preferences = writePreferences(patch)
|
|
603
631
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
604
632
|
return send(res, 200, { preferences })
|
|
605
633
|
} catch (err) {
|
|
@@ -814,12 +842,12 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
814
842
|
const addr = server.address()
|
|
815
843
|
log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
|
|
816
844
|
// A terminal that crashed instead of exiting left its hand-off in
|
|
817
|
-
// .
|
|
845
|
+
// .leg/RESUME.md looking live. The board is the thing that starts
|
|
818
846
|
// after a crash, so it is where that gets corrected.
|
|
819
847
|
setImmediate(() => {
|
|
820
848
|
try {
|
|
821
849
|
const touched = refreshPointers()
|
|
822
|
-
if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no
|
|
850
|
+
if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Leg stamped it`)
|
|
823
851
|
} catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
|
|
824
852
|
})
|
|
825
853
|
if (scheduler) {
|
package/src/session-detail.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { scrub } from './redact.mjs'
|
|
|
11
11
|
import { transcriptTail as claudeTail } from './taps/claude.mjs'
|
|
12
12
|
import { transcriptTail as codexTail } from './taps/codex.mjs'
|
|
13
13
|
import { resumeVerdict, verdictForBoard } from './resume.mjs'
|
|
14
|
+
import { readHistory } from './harness/index.mjs'
|
|
14
15
|
|
|
15
16
|
export const MESSAGE_LIMIT = 8
|
|
16
17
|
export const DIFF_MAX_LINES = 400
|
|
@@ -41,7 +42,7 @@ export function sessionMessages(session, limit = MESSAGE_LIMIT) {
|
|
|
41
42
|
if (!path) return []
|
|
42
43
|
const tail = session.agent === 'claude' ? claudeTail(path, limit)
|
|
43
44
|
: session.agent === 'codex' ? codexTail(path, limit)
|
|
44
|
-
: [] // agy keeps no transcript
|
|
45
|
+
: [] // agy keeps no transcript Leg can read
|
|
45
46
|
return tail.map((m) => ({ role: m.role === 'user' ? 'user' : 'assistant', text: scrub(m.text), ts: m.ts ?? null }))
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -116,6 +117,18 @@ function resumeFor(session) {
|
|
|
116
117
|
try { return verdictForBoard(resumeVerdict(root)) } catch { return null }
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// The harness the leg now running was given: what the session recorded when
|
|
121
|
+
// the leg started, plus every capture, sync and decision the harness trail
|
|
122
|
+
// holds for this session. Paths in the attention list are local; a guest never
|
|
123
|
+
// reaches the drawer.
|
|
124
|
+
function harnessFor(session) {
|
|
125
|
+
const h = session.harness ?? null
|
|
126
|
+
let history = []
|
|
127
|
+
try { history = readHistory(200).filter((r) => r.session_id === session.session_id).slice(-20) } catch { /* the drawer renders without the trail */ }
|
|
128
|
+
if (!h && !history.length) return null
|
|
129
|
+
return { ...(h ?? {}), history }
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
export function sessionDetail(session) {
|
|
120
133
|
return {
|
|
121
134
|
session_id: session.session_id,
|
|
@@ -124,6 +137,7 @@ export function sessionDetail(session) {
|
|
|
124
137
|
events: readEvents(session.session_id).slice(-EVENT_LIMIT),
|
|
125
138
|
bundle: session.bundle ?? null,
|
|
126
139
|
resume: resumeFor(session),
|
|
140
|
+
harness: harnessFor(session),
|
|
127
141
|
ts: new Date().toISOString(),
|
|
128
142
|
}
|
|
129
143
|
}
|
package/src/sessions.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// sessions — the store behind `
|
|
1
|
+
// sessions — the store behind `leg claude|codex|agy`. One directory per
|
|
2
2
|
// interactive terminal session under $BATON_HOME/sessions/<id>/:
|
|
3
3
|
// session.json the live record the board renders (atomic writes)
|
|
4
4
|
// events.jsonl timeline (started, turn, warning, limit, handoff, ended)
|
|
@@ -44,7 +44,7 @@ export function listSessions() {
|
|
|
44
44
|
|
|
45
45
|
export function isActive(s) { return ACTIVE.includes(s?.status) }
|
|
46
46
|
|
|
47
|
-
// The checkout this session's files live in: its own worktree when
|
|
47
|
+
// The checkout this session's files live in: its own worktree when Leg gave
|
|
48
48
|
// it one (repo stays the main checkout, for grouping and landing), else the repo.
|
|
49
49
|
export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
|
|
50
50
|
|
|
@@ -58,10 +58,13 @@ export function createSession({ id, agent, account = 'default', cwd, repo = null
|
|
|
58
58
|
task: null, turns: 0,
|
|
59
59
|
files_touched: [], files_dirty: [], head: null, head_at_start: null,
|
|
60
60
|
limits: null, limit: null, warning: null,
|
|
61
|
-
bundle: null, handoff: null, chain,
|
|
61
|
+
bundle: null, handoff: null, chain, checkpoints: [],
|
|
62
62
|
handoff_order: normalizeHandoffOrder(handoffOrder), installed,
|
|
63
63
|
runtime_capabilities: [...new Set(runtimeCapabilities)],
|
|
64
64
|
lineage: { from: null, to: null },
|
|
65
|
+
// the portable-harness outcome for the leg now running (src/harness/index.mjs);
|
|
66
|
+
// null until the feature is enabled and a leg has been prepared
|
|
67
|
+
harness: null,
|
|
65
68
|
exit_code: null,
|
|
66
69
|
}
|
|
67
70
|
mkdirSync(sessionDir(id), { recursive: true })
|
package/src/share.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// share — optional multiplayer for the board, off until `
|
|
1
|
+
// share — optional multiplayer for the board, off until `leg share on`
|
|
2
2
|
// writes $BATON_HOME/share.json. With it on, the board binds the Tailscale or
|
|
3
3
|
// LAN address, every human has a name and their own token (kept as a sha256
|
|
4
|
-
// hash: a token is printed once and `
|
|
4
|
+
// hash: a token is printed once and `leg share rotate` issues a new one),
|
|
5
5
|
// and every session belongs to the human whose terminal started it
|
|
6
6
|
// (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
|
|
7
7
|
// open and `BATON_TOKEN` is the only token.
|
package/src/stations/agent.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Station kind: agent. One chain leg runs here (or, after `
|
|
1
|
+
// Station kind: agent. One chain leg runs here (or, after `leg down` or a
|
|
2
2
|
// crashed server, the orchestrator re-attaches to the run it left behind),
|
|
3
3
|
// the run is settled, and its verdict is applied to the card. `ops` are the
|
|
4
4
|
// orchestrator's helpers; this module never imports the orchestrator.
|
package/src/sync/dashclaw.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// dashclaw sync — records every ledger event as a DashClaw action over native
|
|
2
|
-
// http/https (LESSONS 07-12: no global fetch). Off unless
|
|
2
|
+
// http/https (LESSONS 07-12: no global fetch). Off unless LEG_SYNC_DASHCLAW=1
|
|
3
3
|
// and DASHCLAW_URL + DASHCLAW_API_KEY are set. Field names come from
|
|
4
4
|
// DashClaw's validator (app/lib/validate.js ACTION_RECORD_SCHEMA): agent_id,
|
|
5
5
|
// action_type and declared_goal are required; status is one of running,
|
|
@@ -26,11 +26,11 @@ const STATUS_BY_TYPE = {
|
|
|
26
26
|
|
|
27
27
|
// One ledger event → one action record (the request body).
|
|
28
28
|
export function actionForEvent(ev, card = null) {
|
|
29
|
-
const actor = ev.actor ?? { type: '
|
|
30
|
-
const agent = actor.type === 'agent' ? `
|
|
29
|
+
const actor = ev.actor ?? { type: 'leg' }
|
|
30
|
+
const agent = actor.type === 'agent' ? `leg/${actor.adapter}` : actor.type === 'human' ? `leg/human:${actor.id}` : 'leg'
|
|
31
31
|
const body = {
|
|
32
32
|
agent_id: agent,
|
|
33
|
-
action_type: `
|
|
33
|
+
action_type: `leg_${ev.type}`,
|
|
34
34
|
declared_goal: String(ev.summary ?? ev.type).slice(0, 2000),
|
|
35
35
|
status: STATUS_BY_TYPE[ev.type] ?? 'completed',
|
|
36
36
|
reversible: true,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// synthesis — the agent-maintained judgment record included in the handoff bundle.
|
|
2
|
+
// Spec: Leg Handoff Synthesis Layer — build spec v1
|
|
3
|
+
// File: .leg/SYNTHESIS-<session-id>.md
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const MAX_SYNTHESIS_BYTES = 4096
|
|
8
|
+
export const TRUNCATED_MARKER = '[synthesis truncated]'
|
|
9
|
+
export const INVALID_HEADER_PREFIX = '[synthesis header invalid, rendering body as-is]'
|
|
10
|
+
export const SYNTHESIS_POINTER_PARAGRAPH = "Read the Synthesis section first if present. Treat 'Ruled out' as settled: do not retry a ruled-out approach unless you have new evidence it was wrong. Start from the top-ranked Next step unless the repo state contradicts it."
|
|
11
|
+
|
|
12
|
+
export const SYNTHESIS_SECTIONS = [
|
|
13
|
+
'## Ruled out',
|
|
14
|
+
'## Decisions',
|
|
15
|
+
'## Next steps',
|
|
16
|
+
'## Open questions',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
// The synthesis file path for a session in its checkout/worktree root
|
|
20
|
+
export function synthesisFile(cwd, id) {
|
|
21
|
+
if (!cwd || !id) return null
|
|
22
|
+
const leg = join(cwd, '.leg', `SYNTHESIS-${id}.md`)
|
|
23
|
+
const baton = join(cwd, '.baton', `SYNTHESIS-${id}.md`)
|
|
24
|
+
if (existsSync(leg)) return leg
|
|
25
|
+
if (existsSync(baton)) return baton
|
|
26
|
+
if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
|
|
27
|
+
return leg
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Safely read the synthesis file; returns null if absent, empty, or unreadable
|
|
31
|
+
export function readSynthesis(cwd, id) {
|
|
32
|
+
if (!cwd || !id) return null
|
|
33
|
+
const file = synthesisFile(cwd, id)
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(file)) return null
|
|
36
|
+
const text = readFileSync(file, 'utf8')
|
|
37
|
+
if (!text || !text.trim()) return null
|
|
38
|
+
return text
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The standing directive for Leg per-session instructions
|
|
45
|
+
export function synthesisDirective(sessionId = '<session-id>') {
|
|
46
|
+
return `Maintain .leg/SYNTHESIS-${sessionId}.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
|
+
|
|
49
|
+
// Validates the 2-line header per schema v1:
|
|
50
|
+
// synthesis_version: 1
|
|
51
|
+
// session: <session-id> updated: <ISO-8601 UTC>
|
|
52
|
+
export function validateSynthesisHeader(text, { sessionId = null } = {}) {
|
|
53
|
+
if (typeof text !== 'string') return { valid: false, reason: 'content is not a string' }
|
|
54
|
+
const lines = text.split(/\r?\n/)
|
|
55
|
+
if (lines.length < 2) return { valid: false, reason: 'fewer than 2 lines' }
|
|
56
|
+
|
|
57
|
+
const l0 = lines[0].trim()
|
|
58
|
+
if (!/^synthesis_version:\s*1$/.test(l0)) {
|
|
59
|
+
return { valid: false, reason: 'missing or invalid synthesis_version: 1' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const l1 = lines[1].trim()
|
|
63
|
+
const m = /^session:\s*(\S+)\s+updated:\s*(\S+)$/.exec(l1)
|
|
64
|
+
if (!m) {
|
|
65
|
+
return { valid: false, reason: 'missing or invalid session/updated header line' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const [, sessId, updated] = m
|
|
69
|
+
if (sessionId && sessId !== sessionId) {
|
|
70
|
+
return { valid: false, reason: `session mismatch: expected ${sessionId}, got ${sessId}` }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parsed = Date.parse(updated)
|
|
74
|
+
if (Number.isNaN(parsed) || !/(?:Z|[+-]00:?00)$/i.test(updated)) {
|
|
75
|
+
return { valid: false, reason: 'updated timestamp is not valid ISO-8601 UTC' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { valid: true, version: 1, session: sessId, updated }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validates the full schema v1: header + optional sections in fixed order with max 5 bullets each
|
|
82
|
+
export function validateSynthesis(text, { sessionId = null } = {}) {
|
|
83
|
+
const header = validateSynthesisHeader(text, { sessionId })
|
|
84
|
+
const errors = []
|
|
85
|
+
if (!header.valid) {
|
|
86
|
+
errors.push(header.reason)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof text !== 'string') {
|
|
90
|
+
return { valid: false, headerValid: false, errors: ['content is not a string'] }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lines = text.split(/\r?\n/)
|
|
94
|
+
let lastSectionIdx = -1
|
|
95
|
+
let currentSection = null
|
|
96
|
+
let currentBullets = 0
|
|
97
|
+
|
|
98
|
+
for (let i = 2; i < lines.length; i++) {
|
|
99
|
+
const line = lines[i].trim()
|
|
100
|
+
if (line.startsWith('## ')) {
|
|
101
|
+
const idx = SYNTHESIS_SECTIONS.indexOf(line)
|
|
102
|
+
if (idx === -1) {
|
|
103
|
+
errors.push(`unknown section: ${line}`)
|
|
104
|
+
} else if (idx <= lastSectionIdx) {
|
|
105
|
+
errors.push(`section out of order: ${line}`)
|
|
106
|
+
} else {
|
|
107
|
+
lastSectionIdx = idx
|
|
108
|
+
}
|
|
109
|
+
currentSection = line
|
|
110
|
+
currentBullets = 0
|
|
111
|
+
} else if (line.startsWith('- ')) {
|
|
112
|
+
currentBullets++
|
|
113
|
+
if (currentBullets > 5) {
|
|
114
|
+
errors.push(`section ${currentSection || 'unknown'} exceeds 5 bullets`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
valid: errors.length === 0,
|
|
121
|
+
headerValid: header.valid,
|
|
122
|
+
errors,
|
|
123
|
+
session: header.session ?? null,
|
|
124
|
+
updated: header.updated ?? null,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Inlines synthesis into the ## Synthesis section with size cap (4 KB) and malformed header handling
|
|
129
|
+
export function formatSynthesisSection(rawText, { sessionId = null } = {}) {
|
|
130
|
+
if (!rawText || !rawText.trim()) return ''
|
|
131
|
+
|
|
132
|
+
let content = rawText
|
|
133
|
+
const buf = Buffer.from(content, 'utf8')
|
|
134
|
+
if (buf.length > MAX_SYNTHESIS_BYTES) {
|
|
135
|
+
const sliced = buf.subarray(0, MAX_SYNTHESIS_BYTES).toString('utf8')
|
|
136
|
+
const sep = sliced.endsWith('\n') ? '' : '\n'
|
|
137
|
+
content = `${sliced}${sep}${TRUNCATED_MARKER}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const header = validateSynthesisHeader(content, { sessionId })
|
|
141
|
+
if (!header.valid) {
|
|
142
|
+
content = `${INVALID_HEADER_PREFIX}\n${content}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `## Synthesis\n\n${content.trim()}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// True if .leg/SYNTHESIS-<session-id>.md exists, is non-empty, and was modified within the last 3 checkpoints
|
|
149
|
+
export function hasRecentSynthesis(session) {
|
|
150
|
+
if (!session) return false
|
|
151
|
+
const root = session.worktree?.path ?? session.repo ?? session.cwd ?? null
|
|
152
|
+
if (!root || !session.session_id) return false
|
|
153
|
+
const file = synthesisFile(root, session.session_id)
|
|
154
|
+
try {
|
|
155
|
+
if (!existsSync(file)) return false
|
|
156
|
+
const st = statSync(file)
|
|
157
|
+
if (!st.isFile() || st.size === 0) return false
|
|
158
|
+
const checkpoints = session.checkpoints ?? []
|
|
159
|
+
if (checkpoints.length < 3) return true
|
|
160
|
+
const threshold = new Date(checkpoints[checkpoints.length - 3]).getTime()
|
|
161
|
+
return st.mtimeMs >= threshold
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/taps/agy.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// agy tap — Antigravity CLI 1.2.0 is a closed Go binary with no hooks and no
|
|
2
|
-
// usage percentage on any surface
|
|
2
|
+
// usage percentage on any surface Leg can read (its own status line fetches
|
|
3
3
|
// a quota summary from the backend; the value is not written anywhere). What
|
|
4
4
|
// it does give:
|
|
5
|
-
// --log-file <path> one log per
|
|
5
|
+
// --log-file <path> one log per Leg session; the wall shows up as
|
|
6
6
|
// RESOURCE_EXHAUSTED / "it resets in <d>" / "out of quota"
|
|
7
7
|
// (strings present in agy.exe; docs-only until hit live)
|
|
8
8
|
// ~/.gemini/antigravity-cli/history.jsonl
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Why not the status line: Claude Code 2.1.268 renders its built-in status
|
|
4
4
|
// line and does not run a custom `statusLine` command passed via --settings
|
|
5
5
|
// or a project settings file (verified 2026-09-11 with an `echo` command at
|
|
6
|
-
// both levels; hooks from the same --settings file do run). So
|
|
6
|
+
// both levels; hooks from the same --settings file do run). So Leg asks the
|
|
7
7
|
// usage endpoint directly with the OAuth token Claude Code stored at login.
|
|
8
8
|
// The token is read by this process only, sent only to api.anthropic.com,
|
|
9
9
|
// and never written anywhere (the ledger scrubs bearer tokens regardless).
|