@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +21 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/board-guide.md +13 -0
  12. package/docs/cli-contracts.md +22 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +22 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/fixtures/verified.json +1 -1
  19. package/package.json +7 -3
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/check-branding.mjs +118 -0
  22. package/scripts/check-claims.mjs +1 -1
  23. package/scripts/license-sign.mjs +1 -1
  24. package/scripts/limits-table.mjs +1 -1
  25. package/scripts/live-limits.mjs +1 -1
  26. package/scripts/npm-publish-gate.mjs +114 -0
  27. package/scripts/probe.mjs +4 -3
  28. package/scripts/seed-fake-cards.mjs +4 -3
  29. package/scripts/seed-floor-board.mjs +5 -4
  30. package/scripts/seed-wes-board.mjs +5 -4
  31. package/scripts/stripe-setup.mjs +1 -1
  32. package/scripts/sync-harness-engine.mjs +159 -0
  33. package/scripts/sync-leg-agents.mjs +127 -0
  34. package/src/accounts.mjs +1 -2
  35. package/src/adapters/codex.mjs +1 -1
  36. package/src/attach.mjs +75 -19
  37. package/src/auth.mjs +2 -2
  38. package/src/board/board.js +3 -3
  39. package/src/board/sessions.js +77 -3
  40. package/src/bundle.mjs +54 -8
  41. package/src/chain.mjs +1 -1
  42. package/src/contract.mjs +4 -3
  43. package/src/fsx.mjs +5 -2
  44. package/src/handoff.mjs +6 -6
  45. package/src/harness/cli.mjs +281 -0
  46. package/src/harness/fingerprint.mjs +68 -0
  47. package/src/harness/index.mjs +407 -0
  48. package/src/harness/registry.mjs +124 -0
  49. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  50. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  51. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  52. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  53. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  54. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  55. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  56. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  57. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  69. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  70. package/src/hook.mjs +49 -49
  71. package/src/land.mjs +7 -35
  72. package/src/launcher.mjs +38 -26
  73. package/src/ledger.mjs +6 -6
  74. package/src/license.mjs +10 -9
  75. package/src/live-capture.mjs +1 -1
  76. package/src/mergequeue.mjs +5 -5
  77. package/src/orchestrator.mjs +28 -4
  78. package/src/preferences.mjs +37 -3
  79. package/src/redact.mjs +1 -1
  80. package/src/resume.mjs +17 -15
  81. package/src/runner.mjs +2 -2
  82. package/src/scheduler.mjs +1 -1
  83. package/src/server.mjs +38 -10
  84. package/src/session-detail.mjs +15 -1
  85. package/src/sessions.mjs +6 -3
  86. package/src/share.mjs +2 -2
  87. package/src/stations/agent.mjs +1 -1
  88. package/src/sync/dashclaw.mjs +4 -4
  89. package/src/synthesis.mjs +165 -0
  90. package/src/taps/agy.mjs +2 -2
  91. package/src/taps/claude-usage.mjs +1 -1
  92. package/src/taps/claude.mjs +170 -170
  93. package/src/taps/codex.mjs +286 -286
  94. package/src/taps/grok.mjs +2 -2
  95. package/src/trust.mjs +205 -36
  96. package/src/usage.mjs +5 -1
  97. package/src/worktree.mjs +5 -4
  98. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  99. package/fixtures/live/agy/err.log +0 -0
  100. package/fixtures/live/agy/out.log +0 -1
  101. package/fixtures/live/agy/supervisor.log +0 -2
  102. package/fixtures/live/claude/err.log +0 -0
  103. package/fixtures/live/claude/out.log +0 -1
  104. package/fixtures/live/claude/supervisor.log +0 -2
  105. package/fixtures/live/codex/err.log +0 -1
  106. package/fixtures/live/codex/out.log +0 -8
  107. package/fixtures/live/codex/supervisor.log +0 -2
  108. package/fixtures/live/grok/err.log +0 -32
  109. package/fixtures/live/grok/out.log +0 -7
  110. package/fixtures/live/grok/supervisor.log +0 -2
@@ -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
+ }