@ucsandman/legcli 0.8.0 → 0.10.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 +121 -0
- package/NOTICE +8 -0
- package/README.md +639 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +43 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +205 -0
- package/docs/README.md +5 -1
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +22 -0
- package/docs/board-guide.md +33 -1
- package/docs/cli-contracts.md +36 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +23 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +18 -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 +6 -4
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +125 -23
- package/src/auth.mjs +2 -2
- package/src/board/board.css +23 -1
- package/src/board/board.js +17 -5
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +95 -7
- 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/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -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 +24 -6
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +224 -18
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +15 -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 +177 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/taps/mod.mjs +340 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +6 -5
- 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/bundle.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { chb, ensureExcluded } from './handoff.mjs'
|
|
|
9
9
|
import { scrub } from './redact.mjs'
|
|
10
10
|
import { updateSession, workRoot } from './sessions.mjs'
|
|
11
11
|
import { perSessionFile, writeHandoffPointer } from './resume.mjs'
|
|
12
|
+
import { readSynthesis, formatSynthesisSection, synthesisDirective, SYNTHESIS_POINTER_PARAGRAPH } from './synthesis.mjs'
|
|
12
13
|
|
|
13
14
|
const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
|
|
14
15
|
const bullets = (items) => items.filter(Boolean).map((x) => `- ${String(x).replace(/\r?\n/g, ' ').trim()}`)
|
|
@@ -21,12 +22,31 @@ function git(cwd, args) {
|
|
|
21
22
|
|
|
22
23
|
export function slugFor(session) { return `leg-${session.session_id}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80) }
|
|
23
24
|
|
|
25
|
+
export function sessionCommitDelta(cwd, session) {
|
|
26
|
+
if (!cwd) return { isClean: false, dirty: [], newCommits: [] }
|
|
27
|
+
const dirty = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '')).filter((f) => !LEG_DIRS.test(f)).slice(0, 60)
|
|
28
|
+
const isClean = dirty.length === 0
|
|
29
|
+
let newCommits = []
|
|
30
|
+
if (isClean && session?.head_at_start) {
|
|
31
|
+
const head = git(cwd, ['rev-parse', 'HEAD']).trim()
|
|
32
|
+
if (head && head !== session.head_at_start) {
|
|
33
|
+
const raw = git(cwd, ['log', '--oneline', `${session.head_at_start}..${head}`])
|
|
34
|
+
if (raw) newCommits = raw.split('\n').filter(Boolean)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { isClean, dirty, newCommits }
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
// Notes in the CLI's section vocabulary; see src/handoff.mjs buildNotes.
|
|
25
41
|
export function sessionNotes(session, { messages = [], why = 'handoff' } = {}) {
|
|
26
42
|
const cwd = workRoot(session)
|
|
27
43
|
const stat = git(cwd, ['diff', '--stat'])
|
|
28
|
-
const
|
|
44
|
+
const delta = sessionCommitDelta(cwd, session)
|
|
45
|
+
const dirty = delta.dirty
|
|
29
46
|
const recent = git(cwd, ['log', '--oneline', '-5'])
|
|
47
|
+
const opportunity = delta.isClean && delta.newCommits.length > 0
|
|
48
|
+
? `Next agent: read this bundle. The previous agent committed changes (${delta.newCommits.length} commit(s): ${delta.newCommits.slice(0, 3).join(' | ')}) and left a clean working tree. Check git log to verify whether the task is already satisfied before doing redundant work. Do not ask the human to restate the task.`
|
|
49
|
+
: 'Next agent: read this bundle, inspect `git status` and `git diff`, continue the task from the last agent message, and do not ask the human to restate the task.'
|
|
30
50
|
const lines = [
|
|
31
51
|
'## Scope', '',
|
|
32
52
|
`Task: ${session.task ?? '(no prompt recorded yet; read the transcript)'}`,
|
|
@@ -39,7 +59,10 @@ export function sessionNotes(session, { messages = [], why = 'handoff' } = {}) {
|
|
|
39
59
|
...bullets((session.files_touched ?? []).slice(0, 50).map((f) => `Edited this session: ${f}`)),
|
|
40
60
|
...bullets(recent ? [`Recent commits: ${recent.replace(/\n/g, ' | ')}`] : []),
|
|
41
61
|
'', '## Opportunities', '',
|
|
42
|
-
...bullets([
|
|
62
|
+
...bullets([
|
|
63
|
+
opportunity,
|
|
64
|
+
synthesisDirective(session.session_id),
|
|
65
|
+
]),
|
|
43
66
|
'', '## Open questions', '',
|
|
44
67
|
...bullets([`Why the previous agent stopped: ${why}`, session.limit?.detail ? `Limit text: ${session.limit.detail}` : null]),
|
|
45
68
|
'', '## Evidence anchors', '',
|
|
@@ -67,8 +90,18 @@ export function saveSessionBundle(session, { messages = [], why = 'checkpoint' }
|
|
|
67
90
|
if (r.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${r.status}): ${scrub(r.stderr || r.stdout).slice(0, 400)}`)
|
|
68
91
|
let out
|
|
69
92
|
try { out = JSON.parse(r.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(r.stdout).slice(0, 200)}`) }
|
|
70
|
-
const
|
|
71
|
-
|
|
93
|
+
const nowIso = new Date().toISOString()
|
|
94
|
+
const bundle = { id: out.bundle_id, path: join(cwd, '.context-handoffs', out.bundle_id), notes: notesPath, quality: out.quality ?? null, updated_at: nowIso, why }
|
|
95
|
+
if (why === 'checkpoint') {
|
|
96
|
+
const checkpoints = [...(session.checkpoints ?? []), nowIso].slice(-20)
|
|
97
|
+
session.checkpoints = checkpoints
|
|
98
|
+
updateSession(session.session_id, (cur) => ({
|
|
99
|
+
bundle,
|
|
100
|
+
checkpoints: [...(cur?.checkpoints ?? []), nowIso].slice(-20),
|
|
101
|
+
}))
|
|
102
|
+
} else {
|
|
103
|
+
updateSession(session.session_id, { bundle })
|
|
104
|
+
}
|
|
72
105
|
return bundle
|
|
73
106
|
}
|
|
74
107
|
|
|
@@ -82,22 +115,35 @@ export function resumePrompt(session, bundle, next) {
|
|
|
82
115
|
if (r.status === 0) loaded = r.stdout
|
|
83
116
|
} catch {}
|
|
84
117
|
const header = `# Leg handoff\n\nPrevious agent: ${session.agent} (${session.account}). Reason: ${session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'}${session.limit?.detail ? `, ${session.limit.detail}` : ''}.\nNext agent: ${next.agent} (${next.account}).\nBundle: ${bundle.path}\n\n`
|
|
85
|
-
const
|
|
118
|
+
const bundleDump = loaded || readFileSync(bundle.notes, 'utf8')
|
|
119
|
+
let synthesisSection = ''
|
|
120
|
+
try {
|
|
121
|
+
const rawSyn = readSynthesis(cwd, session.session_id)
|
|
122
|
+
if (rawSyn) {
|
|
123
|
+
synthesisSection = formatSynthesisSection(rawSyn, { sessionId: session.session_id })
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
const body = header + (synthesisSection ? `${synthesisSection}\n\n` : '') + bundleDump
|
|
86
127
|
// src/resume.mjs owns both files: the per-session one so two sessions sharing
|
|
87
128
|
// one checkout (--no-worktree, or two started in the same instant) never
|
|
88
129
|
// overwrite each other's handoff, and RESUME.md, the copy everyone opens.
|
|
89
130
|
// Both are stamped with the git state and the live terminals they describe,
|
|
90
|
-
// so `
|
|
131
|
+
// so `leg resume --check` can tell a reader when they stopped being true.
|
|
91
132
|
const why = session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'
|
|
92
133
|
writeHandoffPointer(session, body, { bundle, why })
|
|
93
134
|
const perSession = perSessionFile(cwd, session.session_id)
|
|
94
135
|
const task = session.task ? `\n\nThe task, as the human first stated it: ${session.task.slice(0, 700)}` : ''
|
|
136
|
+
const delta = sessionCommitDelta(cwd, session)
|
|
137
|
+
const actionText = delta.isClean && delta.newCommits.length > 0
|
|
138
|
+
? `. The previous agent committed changes (${delta.newCommits.length} commit(s): ${delta.newCommits.slice(0, 2).join(' | ')}) and left a clean working tree. Check git log and verify whether the task is already complete before doing redundant work; continue only if work remains.`
|
|
139
|
+
: ', check git status and git diff, then continue the work from where it stopped.'
|
|
95
140
|
// the absolute path: the next agent is spawned in the session's cwd, which is
|
|
96
|
-
// a subdirectory of the work root whenever
|
|
97
|
-
return `You are taking over an interactive coding session from ${session.agent}, which hit its usage limit. Read ${perSession} (the context handoff bundle is at ${bundle.path})
|
|
141
|
+
// a subdirectory of the work root whenever Leg was started in one
|
|
142
|
+
return `You are taking over an interactive coding session from ${session.agent}, which hit its usage limit. Read ${perSession} (the context handoff bundle is at ${bundle.path})${actionText} Do not ask the human to restate the task.${task}\n\n${SYNTHESIS_POINTER_PARAGRAPH}`
|
|
98
143
|
}
|
|
99
144
|
|
|
100
145
|
// Freshness, never existence: src/resume.mjs recomputes it from git at read
|
|
101
146
|
// time. `resumeFileExists` used to live here and answered "a file is on disk",
|
|
102
147
|
// which every caller then read as "the handoff it describes is still true".
|
|
103
148
|
export { resumeVerdict } from './resume.mjs'
|
|
149
|
+
export { synthesisFile, readSynthesis, validateSynthesis, validateSynthesisHeader, formatSynthesisSection, hasRecentSynthesis, synthesisDirective, SYNTHESIS_POINTER_PARAGRAPH } from './synthesis.mjs'
|
package/src/chain.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export const TRANSITIONS = [
|
|
|
19
19
|
['running', 'leg:completed', 'waiting_human', 'agent leg completed and a human station is next'],
|
|
20
20
|
['running', 'leg:handoff', 'handing_off', 'limit | incomplete | no_progress | stalled | failed and the chain has a next leg'],
|
|
21
21
|
['running', 'leg:handoff', 'failed', 'same outcomes with the chain exhausted'],
|
|
22
|
-
['running', 'leg:auth_failed', 'failed', '
|
|
22
|
+
['running', 'leg:auth_failed', 'failed', 'Leg/environment fault; no advance; human fixes and clicks Rerun'],
|
|
23
23
|
['running', 'leg:launch_failed', 'failed', 'same'],
|
|
24
24
|
['running', 'leg:killed', 'killed', 'the leg was killed from the board'],
|
|
25
25
|
['handing_off', 'bundle_written', 'queued', 'next leg (leg+1) queued at the same station'],
|
package/src/contract.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// contract — the per-leg prompt. Every CLI gets the same contract file at
|
|
2
|
-
// .
|
|
3
|
-
// .
|
|
2
|
+
// .leg/CONTRACT.md in the worktree (CLI-agnostic completion: write
|
|
3
|
+
// .leg/DONE when finished). Leg 1 prompt = the contract; a later leg or a
|
|
4
4
|
// resume = the handoff bundle's resume text + the contract.
|
|
5
5
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
6
6
|
import { join } from 'node:path'
|
|
@@ -44,8 +44,9 @@ export async function renderContract({ card, station, leg, entry, worktree, resu
|
|
|
44
44
|
'',
|
|
45
45
|
`- Work only inside this directory: ${worktree}. It is a git worktree on its own branch; commit as you go or leave changes uncommitted, both are fine.`,
|
|
46
46
|
'- Keep .leg/PROGRESS.md updated as you go: one line per meaningful step, newest last. It is how the next agent (or a human) picks up if you stop early.',
|
|
47
|
+
'- Maintain .leg/SYNTHESIS-<session-id>.md using the schema in section 4. Update it whenever you rule out an approach, make a consequential decision, or change direction. Keep each section to 5 bullets max, one line per bullet.',
|
|
47
48
|
'- Do not push, do not create remotes, do not open pull requests, do not change git config.',
|
|
48
|
-
'- Do not touch anything under .leg/ except PROGRESS.md and DONE.',
|
|
49
|
+
'- Do not touch anything under .leg/ except PROGRESS.md, SYNTHESIS-*.md, and DONE.',
|
|
49
50
|
'- No interactive prompt will be answered; if you need a permission you do not have, write what you need to .leg/PROGRESS.md and stop.',
|
|
50
51
|
'',
|
|
51
52
|
'## Finish',
|
package/src/fsx.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export function canonPath(p) {
|
|
|
16
16
|
return process.platform === 'win32' ? out.toLowerCase() : out
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// The real, long-form path (case preserved): what
|
|
19
|
+
// The real, long-form path (case preserved): what Leg stores and hands to
|
|
20
20
|
// git, so a short or symlinked input never leaks into card.json or worktrees.
|
|
21
21
|
export function realPath(p) {
|
|
22
22
|
let base = resolve(p)
|
|
@@ -39,7 +39,9 @@ const sleepSync = (ms) => { const t = Date.now() + ms; while (Date.now() < t) {
|
|
|
39
39
|
// poller) holds it at a time. A lock older than staleMs (a crashed holder) is
|
|
40
40
|
// stolen. If it cannot be acquired within the budget, fn runs anyway rather
|
|
41
41
|
// than hang the caller (a Claude Code hook must never block the user's turn).
|
|
42
|
-
|
|
42
|
+
// `mustHold`: a caller for whom running unlocked is worse than not running
|
|
43
|
+
// (two harness applies would tear one ownership record) gets a throw instead.
|
|
44
|
+
export function withFileLock(lockPath, fn, { retries = 60, waitMs = 20, staleMs = 5000, mustHold = false } = {}) {
|
|
43
45
|
let fd = null
|
|
44
46
|
for (let i = 0; i < retries; i++) {
|
|
45
47
|
try { fd = openSync(lockPath, 'wx'); break } catch (err) {
|
|
@@ -51,6 +53,7 @@ export function withFileLock(lockPath, fn, { retries = 60, waitMs = 20, staleMs
|
|
|
51
53
|
sleepSync(waitMs)
|
|
52
54
|
}
|
|
53
55
|
}
|
|
56
|
+
if (fd === null && mustHold) throw new Error(`could not take ${lockPath} within ${Math.round(retries * waitMs / 1000)} s; another Leg process holds it`)
|
|
54
57
|
try { return fn() } finally { if (fd !== null) { try { closeSync(fd) } catch {} try { unlinkSync(lockPath) } catch {} } }
|
|
55
58
|
}
|
|
56
59
|
|
package/src/handoff.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// handoff — the context-handoff-bundle seam.
|
|
1
|
+
// handoff — the context-handoff-bundle seam. Leg never re-implements the
|
|
2
2
|
// bundle format: it writes a structured notes file, calls the CLI as an argv
|
|
3
3
|
// subprocess (`save --repo-local` inside the worktree so the next agent finds
|
|
4
4
|
// the bundle in its cwd), validates the bundle, and later `load`s the resume.
|
|
@@ -68,9 +68,9 @@ function bullets(items) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// Notes in the CLI's own section vocabulary (Scope / Findings / Opportunities /
|
|
71
|
-
// Open questions / Evidence anchors) carrying
|
|
71
|
+
// Open questions / Evidence anchors) carrying Leg's four parts: Task, Done so
|
|
72
72
|
// far, Diff, Open findings. Anything not under a known heading is dropped by
|
|
73
|
-
// the parser, so every
|
|
73
|
+
// the parser, so every Leg line lives under one of those five.
|
|
74
74
|
export function buildNotes({ card, station, leg, entry, run, progress = '', lastMessage = null, diff = null, diffStat = '', changedFiles = [], extra = [] }) {
|
|
75
75
|
const outcome = run?.outcome ?? 'handoff'
|
|
76
76
|
const signal = run?.signal && run.signal !== 'none' ? ` (${run.signal})` : ''
|
|
@@ -150,9 +150,9 @@ export function writeHandoff({ card, station, leg, entry, run, worktree, runDir,
|
|
|
150
150
|
const notesPath = join(legDir, `handoff-${station.name}-leg${leg}.md`)
|
|
151
151
|
writeFileSync(notesPath, notes)
|
|
152
152
|
if (card.repo) ensureExcluded(card.repo, '.context-handoffs/')
|
|
153
|
-
const slug = `
|
|
154
|
-
const title = `
|
|
155
|
-
const save = chb(['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', '
|
|
153
|
+
const slug = `leg-${card.card_id}-${station.name}-leg${leg}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80)
|
|
154
|
+
const title = `leg ${card.card_id} ${station.name} leg ${leg} ${entry?.adapter ?? run?.adapter ?? 'agent'}`
|
|
155
|
+
const save = chb(['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'leg'], { cwd: worktree })
|
|
156
156
|
if (save.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${save.status}): ${scrub(save.stderr || save.stdout).slice(0, 500)}`)
|
|
157
157
|
let out
|
|
158
158
|
try { out = JSON.parse(save.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(save.stdout).slice(0, 300)}`) }
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// `leg harness <verb>` — the human surface of the portable harness. Every
|
|
2
|
+
// verb prints for a person by default and JSON with --json; every write is
|
|
3
|
+
// explicit (enable, sync, capture, source, policy), and check/status/inspect/
|
|
4
|
+
// explain/diff/doctor write nothing. Exit codes follow the rest of the CLI:
|
|
5
|
+
// 0 fine, 1 stale or attention (check, sync), 2 usage, 3 not enabled / no
|
|
6
|
+
// bundle / declined.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { createInterface } from 'node:readline'
|
|
10
|
+
import {
|
|
11
|
+
COMPONENTS, CLIENT_IDS, NO_ADAPTER, STATES, POLICIES,
|
|
12
|
+
applyHarness, captureHarness, detectSources, explainHarnessDrops, getHarnessStatus, harnessConfig, harnessDir, harnessHome,
|
|
13
|
+
inspectHarness, readHistory, registry, resolveSource, setHarnessConfig, bundleDir, captureFile,
|
|
14
|
+
} from './index.mjs'
|
|
15
|
+
import { HARNESS_POLICIES, HARNESS_SOURCES } from '../preferences.mjs'
|
|
16
|
+
import { check as checkVendor } from '../../scripts/sync-harness-engine.mjs'
|
|
17
|
+
|
|
18
|
+
const VERBS = ['status', 'inspect', 'enable', 'disable', 'capture', 'sync', 'check', 'explain', 'diff', 'source', 'policy', 'doctor', 'history']
|
|
19
|
+
const GLYPH = { synced: '✓', partial: '◐', stale: '✗', attention: '!', unsupported: '-', error: 'E', source: '=', blocked: '✗', off: '·' }
|
|
20
|
+
const ABBR = { rules: 'rules', identity: 'ident', hooks: 'hooks', skills: 'skills', agents: 'agents', commands: 'cmds', mcp: 'mcp', permissions: 'perms' }
|
|
21
|
+
|
|
22
|
+
export const HELP = `leg harness: carry the source agent's working environment to the agents a hand-off lands on
|
|
23
|
+
status [--json] what is enabled, the source, when it was captured, each client's state
|
|
24
|
+
inspect [--json] what the captured harness holds: rules, hooks, skills, agents, commands, MCP servers, permissions
|
|
25
|
+
enable [--source claude|codex] [--policy warn|sync|strict] [--to a,b] [--yes]
|
|
26
|
+
first run: detect clients, capture, show what each client receives and drops, apply after confirmation
|
|
27
|
+
disable stop carrying the harness; nothing already written is removed
|
|
28
|
+
capture [claude|codex] [--force] re-read the source client (fingerprinted: a no-op when nothing changed)
|
|
29
|
+
sync [--to a,b] [--force] [--dry-run] [--json]
|
|
30
|
+
capture, then write every managed file that is out of date; --force replaces hand-edited files (backed up)
|
|
31
|
+
check [--to a,b] [--json] report only, write nothing; exit 1 when a client is stale or needs attention
|
|
32
|
+
explain [--to a,b] [--json] every item a client could not receive, and why
|
|
33
|
+
diff <client> what a sync would write to that client, file by file, without writing it
|
|
34
|
+
source claude|codex which client's harness is the one carried
|
|
35
|
+
policy warn|sync|strict what an unattended hand-off may do (warn: report; sync: write when safe; strict: refuse an unsafe destination)
|
|
36
|
+
doctor [--json] the engine, the source, the bundle, the state and every client, each with a verdict
|
|
37
|
+
history [--json] [--limit n] the evidence trail: every capture, sync and hand-off decision`
|
|
38
|
+
|
|
39
|
+
function table(targets, { source }) {
|
|
40
|
+
const ids = Object.keys(targets)
|
|
41
|
+
const w = Math.max(6, ...ids.map((id) => targets[id].name.length))
|
|
42
|
+
const head = ['client'.padEnd(w), 'state'.padEnd(11), ...COMPONENTS.map((c) => ABBR[c].padEnd(6)), 'dropped'].join(' ')
|
|
43
|
+
const lines = [head, '-'.repeat(head.length)]
|
|
44
|
+
for (const id of ids) {
|
|
45
|
+
const t = targets[id]
|
|
46
|
+
const cells = COMPONENTS.map((c) => {
|
|
47
|
+
const comp = t.components[c]
|
|
48
|
+
if (t.state === 'source') return '='.padEnd(6)
|
|
49
|
+
const g = GLYPH[comp?.state] ?? '?'
|
|
50
|
+
const n = comp?.total !== null && comp?.total !== undefined ? `${g}${comp.carried}/${comp.total}` : g
|
|
51
|
+
return n.padEnd(6)
|
|
52
|
+
})
|
|
53
|
+
const note = t.state === 'source' ? 'source of truth; never written' : !t.installed ? 'not installed' : t.attention.length ? `${t.attention.length} need attention` : ''
|
|
54
|
+
lines.push(`${t.name.padEnd(w)} ${t.state.padEnd(11)} ${cells.join(' ')} ${String(t.dropped.length).padStart(7)} ${note}`)
|
|
55
|
+
}
|
|
56
|
+
lines.push(`legend: ✓ synced ◐ partial (some items dropped) ✗ stale ! attention (hand-edited or error) - unsupported = source${source ? ` (${source})` : ''}`)
|
|
57
|
+
return lines.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dropLines(targets) {
|
|
61
|
+
const out = []
|
|
62
|
+
for (const t of Object.values(targets)) {
|
|
63
|
+
if (!t.dropped.length && !t.attention.length) continue
|
|
64
|
+
out.push(` ${t.name}`)
|
|
65
|
+
for (const d of t.dropped) out.push(` ${d.component}: ${d.item}${d.excluded ? ' (excluded by policy)' : ''}: ${d.reason}`)
|
|
66
|
+
for (const a of t.attention) out.push(` ${a.component}: ${a.file ?? ''}${a.file ? ': ' : ''}${a.reason}`)
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function confirm(question) {
|
|
72
|
+
if (!process.stdin.isTTY) return false
|
|
73
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
74
|
+
const answer = await new Promise((r) => rl.question(`${question} [y/N] `, r))
|
|
75
|
+
rl.close()
|
|
76
|
+
return /^y(es)?$/i.test(answer.trim())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
|
|
80
|
+
|
|
81
|
+
export async function harnessCommand(cmd, args, { out, die, env = process.env }) {
|
|
82
|
+
const json = Boolean(args.json)
|
|
83
|
+
if (!cmd || cmd === 'help' || cmd === '--help') { out(HELP); return 0 }
|
|
84
|
+
if (!VERBS.includes(cmd)) return die(2, `unknown harness command "${cmd}" (${VERBS.join('|')})`)
|
|
85
|
+
const cfg = harnessConfig()
|
|
86
|
+
|
|
87
|
+
if (cmd === 'status') {
|
|
88
|
+
const s = getHarnessStatus({ env })
|
|
89
|
+
if (json) { out(JSON.stringify(s, null, 2)); return 0 }
|
|
90
|
+
out(`portable harness: ${s.enabled ? `on (policy ${s.policy})` : 'off'}${s.enabled ? '' : ' · leg harness enable'}`)
|
|
91
|
+
out(`source: ${s.source ?? 'none detected'}${s.sources_detected.length ? ` (on this machine: ${s.sources_detected.join(', ')})` : ''}`)
|
|
92
|
+
if (s.captured) out(`captured: ${s.captured_at} · fingerprint ${String(s.fingerprint).slice(0, 12)} · ${Object.entries(s.components ?? {}).map(([k, v]) => `${k} ${v}`).join(', ')}${s.source_changed ? ' · SOURCE CHANGED since (leg harness sync)' : ''}`)
|
|
93
|
+
else out('captured: never')
|
|
94
|
+
if (s.corrupt) out(`bundle: unreadable (${s.corrupt}); leg harness capture --force`)
|
|
95
|
+
for (const w of s.warnings) out(` ! ${w}`)
|
|
96
|
+
if (Object.keys(s.targets).length) out(table(s.targets, { source: s.source }))
|
|
97
|
+
for (const [agent, why] of Object.entries(s.unsupported)) out(`${agent}: unsupported. ${why}`)
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (cmd === 'inspect') {
|
|
102
|
+
const i = inspectHarness()
|
|
103
|
+
if (json) { out(JSON.stringify(i, null, 2)); return i.captured ? 0 : 3 }
|
|
104
|
+
if (!i.captured) { out('no harness captured yet: leg harness capture'); return 3 }
|
|
105
|
+
if (i.corrupt) { out(`the captured harness is unreadable: ${i.corrupt}`); return 3 }
|
|
106
|
+
out(`source ${i.source}, captured ${i.captured_at}, fingerprint ${i.fingerprint.slice(0, 12)}`)
|
|
107
|
+
out(`rules: ${i.rules_bytes} bytes${i.identity ? ', identity present' : ', no identity file'}`)
|
|
108
|
+
out(`hooks: ${Object.entries(i.hooks).map(([e, n]) => `${e} ${n}`).join(', ') || '(none)'}`)
|
|
109
|
+
out(`skills (${i.skills.length}): ${i.skills.join(', ') || '(none)'}`)
|
|
110
|
+
out(`agents (${i.agents.length}): ${i.agents.map((a) => `${a.name} [${a.model}${a.readonly ? ', read-only' : ''}]`).join(', ') || '(none)'}`)
|
|
111
|
+
out(`commands (${i.commands.length}): ${i.commands.join(', ') || '(none)'}`)
|
|
112
|
+
out(`mcp (${i.mcp.length}): ${i.mcp.map((m) => `${m.name} (${m.transport}${m.env_refs ? `, ${m.env_refs} env ref` : ''})`).join(', ') || '(none)'}`)
|
|
113
|
+
out(`permissions: allow ${i.permissions.allow.length}, deny ${i.permissions.deny.length}, ask ${i.permissions.ask.length}`)
|
|
114
|
+
for (const w of i.warnings) out(` ! ${w}`)
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (cmd === 'enable') {
|
|
119
|
+
const found = detectSources({ env })
|
|
120
|
+
const source = typeof args.source === 'string' ? args.source : cfg.source ?? found[0]?.id ?? null
|
|
121
|
+
if (!source) return die(3, `no source client found: neither ~/.claude/CLAUDE.md nor ~/.codex/AGENTS.md exists under ${harnessHome(env)}`)
|
|
122
|
+
if (!HARNESS_SOURCES.includes(source)) return die(2, `--source must be one of ${HARNESS_SOURCES.join(', ')}`)
|
|
123
|
+
const policy = typeof args.policy === 'string' ? args.policy : 'sync'
|
|
124
|
+
if (!HARNESS_POLICIES.includes(policy)) return die(2, `--policy must be one of ${HARNESS_POLICIES.join(', ')}`)
|
|
125
|
+
const to = list(args.to)
|
|
126
|
+
if (to) for (const id of to) if (!CLIENT_IDS.includes(id)) return die(2, `--to: unknown client "${id}" (${CLIENT_IDS.join(', ')})`)
|
|
127
|
+
out(`source: ${source}${found.length > 1 ? ` (also on this machine: ${found.filter((s) => s.id !== source).map((s) => s.id).join(', ')}; --source to choose)` : ''}`)
|
|
128
|
+
let cap
|
|
129
|
+
try { cap = captureHarness({ source, force: true, env }) } catch (err) { return die(3, `capture failed: ${err.message}`) }
|
|
130
|
+
const c = cap.bundle.manifest.components
|
|
131
|
+
out(`captured ${source}: ${Object.entries(c).map(([k, v]) => `${k} ${v}`).join(', ')}`)
|
|
132
|
+
for (const w of cap.warnings) out(` ! ${w}`)
|
|
133
|
+
const plan = applyHarness({ to, dryRun: true, env, bundle: cap.bundle })
|
|
134
|
+
const rows = Object.values(plan.targets)
|
|
135
|
+
out('')
|
|
136
|
+
out(table(plan.targets, { source }))
|
|
137
|
+
const drops = dropLines(plan.targets)
|
|
138
|
+
if (drops.length) { out(''); out('not carried:'); for (const l of drops) out(l) }
|
|
139
|
+
const writable = rows.filter((t) => t.installed && t.state !== 'source')
|
|
140
|
+
out('')
|
|
141
|
+
out(`policy ${policy}: ${policy === 'warn' ? 'hand-offs report drift and never write' : policy === 'sync' ? 'hand-offs write managed files when that is safe' : 'a hand-off refuses a destination that cannot be made safe'}`)
|
|
142
|
+
const go = args.yes ? true : await confirm(`write the managed files above into ${writable.map((t) => t.name).join(', ') || 'nothing (no client installed)'} and turn portable harness on?`)
|
|
143
|
+
if (!go) { out('nothing written. Re-run with --yes to enable without a prompt.'); return 3 }
|
|
144
|
+
const applied = writable.length ? applyHarness({ to, env, bundle: cap.bundle }) : { targets: {} }
|
|
145
|
+
setHarnessConfig({ enabled: true, policy, source })
|
|
146
|
+
out(`portable harness on: source ${source}, policy ${policy}${writable.length ? `; wrote ${Object.values(applied.targets).reduce((n, t) => n + t.files_written, 0)} file(s), ${Object.values(applied.targets).reduce((n, t) => n + t.backups.length, 0)} backup(s) under ${join(harnessDir(), 'backups')}` : ''}`)
|
|
147
|
+
const attention = Object.values(applied.targets).flatMap((t) => t.attention)
|
|
148
|
+
for (const a of attention) out(` ! ${a.component}: ${a.file ?? ''} ${a.reason}`)
|
|
149
|
+
return attention.length ? 1 : 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (cmd === 'disable') {
|
|
153
|
+
setHarnessConfig({ enabled: false })
|
|
154
|
+
out('portable harness off: hand-offs carry task context only. Files Leg wrote stay where they are (each carries "GENERATED by Leg harness"); leg harness enable turns it back on.')
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (cmd === 'capture') {
|
|
159
|
+
const source = args._[0] ?? cfg.source ?? resolveSource({ env, cfg })
|
|
160
|
+
if (!source) return die(3, 'no source client found: leg harness source <claude|codex>')
|
|
161
|
+
if (!HARNESS_SOURCES.includes(source)) return die(2, `source must be one of ${HARNESS_SOURCES.join(', ')}`)
|
|
162
|
+
try {
|
|
163
|
+
const cap = captureHarness({ source, force: Boolean(args.force), env })
|
|
164
|
+
if (json) { out(JSON.stringify({ source, cached: cap.cached, fingerprint: cap.bundle.manifest.fingerprint, captured_at: cap.bundle.manifest.capturedAt, components: cap.bundle.manifest.components, warnings: cap.warnings, elapsed_ms: cap.elapsed_ms }, null, 2)); return 0 }
|
|
165
|
+
out(`${cap.cached ? 'unchanged' : 'captured'} ${source} (${cap.elapsed_ms} ms): ${Object.entries(cap.bundle.manifest.components).map(([k, v]) => `${k} ${v}`).join(', ')} · fingerprint ${cap.bundle.manifest.fingerprint.slice(0, 12)}`)
|
|
166
|
+
for (const w of cap.warnings) out(` ! ${w}`)
|
|
167
|
+
return 0
|
|
168
|
+
} catch (err) { return die(3, `capture failed: ${err.message}`) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (cmd === 'sync' || cmd === 'check' || cmd === 'diff') {
|
|
172
|
+
const to = cmd === 'diff' ? (args._[0] ? [args._[0]] : null) : list(args.to)
|
|
173
|
+
if (cmd === 'diff' && !to) return die(2, 'usage: leg harness diff <client>')
|
|
174
|
+
if (to) for (const id of to) if (!CLIENT_IDS.includes(id)) return die(2, `unknown client "${id}" (${CLIENT_IDS.join(', ')}${NO_ADAPTER[id] ? `; ${NO_ADAPTER[id]}` : ''})`)
|
|
175
|
+
const source = cfg.source ?? resolveSource({ env, cfg })
|
|
176
|
+
if (!source) return die(3, 'no source client found: leg harness source <claude|codex>')
|
|
177
|
+
const dryRun = cmd !== 'sync' || Boolean(args['dry-run'])
|
|
178
|
+
// the one consent is `enable`, which shows the plan first; a sync on an
|
|
179
|
+
// install that never gave it writes nothing
|
|
180
|
+
if (!dryRun && !cfg.enabled) return die(3, 'portable harness is off: leg harness enable shows what a sync would write and asks first (leg harness check reports without writing)')
|
|
181
|
+
let cap
|
|
182
|
+
try { cap = captureHarness({ source, env }) } catch (err) { return die(3, `capture failed: ${err.message}`) }
|
|
183
|
+
let r
|
|
184
|
+
try { r = applyHarness({ to, force: Boolean(args.force), dryRun, env, bundle: cap.bundle }) } catch (err) { return die(3, err.message) }
|
|
185
|
+
const rows = Object.values(r.targets)
|
|
186
|
+
const stale = rows.some((t) => t.state === 'stale' || t.state === 'attention' || t.state === 'error')
|
|
187
|
+
if (json) { out(JSON.stringify({ ...r, cached_capture: cap.cached, warnings: cap.warnings }, null, 2)); return stale ? 1 : 0 }
|
|
188
|
+
if (cmd === 'diff') {
|
|
189
|
+
const raw = JSON.parse(readFileSync(join(harnessDir(), 'harness-report.json'), 'utf8'))
|
|
190
|
+
const t = raw.targets[to[0]]
|
|
191
|
+
out(`${source} → ${to[0]} (${t.installed ? 'installed' : 'not installed'}): what a sync would do`)
|
|
192
|
+
for (const [c, res] of Object.entries(t.components ?? {})) {
|
|
193
|
+
for (const f of res.files ?? []) if (f.action !== 'unchanged' && f.action !== 'inspected') out(` ${c.padEnd(11)} ${f.action.padEnd(20)} ${f.path}`)
|
|
194
|
+
}
|
|
195
|
+
const same = Object.values(t.components ?? {}).every((res) => (res.files ?? []).every((f) => f.action === 'unchanged' || f.action === 'inspected' || f.action === 'skipped-real-directory'))
|
|
196
|
+
if (same) out(' (nothing to write: already in sync)')
|
|
197
|
+
for (const l of dropLines({ [to[0]]: r.targets[to[0]] })) out(l)
|
|
198
|
+
return 0
|
|
199
|
+
}
|
|
200
|
+
out(`${cap.cached ? 'harness unchanged' : `captured ${source}`} · ${dryRun ? 'check only, nothing written' : `synced ${rows.filter((t) => t.state !== 'source').map((t) => t.id).join(', ')}`}`)
|
|
201
|
+
for (const w of cap.warnings) out(` ! ${w}`)
|
|
202
|
+
out(table(r.targets, { source }))
|
|
203
|
+
const drops = dropLines(r.targets)
|
|
204
|
+
if (drops.length) { out('not carried:'); for (const l of drops) out(l) }
|
|
205
|
+
if (!dryRun) {
|
|
206
|
+
const written = rows.reduce((n, t) => n + t.files_written, 0)
|
|
207
|
+
const backups = rows.reduce((n, t) => n + t.backups.length, 0)
|
|
208
|
+
out(`${written} file(s) written, ${backups} backup(s)${backups ? ` under ${join(harnessDir(), 'backups')}` : ''}`)
|
|
209
|
+
}
|
|
210
|
+
return stale ? 1 : 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (cmd === 'explain') {
|
|
214
|
+
const to = list(args.to)
|
|
215
|
+
const e = explainHarnessDrops({ to, env })
|
|
216
|
+
if (json) { out(JSON.stringify(e, null, 2)); return 0 }
|
|
217
|
+
if (!e.source) { out('no harness captured yet: leg harness capture'); return 3 }
|
|
218
|
+
out(`from the ${e.source} harness, ${e.dropped.length} item(s) not carried:`)
|
|
219
|
+
for (const d of e.dropped) out(` ${d.target.padEnd(7)} ${d.component.padEnd(11)} ${d.item}${d.excluded ? ' (excluded by policy)' : ''}: ${d.reason}`)
|
|
220
|
+
for (const [agent, why] of Object.entries(e.unsupported)) out(` ${agent.padEnd(7)} everything ${why}`)
|
|
221
|
+
return 0
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (cmd === 'source') {
|
|
225
|
+
const source = args._[0]
|
|
226
|
+
if (!source || !HARNESS_SOURCES.includes(source)) return die(2, `usage: leg harness source <${HARNESS_SOURCES.join('|')}>`)
|
|
227
|
+
const found = detectSources({ env }).map((s) => s.id)
|
|
228
|
+
if (!found.includes(source)) out(`note: no ${source} rules file under ${harnessHome(env)} right now; the setting is saved anyway`)
|
|
229
|
+
setHarnessConfig({ source })
|
|
230
|
+
out(`harness source: ${source}. Run leg harness sync to carry it.`)
|
|
231
|
+
return 0
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (cmd === 'policy') {
|
|
235
|
+
const policy = args._[0]
|
|
236
|
+
if (!policy || !HARNESS_POLICIES.includes(policy)) return die(2, `usage: leg harness policy <${HARNESS_POLICIES.join('|')}>`)
|
|
237
|
+
setHarnessConfig({ policy })
|
|
238
|
+
out(`harness policy: ${policy}${cfg.enabled ? '' : ' (portable harness is off; leg harness enable turns it on)'}`)
|
|
239
|
+
return 0
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (cmd === 'history') {
|
|
243
|
+
const rows = readHistory(args.limit ? parseInt(args.limit, 10) : 30)
|
|
244
|
+
if (json) { out(JSON.stringify(rows, null, 2)); return 0 }
|
|
245
|
+
if (!rows.length) { out('(no harness activity yet)'); return 0 }
|
|
246
|
+
for (const r of rows) out(`${r.ts} ${String(r.op).padEnd(8)} ${r.op === 'capture' ? `${r.source} fp ${String(r.fingerprint).slice(0, 12)} ${JSON.stringify(r.components)}` : r.op === 'apply' ? `${r.source} → ${r.target}: ${r.state}, ${r.written ?? 0} written, ${r.backups?.length ?? 0} backup(s)${r.force ? ' (forced)' : ''}` : `${r.from ?? '?'} → ${r.to}: ${r.state}${r.proceed === false ? ' BLOCKED' : ''} (${r.policy}${r.reason ? `; ${r.reason}` : ''})`}${r.session_id ? ` [${r.session_id}]` : ''} ${r.elapsed_ms ?? ''}ms`)
|
|
247
|
+
return 0
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (cmd === 'doctor') {
|
|
251
|
+
const rows = []
|
|
252
|
+
const push = (name, ok, detail) => rows.push({ name, status: ok === true ? 'ok' : ok === false ? 'FAIL' : 'note', detail })
|
|
253
|
+
try { const v = checkVendor(); push('engine', v.problems.length === 0, `vendored Agnostic AI engine at ${String(v.commit).slice(0, 12)}: ${v.checked} file(s) checked${v.problems.length ? `; ${v.problems.join('; ')}` : ''}`) } catch (err) { push('engine', false, err.message) }
|
|
254
|
+
const homeDir = harnessHome(env)
|
|
255
|
+
push('home', existsSync(homeDir), `client configs are read under ${homeDir}${env.LEG_HARNESS_HOME || env.BATON_HARNESS_HOME ? ' (LEG_HARNESS_HOME)' : ''}`)
|
|
256
|
+
push('enabled', cfg.enabled ? true : null, cfg.enabled ? `on, policy ${cfg.policy}` : 'off (leg harness enable)')
|
|
257
|
+
const found = detectSources({ env })
|
|
258
|
+
const source = cfg.source ?? found[0]?.id ?? null
|
|
259
|
+
push('source', Boolean(source), source ? `${source}${cfg.source ? ' (configured)' : ' (detected)'}` : 'none of ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md found')
|
|
260
|
+
const cap = existsSync(captureFile()) ? JSON.parse(readFileSync(captureFile(), 'utf8')) : null
|
|
261
|
+
push('bundle', cap ? (existsSync(join(bundleDir(), 'manifest.json')) ? true : false) : null, cap ? `captured ${cap.captured_at} from ${cap.source}; ${existsSync(join(bundleDir(), 'manifest.json')) ? 'manifest present' : 'manifest missing (leg harness capture --force)'}` : 'never captured')
|
|
262
|
+
if (cap) {
|
|
263
|
+
try { const i = inspectHarness(); push('bundle valid', !i.corrupt, i.corrupt ?? `fingerprint ${String(i.fingerprint).slice(0, 12)}`) } catch (err) { push('bundle valid', false, err.message) }
|
|
264
|
+
}
|
|
265
|
+
const stateFile = join(harnessDir(), 'harness-state.json')
|
|
266
|
+
let state = null
|
|
267
|
+
if (existsSync(stateFile)) { try { state = JSON.parse(readFileSync(stateFile, 'utf8')); push('state', true, `${Object.keys(state.targets ?? {}).length} client(s) with ownership records`) } catch (err) { push('state', false, `harness-state.json unreadable: ${err.message}`) } } else push('state', null, 'no ownership records yet (nothing written)')
|
|
268
|
+
push('policy file', null, existsSync(join(harnessDir(), 'policy.json')) ? `${join(harnessDir(), 'policy.json')} merged over the defaults` : 'defaults (nothing excluded)')
|
|
269
|
+
for (const t of registry({ env, home: homeDir })) push(`client ${t.id}`, t.installed ? true : null, t.installed ? `installed at ${t.home}` : `not installed (${t.home} absent)`)
|
|
270
|
+
for (const [agent, why] of Object.entries(NO_ADAPTER)) push(`client ${agent}`, null, why)
|
|
271
|
+
if (cap && source) {
|
|
272
|
+
try { const s = getHarnessStatus({ env }); for (const t of Object.values(s.targets)) if (t.state !== 'source') push(`sync ${t.id}`, t.state === 'synced' || t.state === 'partial' ? true : t.state === 'unsupported' ? null : false, `${t.state}${t.attention.length ? `: ${t.attention.map((a) => a.reason).join('; ')}` : ''}`) } catch (err) { push('sync', false, err.message) }
|
|
273
|
+
}
|
|
274
|
+
if (json) { out(JSON.stringify({ rows, states: STATES, policies: POLICIES }, null, 2)); return rows.some((r) => r.status === 'FAIL') ? 1 : 0 }
|
|
275
|
+
const w = Math.max(...rows.map((r) => r.name.length))
|
|
276
|
+
for (const r of rows) out(`${r.name.padEnd(w)} ${r.status.padEnd(4)} ${r.detail}`)
|
|
277
|
+
out(`doctor: ${rows.filter((r) => r.status === 'FAIL').length} failure(s) across ${rows.length} check(s)`)
|
|
278
|
+
return rows.some((r) => r.status === 'FAIL') ? 1 : 0
|
|
279
|
+
}
|
|
280
|
+
return die(2, `unknown harness command "${cmd}"`)
|
|
281
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// fingerprint — "did the source client's harness change since the last
|
|
2
|
+
// capture", answered from file metadata alone. A hand-off must not re-read a
|
|
3
|
+
// whole client home to find out that nothing moved: this stats the handful of
|
|
4
|
+
// surfaces a capture reads (rules and their imports, settings, MCP, agents,
|
|
5
|
+
// commands, skills) and hashes their paths, sizes and mtimes. A miss costs one
|
|
6
|
+
// capture; a hit costs a few dozen stat calls.
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
9
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
10
|
+
|
|
11
|
+
function stat(p) {
|
|
12
|
+
try { const s = statSync(p); return `${s.size}:${Math.round(s.mtimeMs)}` } catch { return 'absent' }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dirEntries(dir, inner = null) {
|
|
16
|
+
let names
|
|
17
|
+
try { names = readdirSync(dir).filter((n) => !n.startsWith('.')).sort() } catch { return [`${dir}=absent`] }
|
|
18
|
+
return names.map((n) => `${dir}/${n}=${stat(inner ? join(dir, n, inner) : join(dir, n))}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The files a Claude Code rules file pulls in (`@path` lines), one level deep,
|
|
22
|
+
// the same way the capture inlines them.
|
|
23
|
+
function claudeImports(rulesFile, home) {
|
|
24
|
+
const out = []
|
|
25
|
+
let text
|
|
26
|
+
try { text = readFileSync(rulesFile, 'utf8') } catch { return out }
|
|
27
|
+
for (const line of text.split(/\r?\n/)) {
|
|
28
|
+
const m = line.match(/^@(\S+)[ \t]*$/)
|
|
29
|
+
if (!m) continue
|
|
30
|
+
const spec = m[1]
|
|
31
|
+
const p = spec === '~' ? home : spec.startsWith('~/') || spec.startsWith('~\\') ? join(home, spec.slice(2)) : isAbsolute(spec) ? resolve(spec) : resolve(dirname(rulesFile), spec)
|
|
32
|
+
out.push(p)
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Every path a capture of `source` reads, with its metadata.
|
|
38
|
+
export function sourceSurfaces(source, target, home) {
|
|
39
|
+
const lines = []
|
|
40
|
+
if (source === 'claude') {
|
|
41
|
+
const rules = join(target.home, 'CLAUDE.md')
|
|
42
|
+
lines.push(`${rules}=${stat(rules)}`)
|
|
43
|
+
for (const imp of claudeImports(rules, home)) lines.push(`${imp}=${stat(imp)}`)
|
|
44
|
+
for (const f of [target.hooksConfigFile, target.traitsFile, target.mcpConfigFile, join(target.home, '.mcp.json')]) if (f) lines.push(`${f}=${stat(f)}`)
|
|
45
|
+
lines.push(...dirEntries(target.agentsDir))
|
|
46
|
+
lines.push(...dirEntries(target.commandsDir))
|
|
47
|
+
lines.push(...dirEntries(target.skillsDir, 'SKILL.md'))
|
|
48
|
+
} else if (source === 'codex') {
|
|
49
|
+
for (const f of [target.rulesFile, target.hooksConfigFile]) if (f) lines.push(`${f}=${stat(f)}`)
|
|
50
|
+
lines.push(...dirEntries(target.agentsDir))
|
|
51
|
+
lines.push(...dirEntries(target.commandsDir))
|
|
52
|
+
lines.push(...dirEntries(target.skillsDir, 'SKILL.md'))
|
|
53
|
+
if (target.permissionsFile) lines.push(...dirEntries(dirname(target.permissionsFile)))
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`no capture adapter for source "${source}"`)
|
|
56
|
+
}
|
|
57
|
+
return lines
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function sourceFingerprint(source, target, home) {
|
|
61
|
+
const lines = sourceSurfaces(source, target, home)
|
|
62
|
+
return { hash: createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 24), surfaces: lines.length }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sourcePresent(source, target) {
|
|
66
|
+
const rules = source === 'claude' ? join(target.home, 'CLAUDE.md') : target.rulesFile
|
|
67
|
+
return existsSync(rules)
|
|
68
|
+
}
|