@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,407 @@
1
+ // harness — the portable-harness subsystem. Leg carries a session's task
2
+ // context between agents (src/bundle.mjs); this carries the agent's WORKING
3
+ // ENVIRONMENT: the global working agreement, identity, hooks, skills,
4
+ // subagents, slash commands, MCP servers and permissions, as far as the
5
+ // destination client can represent them, and says what it could not carry.
6
+ //
7
+ // The engine is the Agnostic AI port engine (MIT), vendored byte for byte
8
+ // under vendor/agnostic-ai and driven only through its library entry
9
+ // (engine/harness/index.cjs). Leg owns everything around it: consent
10
+ // (preferences.harness.enabled), policy (warn | sync | strict), the client
11
+ // registry (registry.mjs), where state lives ($LEG_HOME/harness), the
12
+ // fingerprint that keeps a hand-off fast (fingerprint.mjs), the evidence
13
+ // trail (history.jsonl) and the decision a hand-off makes (prepare below).
14
+ //
15
+ // What the engine promises and Leg keeps: the source client is never written;
16
+ // every generated file carries the ownership claim BRAND.mark; a file the user
17
+ // hand-edited is backed up and skipped, never overwritten, unless --force;
18
+ // managed regions leave everything outside them byte for byte; a credential
19
+ // never enters the bundle (it becomes ${NAME} and the user is told); every
20
+ // dropped item carries a reason. Account credentials are not a harness concern
21
+ // at all: which login runs is the account layer's decision (src/accounts.mjs).
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import engine from './vendor/agnostic-ai/engine/harness/index.cjs'
25
+ import { BRAND, CLIENT_IDS, NO_ADAPTER, SOURCE_IDS, VENDOR, clientFor, detectSources, harnessHome, registry } from './registry.mjs'
26
+ import { sourceFingerprint, sourcePresent } from './fingerprint.mjs'
27
+ import { home } from '../store.mjs'
28
+ import { withFileLock, writeJsonAtomic } from '../fsx.mjs'
29
+ import { readPreferences, writePreferences, HARNESS_POLICIES } from '../preferences.mjs'
30
+ import { SECRET_PATTERNS } from '../redact.mjs'
31
+
32
+ export { BRAND, CLIENT_IDS, SOURCE_IDS, NO_ADAPTER, clientFor, detectSources, harnessHome, registry } from './registry.mjs'
33
+ export const COMPONENTS = engine.COMPONENTS
34
+ export const POLICIES = ['off', ...HARNESS_POLICIES]
35
+ // What a hand-off records about the destination's harness. One word each,
36
+ // so the board and the CLI print the same vocabulary.
37
+ export const STATES = ['off', 'same-client', 'source', 'synced', 'partial', 'stale', 'attention', 'unsupported', 'blocked', 'error']
38
+
39
+ // ---- engine configuration (once per process) ----
40
+ let configured = false
41
+ function guardsPatterns() {
42
+ try {
43
+ const g = JSON.parse(readFileSync(join(VENDOR, 'core', 'safety', 'guards.json'), 'utf8'))
44
+ return g?.guards?.secretScan?.sensitivePatterns ?? []
45
+ } catch { return [] }
46
+ }
47
+ export function configureEngine() {
48
+ if (configured) return engine
49
+ engine.configure({
50
+ brand: { ...BRAND },
51
+ // the engine's own shapes plus Leg's (src/redact.mjs): one list, fail closed
52
+ secretPatterns: [...guardsPatterns(), ...SECRET_PATTERNS.map(([, re]) => re)],
53
+ shimPath: join(VENDOR, 'engine', 'hooks', 'shim.cjs'),
54
+ // no rules live inside the package: an @import may only resolve inside the user's home
55
+ importRoots: [],
56
+ })
57
+ configured = true
58
+ return engine
59
+ }
60
+
61
+ // ---- where state lives ----
62
+ export function harnessDir() { return join(home(), 'harness') }
63
+ export const bundleDir = () => join(harnessDir(), 'bundle')
64
+ export const captureFile = () => join(harnessDir(), 'capture.json')
65
+ export const historyFile = () => join(harnessDir(), 'history.jsonl')
66
+ export const policyFile = () => join(harnessDir(), 'policy.json')
67
+
68
+ function readJson(file, fallback = null) {
69
+ if (!existsSync(file)) return fallback
70
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return fallback }
71
+ }
72
+
73
+ // ---- the port policy (what is deliberately not carried) ----
74
+ // Leg ships a neutral default: nothing excluded, nothing dropped, and the
75
+ // Codex model ladder that maps the four Claude tiers onto Codex models. A user
76
+ // customises it in $LEG_HOME/harness/policy.json, which is deep-merged over
77
+ // this (docs/harness.md, "The port policy").
78
+ export const DEFAULT_POLICY = Object.freeze({
79
+ targets: 'installed',
80
+ rules: { dropSectionsForTargets: [] },
81
+ hooks: { exclude: [], extra: {} },
82
+ skills: { exclude: {}, codexDisable: [] },
83
+ mcp: { exclude: {} },
84
+ agents: { modelLadder: { codex: { fable: ['gpt-6-astra', 'high'], opus: ['gpt-5.6-sol', 'high'], sonnet: ['gpt-5.6-terra', 'medium'], haiku: ['gpt-5.6-luna', 'low'] } } },
85
+ })
86
+ function merge(base, over) {
87
+ if (!over || typeof over !== 'object' || Array.isArray(over)) return over === undefined ? base : over
88
+ const out = { ...base }
89
+ for (const [k, v] of Object.entries(over)) out[k] = base && typeof base[k] === 'object' && !Array.isArray(base[k]) ? merge(base[k], v) : v
90
+ return out
91
+ }
92
+ export function readPolicy() {
93
+ const user = readJson(policyFile(), null)
94
+ const merged = merge(structuredClone(DEFAULT_POLICY), user ?? {})
95
+ merged.baseDir = harnessDir()
96
+ return merged
97
+ }
98
+
99
+ // ---- configuration (consent + mode + source) ----
100
+ export function harnessConfig() { return readPreferences().harness }
101
+ export function effectivePolicy(cfg = harnessConfig()) { return cfg.enabled ? cfg.policy : 'off' }
102
+
103
+ // ---- evidence ----
104
+ export function appendHistory(entry) {
105
+ mkdirSync(harnessDir(), { recursive: true })
106
+ appendFileSync(historyFile(), JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n')
107
+ }
108
+ export function readHistory(limit = 50) {
109
+ if (!existsSync(historyFile())) return []
110
+ return readFileSync(historyFile(), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean).slice(-limit)
111
+ }
112
+
113
+ // ---- capture ----
114
+ function sourceTarget(source, opts) {
115
+ const t = registry(opts).find((x) => x.id === source)
116
+ if (!t) throw new Error(`"${source}" is not a client Leg can capture a harness from (${SOURCE_IDS.join(', ')})`)
117
+ return t
118
+ }
119
+
120
+ export function resolveSource({ env = process.env, cfg = harnessConfig() } = {}) {
121
+ if (cfg.source) return cfg.source
122
+ const found = detectSources({ env })
123
+ return found.length ? found[0].id : null
124
+ }
125
+
126
+ // Read the source client into the neutral bundle under $LEG_HOME/harness/bundle.
127
+ // Cheap when nothing changed: the source surfaces are fingerprinted first and a
128
+ // matching capture.json means the bundle on disk is current.
129
+ export function captureHarness({ source = null, force = false, env = process.env } = {}) {
130
+ configureEngine()
131
+ const src = source ?? resolveSource({ env })
132
+ if (!src) throw new Error('no source client found: none of ~/.claude/CLAUDE.md or ~/.codex/AGENTS.md exists (leg harness source <claude|codex>)')
133
+ const homeDir = harnessHome(env)
134
+ const reg = registry({ env, home: homeDir })
135
+ const target = sourceTarget(src, { env, home: homeDir })
136
+ if (!sourcePresent(src, target)) throw new Error(`source client ${src} has no rules file under ${target.home}`)
137
+ const fp = sourceFingerprint(src, target, homeDir)
138
+ const t0 = Date.now()
139
+ mkdirSync(harnessDir(), { recursive: true })
140
+ return withFileLock(join(harnessDir(), '.lock'), () => {
141
+ const cache = readJson(captureFile())
142
+ if (!force && cache && cache.source === src && cache.surfaces_hash === fp.hash && existsSync(join(bundleDir(), 'manifest.json'))) {
143
+ // a bundle that no longer loads, or no longer matches the fingerprint it
144
+ // was recorded with, is repaired by capturing again, never trusted
145
+ let bundle = null
146
+ try { bundle = engine.bundle.load(bundleDir()) } catch { bundle = null }
147
+ if (bundle && bundle.manifest.fingerprint === cache.fingerprint) return { bundle, warnings: cache.warnings ?? [], cached: true, source: src, elapsed_ms: Date.now() - t0 }
148
+ }
149
+ const { bundle, warnings } = engine.capture({ from: src, home: homeDir, outDir: bundleDir(), registry: reg, port: readPolicy() })
150
+ const record = { source: src, fingerprint: bundle.manifest.fingerprint, surfaces_hash: fp.hash, surfaces: fp.surfaces, captured_at: bundle.manifest.capturedAt, components: bundle.manifest.components, warnings }
151
+ mkdirSync(harnessDir(), { recursive: true })
152
+ writeJsonAtomic(captureFile(), record)
153
+ appendHistory({ op: 'capture', source: src, fingerprint: record.fingerprint, components: record.components, warnings, elapsed_ms: Date.now() - t0 })
154
+ return { bundle, warnings, cached: false, source: src, elapsed_ms: Date.now() - t0 }
155
+ }, LOCK)
156
+ }
157
+
158
+ // One writer at a time across every Leg process: a hand-off in one terminal,
159
+ // a `leg harness sync` in another. Thirty seconds covers a cold apply; a
160
+ // caller that cannot get the lock fails its own step rather than tearing the
161
+ // ownership record two processes would otherwise both rewrite.
162
+ const LOCK = { retries: 600, waitMs: 50, staleMs: 60000, mustHold: true }
163
+
164
+ export function loadBundle() {
165
+ configureEngine()
166
+ try { return engine.bundle.load(bundleDir()) } catch (err) { return { corrupt: err.message } }
167
+ }
168
+
169
+ // What the bundle holds, by name, for `leg harness inspect` and the board.
170
+ export function inspectHarness() {
171
+ const cap = readJson(captureFile())
172
+ const b = loadBundle()
173
+ if (!b) return { captured: false, source: cap?.source ?? null }
174
+ if (b.corrupt) return { captured: true, corrupt: b.corrupt, source: cap?.source ?? null }
175
+ return {
176
+ captured: true,
177
+ source: b.manifest.source,
178
+ captured_at: b.manifest.capturedAt,
179
+ fingerprint: b.manifest.fingerprint,
180
+ components: b.manifest.components,
181
+ rules_bytes: b.rules.length,
182
+ identity: Boolean(b.identity),
183
+ hooks: Object.fromEntries(Object.entries(b.hooks.events).map(([ev, groups]) => [ev, groups.reduce((n, g) => n + g.hooks.length, 0)])),
184
+ skills: b.skills.skills.map((s) => s.name),
185
+ agents: b.agents.map((a) => ({ name: a.name, model: a.meta.model, readonly: String(a.meta.readonly) === 'true' })),
186
+ commands: b.commands.map((c) => c.name),
187
+ mcp: Object.entries(b.mcp.servers).map(([name, s]) => ({ name, transport: s.transport, env_refs: [...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})].filter((v) => /^\$\{[A-Za-z0-9_]+\}$/.test(String(v))).length })),
188
+ permissions: b.permissions,
189
+ warnings: cap?.warnings ?? [],
190
+ }
191
+ }
192
+
193
+ // ---- normalising an engine report into Leg's vocabulary ----
194
+ const ROW_STATE = { synced: 'synced', written: 'synced', stale: 'stale', skipped: 'attention', unsupported: 'unsupported', error: 'error', source: 'source' }
195
+ const COMPONENT_STATE = { synced: 'synced', written: 'synced', stale: 'stale', skipped: 'attention', unsupported: 'unsupported', error: 'error' }
196
+ const COUNTED = { skills: 'skills', agents: 'agents', commands: 'commands', mcp: 'mcp', hooks: 'hooks', permissions: 'permissions' }
197
+
198
+ function excludedBy(policy, component, item) {
199
+ const name = String(item).replace(/^(skill|mcp|agent|command)\s+/, '')
200
+ if (component === 'skills' && policy.skills?.exclude?.[name]) return true
201
+ if (component === 'mcp' && policy.mcp?.exclude?.[name]) return true
202
+ if (component === 'hooks' && (policy.hooks?.exclude ?? []).some((e) => { try { return new RegExp(e.match).test(item) } catch { return String(item).includes(e.match) } })) return true
203
+ if (component === 'rules' && /^rules section/.test(item)) return true
204
+ return false
205
+ }
206
+
207
+ export function normalizeRow(row, bundle, policy = readPolicy()) {
208
+ const counts = bundle?.manifest?.components ?? {}
209
+ const components = {}
210
+ const dropped = []
211
+ const attention = []
212
+ let files = 0
213
+ for (const c of COMPONENTS) {
214
+ const r = row.components?.[c]
215
+ if (!r) { components[c] = { state: row.status === 'source' ? 'source' : 'unsupported', note: null }; continue }
216
+ const state = COMPONENT_STATE[r.status] ?? 'error'
217
+ const drops = (r.dropped ?? []).map((d) => ({ component: c, item: d.item, reason: d.reason, excluded: excludedBy(policy, c, d.item) }))
218
+ dropped.push(...drops)
219
+ // a deny or ask the destination cannot express leaves it with a WIDER
220
+ // policy than the source: that is not a drop to note, it is a gap to fix
221
+ for (const d of drops) if (c === 'permissions' && /^(deny|ask):/.test(d.item) && !d.excluded) attention.push({ component: c, file: null, reason: `${d.item} cannot be expressed here (${d.reason}); the destination is less restricted than the source` })
222
+ for (const f of r.files ?? []) {
223
+ if (f.action === 'written' || f.action === 'linked' || f.action === 'pruned' || f.action === 'removed') files++
224
+ if (f.action === 'skipped-hand-edited') attention.push({ component: c, file: f.path, reason: 'hand-edited since Leg last wrote it; left alone (backup taken). Run `leg harness sync --force` to replace it, or edit the source client instead.' })
225
+ }
226
+ if (r.status === 'error') attention.push({ component: c, file: null, reason: r.error ?? r.note ?? 'error' })
227
+ const total = COUNTED[c] ? (counts[c] ?? 0) : null
228
+ const notCarried = drops.filter((d) => !d.excluded).length
229
+ const carried = total === null ? null : Math.max(0, total - drops.length)
230
+ components[c] = { state, note: r.note ?? null, total, carried, dropped: drops.length, excluded: drops.length - notCarried, error: r.error ?? null }
231
+ }
232
+ let state = ROW_STATE[row.status] ?? 'error'
233
+ if (state === 'synced' && dropped.some((d) => !d.excluded)) state = 'partial'
234
+ if (attention.length && state !== 'error') state = 'attention'
235
+ return { state, installed: row.installed, adapter: row.adapter, components, dropped, attention, files_written: files, backups: [], note: row.note ?? null, error: row.error ?? null }
236
+ }
237
+
238
+ function sortedTargets(report, bundle, policy) {
239
+ const out = {}
240
+ for (const id of CLIENT_IDS) if (report.targets[id]) out[id] = { id, name: report.targets[id].name, ...normalizeRow(report.targets[id], bundle, policy) }
241
+ return out
242
+ }
243
+
244
+ // ---- compare / apply ----
245
+ export function compareHarness({ to = null, env = process.env, bundle = null } = {}) {
246
+ configureEngine()
247
+ const b = bundle ?? loadBundle()
248
+ if (!b || b.corrupt) return { captured: Boolean(b), corrupt: b?.corrupt ?? null, targets: {} }
249
+ const homeDir = harnessHome(env)
250
+ const policy = readPolicy()
251
+ mkdirSync(harnessDir(), { recursive: true })
252
+ const report = withFileLock(join(harnessDir(), '.lock'), () => engine.status({ bundle: b, home: homeDir, registry: registry({ env, home: homeDir }), port: policy, storageDir: harnessDir(), to: to ? [].concat(to) : undefined, quiet: true, log: () => {} }).report, LOCK)
253
+ return { captured: true, source: b.manifest.source, fingerprint: b.manifest.fingerprint, captured_at: b.manifest.capturedAt, targets: sortedTargets(report, b, policy), stale: report.stale }
254
+ }
255
+
256
+ export function applyHarness({ to = null, force = false, dryRun = false, env = process.env, bundle = null, sessionId = null } = {}) {
257
+ configureEngine()
258
+ const b = bundle ?? loadBundle()
259
+ if (!b) throw new Error('no harness captured yet: leg harness capture')
260
+ if (b.corrupt) throw new Error(`the captured harness is unreadable (${b.corrupt}); run leg harness capture --force`)
261
+ const homeDir = harnessHome(env)
262
+ const policy = readPolicy()
263
+ const t0 = Date.now()
264
+ const backupsDir = join(harnessDir(), 'backups')
265
+ const before = backupNames(backupsDir)
266
+ mkdirSync(harnessDir(), { recursive: true })
267
+ const report = withFileLock(join(harnessDir(), '.lock'), () => engine.apply({
268
+ bundle: b, home: homeDir, registry: registry({ env, home: homeDir }), port: policy, storageDir: harnessDir(),
269
+ to: to ? [].concat(to) : undefined, force, dryRun, check: dryRun, quiet: true, log: () => {},
270
+ }), LOCK)
271
+ const targets = sortedTargets(report, b, policy)
272
+ // The writer names every backup it takes (<target>-<file>-<stamp>.bak) but
273
+ // adapters keep only the action, so the count comes from the directory.
274
+ const created = [...backupNames(backupsDir)].filter((n) => !before.has(n))
275
+ for (const [id, t] of Object.entries(targets)) t.backups = created.filter((n) => n.startsWith(`${id}-`)).map((n) => join(backupsDir, n))
276
+ const out = { source: b.manifest.source, fingerprint: b.manifest.fingerprint, captured_at: b.manifest.capturedAt, mode: report.mode, targets, applied_at: report.appliedAt, elapsed_ms: Date.now() - t0 }
277
+ if (!dryRun) {
278
+ for (const [id, t] of Object.entries(targets)) {
279
+ const files = []
280
+ for (const r of Object.values(report.targets[id].components ?? {})) for (const f of r.files ?? []) if (f.action && f.action !== 'unchanged' && f.action !== 'inspected') files.push({ path: engine.common.tildePath(f.path, homeDir), action: f.action })
281
+ appendHistory({ op: 'apply', source: out.source, target: id, fingerprint: out.fingerprint, captured_at: out.captured_at, state: t.state, session_id: sessionId, force, components: Object.fromEntries(Object.entries(t.components).map(([c, v]) => [c, v.state])), dropped: t.dropped.slice(0, 50), attention: t.attention, warnings: [], files, written: t.files_written, backups: t.backups, elapsed_ms: out.elapsed_ms })
282
+ }
283
+ }
284
+ return out
285
+ }
286
+
287
+ function backupNames(dir) {
288
+ try { return new Set(readdirSync(dir).filter((n) => { try { return statSync(join(dir, n)).isFile() } catch { return false } })) } catch { return new Set() }
289
+ }
290
+
291
+ // ---- status ----
292
+ export function getHarnessStatus({ env = process.env, fresh = false } = {}) {
293
+ const cfg = harnessConfig()
294
+ const cap = readJson(captureFile())
295
+ const source = cfg.source ?? cap?.source ?? resolveSource({ env, cfg })
296
+ const out = {
297
+ enabled: cfg.enabled, policy: effectivePolicy(cfg), configured_policy: cfg.policy, source,
298
+ sources_detected: detectSources({ env }).map((s) => s.id),
299
+ captured: Boolean(cap), captured_at: cap?.captured_at ?? null, fingerprint: cap?.fingerprint ?? null, components: cap?.components ?? null,
300
+ source_changed: null, targets: {}, unsupported: {}, warnings: cap?.warnings ?? [],
301
+ }
302
+ for (const [agent, reason] of Object.entries(NO_ADAPTER)) out.unsupported[agent] = reason
303
+ if (!source) return out
304
+ try {
305
+ const homeDir = harnessHome(env)
306
+ const target = registry({ env, home: homeDir }).find((t) => t.id === source)
307
+ if (target && sourcePresent(source, target)) out.source_changed = cap ? sourceFingerprint(source, target, homeDir).hash !== cap.surfaces_hash : null
308
+ } catch { /* the status page must render whatever the source looks like */ }
309
+ if (fresh && out.enabled) {
310
+ try { const c = captureHarness({ source, env }); out.captured = true; out.captured_at = c.bundle.manifest.capturedAt; out.fingerprint = c.bundle.manifest.fingerprint; out.components = c.bundle.manifest.components; out.source_changed = false; out.warnings = c.warnings } catch (err) { out.capture_error = err.message }
311
+ }
312
+ if (out.captured) {
313
+ const cmp = compareHarness({ env })
314
+ out.targets = cmp.targets
315
+ out.corrupt = cmp.corrupt ?? null
316
+ }
317
+ return out
318
+ }
319
+
320
+ export function explainHarnessDrops({ to = null, env = process.env } = {}) {
321
+ const cmp = compareHarness({ to, env })
322
+ const out = []
323
+ for (const t of Object.values(cmp.targets)) for (const d of t.dropped) out.push({ target: t.id, ...d })
324
+ return { source: cmp.source ?? null, dropped: out, unsupported: NO_ADAPTER }
325
+ }
326
+
327
+ // ---- enable / disable ----
328
+ export function setHarnessConfig(patch) { return writePreferences({ harness: patch }).harness }
329
+
330
+ // ---- the hand-off decision ----
331
+ // Called before every leg Leg launches. Deterministic, never interactive,
332
+ // never throws: the answer is a record the session keeps and the board shows.
333
+ // proceed whether the launch goes ahead (only strict mode says no)
334
+ // state one of STATES
335
+ export function prepareHarnessForHandoff({ from = null, to, sessionId = null, env = process.env, cfg = harnessConfig(), log = null } = {}) {
336
+ const t0 = Date.now()
337
+ const policy = effectivePolicy(cfg)
338
+ const base = { policy, from: from ?? null, to, target: clientFor(to), proceed: true, elapsed_ms: 0, at: new Date().toISOString() }
339
+ const finish = (rest) => {
340
+ const out = { ...base, ...rest, elapsed_ms: Date.now() - t0 }
341
+ if (out.state !== 'off') appendHistory({ op: 'prepare', session_id: sessionId, from: out.from, to: out.to, target: out.target, policy, state: out.state, proceed: out.proceed, fingerprint: out.fingerprint ?? null, reason: out.reason ?? null, elapsed_ms: out.elapsed_ms })
342
+ if (log) log(out)
343
+ return out
344
+ }
345
+ if (policy === 'off') return finish({ state: 'off', summary: 'portable harness is off' })
346
+ const target = clientFor(to)
347
+ const fromClient = clientFor(from)
348
+ if (!target) return finish({ state: 'unsupported', reason: `${to} runs under no client Leg can carry a harness to`, summary: `${to}: no client to carry a harness to` })
349
+ if (fromClient && fromClient === target) return finish({ state: 'same-client', summary: `${target} to ${target}: same client, same harness` })
350
+ try {
351
+ const source = resolveSource({ env, cfg })
352
+ if (!source) return finish({ state: 'error', reason: 'no source client configured or detected', proceed: policy !== 'strict', summary: 'no source harness to carry' })
353
+ if (target === source) return finish({ state: 'source', source, summary: `${target} is the source of the harness; nothing to carry` })
354
+ if (NO_ADAPTER[target] || !CLIENT_IDS.includes(target)) {
355
+ return finish({ state: 'unsupported', source, reason: NO_ADAPTER[target] ?? `no harness adapter for ${target}`, proceed: policy !== 'strict', summary: `${target}: ${NO_ADAPTER[target] ?? 'no harness adapter'}` })
356
+ }
357
+ const cap = captureHarness({ source, env })
358
+ const check = compareHarness({ to: target, env, bundle: cap.bundle })
359
+ let row = check.targets[target]
360
+ if (!row) return finish({ state: 'error', source, reason: `no status for ${target}`, proceed: policy !== 'strict', summary: `${target}: no status` })
361
+ if (!row.installed) return finish({ state: 'unsupported', source, reason: `${target} is not installed under ${harnessHome(env)}`, proceed: policy !== 'strict', summary: `${target} is not installed` })
362
+ let synced_at = null
363
+ let mode = 'check'
364
+ if (policy !== 'warn' && (row.state === 'stale' || row.state === 'attention')) {
365
+ const applied = applyHarness({ to: target, env, bundle: cap.bundle, sessionId })
366
+ row = applied.targets[target]
367
+ synced_at = applied.applied_at
368
+ mode = 'sync'
369
+ }
370
+ const blocked = policy === 'strict' && !(row.state === 'synced' || row.state === 'partial')
371
+ const state = blocked ? 'blocked' : row.state
372
+ const summary = describe({ state, target, row, policy, mode, cached: cap.cached })
373
+ return finish({
374
+ state, source, fingerprint: cap.bundle.manifest.fingerprint, captured_at: cap.bundle.manifest.capturedAt, capture_cached: cap.cached,
375
+ synced_at, mode, proceed: !blocked, reason: blocked ? `strict policy: ${target} is ${row.state}` : null,
376
+ components: row.components, dropped: row.dropped.slice(0, 50), attention: row.attention, warnings: cap.warnings,
377
+ files_written: row.files_written, backups: row.backups.length, summary,
378
+ })
379
+ } catch (err) {
380
+ return finish({ state: 'error', reason: String(err.message).slice(0, 300), proceed: policy !== 'strict', summary: `harness error: ${String(err.message).slice(0, 120)}` })
381
+ }
382
+ }
383
+
384
+ function describe({ state, target, row, policy, mode, cached }) {
385
+ const carried = COMPONENTS.filter((c) => row.components[c]?.state === 'synced').length
386
+ const supported = COMPONENTS.filter((c) => row.components[c]?.state !== 'unsupported').length
387
+ const parts = [`${target} harness ${state}`]
388
+ parts.push(`${carried}/${supported} components${row.dropped.length ? `, ${row.dropped.length} dropped` : ''}`)
389
+ if (mode === 'sync') parts.push(`${row.files_written} file(s) written`)
390
+ else if (state === 'stale') parts.push(policy === 'warn' ? 'warn policy: not synced' : 'not synced')
391
+ if (row.attention.length) parts.push(`${row.attention.length} need(s) attention`)
392
+ if (cached) parts.push('capture reused')
393
+ return parts.join(' · ')
394
+ }
395
+
396
+ // The one-line summary a session's timeline and the terminal print.
397
+ export function harnessLine(outcome) {
398
+ if (!outcome || outcome.state === 'off') return null
399
+ if (outcome.state === 'same-client') return null
400
+ return outcome.summary ?? `${outcome.target}: ${outcome.state}`
401
+ }
402
+
403
+ // A status a session card can show beside the agent, or null when there is nothing to say.
404
+ export function harnessBadge(outcome) {
405
+ if (!outcome || ['off', 'same-client'].includes(outcome.state)) return null
406
+ return { state: outcome.state, target: outcome.target, source: outcome.source ?? null, synced_at: outcome.synced_at ?? null, captured_at: outcome.captured_at ?? null }
407
+ }
@@ -0,0 +1,124 @@
1
+ // registry — which coding clients Leg can carry a harness between, and where
2
+ // each keeps its configuration. Built from the engine's own client registry
3
+ // (src/harness/vendor/agnostic-ai/core/templates/targets.json, byte-for-byte
4
+ // from upstream) narrowed to the clients Leg launches and given Leg's own
5
+ // generated-file preambles. Paths honour the same config-dir variables the
6
+ // trust module and the account layer honour (CLAUDE_CONFIG_DIR, CODEX_HOME,
7
+ // GEMINI_CONFIG_DIR), so a harness lands where the agent Leg starts will read.
8
+ import { existsSync, readFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import engine from './vendor/agnostic-ai/engine/harness/index.cjs'
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url))
15
+ export const VENDOR = join(HERE, 'vendor', 'agnostic-ai')
16
+ const TARGETS_FILE = join(VENDOR, 'core', 'templates', 'targets.json')
17
+
18
+ // The clients a `leg <agent>` session can hand off between, plus Gemini CLI,
19
+ // which shares GEMINI.md with Antigravity and is kept coherent with it.
20
+ export const CLIENT_IDS = ['claude', 'codex', 'gemini', 'agy']
21
+ // Clients the engine can read a harness FROM.
22
+ export const SOURCE_IDS = ['claude', 'codex']
23
+ // Leg agents with no harness adapter: reported as unsupported, never guessed.
24
+ export const NO_ADAPTER = { grok: 'the Grok CLI has no harness adapter yet: its rules, skills and MCP servers are not carried' }
25
+
26
+ export const BRAND = Object.freeze({ id: 'leg-harness', mark: 'GENERATED by Leg harness', region: 'leg harness' })
27
+
28
+ const PATH_FIELDS = ['home', 'rulesFile', 'traitsFile', 'hooksConfigFile', 'skillsDir', 'agentsDir', 'commandsDir', 'mcpConfigFile', 'permissionsFile']
29
+
30
+ // Leg's own preamble per client: the ownership claim (BRAND.mark) in the first
31
+ // lines, and the command that regenerates the file.
32
+ const PREAMBLES = {
33
+ claude: '# Leg harness rules (Claude Code)\n\nGENERATED by Leg harness from the captured source client. Do not hand-edit; edit the source client and run `leg harness sync`. Loaded via an @import line in ~/.claude/CLAUDE.md.\n',
34
+ codex: '# AGENTS.md (Codex CLI)\n\nGENERATED by Leg harness from the captured source client. Do not hand-edit; edit the source client and run `leg harness sync`.\n',
35
+ gemini: '# GEMINI.md (Gemini CLI)\n\nGENERATED by Leg harness from the captured source client. Do not hand-edit; edit the source client and run `leg harness sync`.\n',
36
+ agy: '# GEMINI.md (Gemini CLI)\n\nGENERATED by Leg harness from the captured source client. Do not hand-edit; edit the source client and run `leg harness sync`.\n',
37
+ }
38
+
39
+ // Leg writes its rules file under its own name so it never collides with a
40
+ // file another tool generated into the same client.
41
+ const RENAMES = {
42
+ claude: { rulesFile: '~/.claude/leg-rules.md' },
43
+ codex: { permissionsFile: '~/.codex/rules/leg-harness.rules' },
44
+ }
45
+
46
+ // A drive-letter path is absolute on every host (src/trust.mjs makes the same call).
47
+ const DRIVE_ABS = /^[A-Za-z]:[\\/]/
48
+ const absolutely = (p) => (DRIVE_ABS.test(p) ? p : resolve(p))
49
+
50
+ // `~/.codex` -> $CODEX_HOME, and so on, applied to every path field of one
51
+ // entry. The variable names a directory; the file names inside it stay.
52
+ function rebase(entry, from, to) {
53
+ const out = { ...entry }
54
+ for (const field of PATH_FIELDS) {
55
+ const v = entry[field]
56
+ if (typeof v !== 'string') continue
57
+ if (v === from) out[field] = to
58
+ else if (v.startsWith(from + '/')) out[field] = join(to, v.slice(from.length + 1))
59
+ }
60
+ return out
61
+ }
62
+
63
+ function envOverrides(entry, env) {
64
+ let e = entry
65
+ if (entry.id === 'claude' && env.CLAUDE_CONFIG_DIR) {
66
+ const dir = absolutely(env.CLAUDE_CONFIG_DIR)
67
+ e = rebase(e, '~/.claude', dir)
68
+ // Claude Code keeps its MCP servers beside the config dir, not inside it
69
+ if (e.mcpConfigFile === '~/.claude.json') e.mcpConfigFile = join(dir, '.claude.json')
70
+ }
71
+ if (entry.id === 'codex' && env.CODEX_HOME) e = rebase(e, '~/.codex', absolutely(env.CODEX_HOME))
72
+ if ((entry.id === 'gemini' || entry.id === 'agy') && env.GEMINI_CONFIG_DIR) e = rebase(e, '~/.gemini', absolutely(env.GEMINI_CONFIG_DIR))
73
+ return e
74
+ }
75
+
76
+ // The OS home the client registry expands `~/` against. LEG_HARNESS_HOME is
77
+ // the test seam: the suite points it at a throwaway directory so nothing it
78
+ // does can reach the developer's real ~/.claude, ~/.codex or ~/.gemini.
79
+ export function harnessHome(env = process.env) {
80
+ const v = env.LEG_HARNESS_HOME || env.BATON_HARNESS_HOME
81
+ return v ? absolutely(v) : homedir()
82
+ }
83
+
84
+ let rawCache = null
85
+ function rawTargets() {
86
+ if (!rawCache) rawCache = JSON.parse(readFileSync(TARGETS_FILE, 'utf8')).targets
87
+ return rawCache
88
+ }
89
+
90
+ // The expanded registry: one entry per client, `installed` read from disk.
91
+ export function registry({ env = process.env, home = harnessHome(env) } = {}) {
92
+ const targets = rawTargets()
93
+ .filter((t) => CLIENT_IDS.includes(t.id))
94
+ .map((t) => ({ ...t, ...(RENAMES[t.id] ?? {}), preamble: PREAMBLES[t.id] ?? t.preamble }))
95
+ .map((t) => envOverrides(t, env))
96
+ return engine.loadRegistry(home, { targets })
97
+ }
98
+
99
+ export function targetFor(id, opts) {
100
+ return registry(opts).find((t) => t.id === id) ?? null
101
+ }
102
+
103
+ // A Leg adapter name -> the client whose harness it runs under. Fake adapters
104
+ // emulate a real client (bin/fake-agent.mjs); the plain `fake` emulates none.
105
+ export function clientFor(adapter) {
106
+ if (!adapter) return null
107
+ const name = String(adapter).replace(/^fake-/, '')
108
+ if (name === 'fake') return null
109
+ return name
110
+ }
111
+
112
+ // Which sources exist on this machine, decided from disk: the rules file a
113
+ // user of that client necessarily has.
114
+ export function detectSources({ env = process.env, home = harnessHome(env) } = {}) {
115
+ const reg = registry({ env, home })
116
+ const out = []
117
+ for (const id of SOURCE_IDS) {
118
+ const t = reg.find((x) => x.id === id)
119
+ if (!t) continue
120
+ const rulesFile = id === 'claude' ? join(t.home, 'CLAUDE.md') : t.rulesFile
121
+ if (existsSync(rulesFile)) out.push({ id, home: t.home, rulesFile })
122
+ }
123
+ return out
124
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wes Sander
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,30 @@
1
+ {
2
+ "_comment": "Written by scripts/sync-harness-engine.mjs; never edited by hand. Every file under this directory is a byte-for-byte copy of the Agnostic AI engine at the commit below, and `npm test` fails if one drifts.",
3
+ "upstream": "https://github.com/ucsandman/Agnostic-AI",
4
+ "license": "MIT (see LICENSE beside this file)",
5
+ "commit": "7e35b5164d01fa8d738bfe1153b0fc0f7b86c23d",
6
+ "upstreamDirty": false,
7
+ "syncedAt": "2026-09-16T20:27:22.033Z",
8
+ "files": {
9
+ "LICENSE": "4bd581dc7a60a4ffc3f1ec5c8f3bed3a681f446eff6457e6051963e83da7066d",
10
+ "engine/harness/README.md": "10b4273325add7aa50f4705cd5ad3bc1361609fdfcc2b70b3bae3383a90711ba",
11
+ "engine/harness/index.cjs": "655273cdaa881b97188306f1a2b815012f0e1934890f3920d948a141e098c0e5",
12
+ "engine/harness/common.cjs": "dccf2d5c9fa035dcf63d7490195f7ef9da694d71f7397f2dfa864c1842c67fe7",
13
+ "engine/harness/bundle.cjs": "630ebc4540d32451e7a32527941ceafe7906beb65fead4e49bc8e7ac90d7df43",
14
+ "engine/harness/capture.cjs": "8a26fccd980bbcdb87b67429bde50ab01822cf918346208091921a622c30e9a1",
15
+ "engine/harness/apply.cjs": "ac15d0a4e318bd6316860c3763f8e90cc1ce1d34238ef0df168d6f7f1f3194d9",
16
+ "engine/harness/status.cjs": "748a8bfdfbc76f215bfe0091ccbdaf260cbbd3642df53d8cf8f8fb7ecd16624c",
17
+ "engine/harness/toml.cjs": "2cd95222f36d23e1b7efff27476c5a4ea8aa083e70b98752de288be6959c8bf3",
18
+ "engine/harness/sources/claude.cjs": "3818d4d3ab9a12ac920b8e3c32624490ae59a7a7500069df697b543ed4ba2506",
19
+ "engine/harness/sources/codex.cjs": "9f4668370d225a6fd4ecd0940494b3fbcfa5b2992a26a57068eb27b93b0f9d9a",
20
+ "engine/harness/targets/generic.cjs": "cc2ce7796689a82716630853217aa8ef39657e8139f4b2a74521956f453012ea",
21
+ "engine/harness/targets/claude.cjs": "21240425a80d5ae1e11707abdc97e88d83baf3af99f3a7544194b0a9b0abac3f",
22
+ "engine/harness/targets/codex.cjs": "3714cafd41ec64b402cd547f3a39edc46ee40044b564d9841bfdcc5f570b633f",
23
+ "engine/harness/targets/gemini.cjs": "dcf2bdc1d1122874c426f95bec9ff2d363dff429949b573db7cd8fb1f7835cf6",
24
+ "engine/harness/targets/agy.cjs": "e504db894caa590116c105ec3a4bc3d457753d4acbdd32218dbaa6b42d8eb000",
25
+ "engine/harness/targets/cursor.cjs": "5617e05d31c18c447136bcb8c0df3ad536018c46e65bad597eed9b7197150f5c",
26
+ "engine/hooks/shim.cjs": "83684ad537b5df456fcfaf61487fed14989877d1d16997ae49d124c4ce314f95",
27
+ "core/templates/targets.json": "bfa9787c7a998ca2eabede129fcd642ef5b2ac5d5a063099c6f70cf994c223ae",
28
+ "core/safety/guards.json": "fee1cfb1a629facdac39ff93462ae4ed573c5755f2ab9ea3422645edbe761066"
29
+ }
30
+ }