@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,247 @@
1
+ /**
2
+ * engine/harness/apply.cjs — render the captured bundle into every other client.
3
+ *
4
+ * One guarded write per file, one ComponentResult per component, one row per
5
+ * target. The source client is never written to: it is the thing being copied.
6
+ * Everything a target could not take lands in `dropped` with a reason, which is
7
+ * what `npm run explain` prints.
8
+ *
9
+ * Contract for `ctx` and ComponentResult: engine/harness/README.md.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const common = require('./common.cjs');
17
+ const bundleMod = require('./bundle.cjs');
18
+ const { loadRegistry, loadPort } = require('./capture.cjs');
19
+
20
+ const ROOT = common.ROOT;
21
+ const DEFAULT_STORAGE = path.join(ROOT, 'storage');
22
+ const TARGETS_DIR = path.join(__dirname, 'targets');
23
+
24
+ const GLYPH = { synced: '✓', written: '●', stale: '✗', skipped: '!', unsupported: '-', error: 'E', source: '=' };
25
+ const ABBR = { rules: 'ru', identity: 'id', hooks: 'ho', skills: 'sk', agents: 'ag', commands: 'cm', mcp: 'mc', permissions: 'pm' };
26
+ // Worst-first: a target's headline status is the worst thing that happened to it.
27
+ const SEVERITY = ['error', 'stale', 'skipped', 'written', 'synced', 'unsupported'];
28
+
29
+ const LEGEND = `legend: ${GLYPH.synced} synced ${GLYPH.written} written ${GLYPH.stale} stale ${GLYPH.skipped} skipped (hand-edited) ${GLYPH.unsupported} unsupported ${GLYPH.error} error ${GLYPH.source} source`;
30
+
31
+ function readState(file) {
32
+ const state = common.readJSON(file);
33
+ if (!state || typeof state !== 'object' || !state.targets) return { version: 1, targets: {} };
34
+ return state;
35
+ }
36
+
37
+ /** Load `targets/<adapter>.cjs`, naming the missing module when it is not there. */
38
+ function loadTargetAdapter(adapterId) {
39
+ const file = path.join(TARGETS_DIR, `${adapterId}.cjs`);
40
+ try {
41
+ return require(file);
42
+ } catch (err) {
43
+ if (!fs.existsSync(file)) {
44
+ throw new Error(`missing module engine/harness/targets/${adapterId}.cjs — no render adapter for "${adapterId}" (it must export { id, components, <component>(ctx) })`);
45
+ }
46
+ throw err;
47
+ }
48
+ }
49
+
50
+ /** Which registry entries this run writes to, and why. Throws on an unknown --to id. */
51
+ function selectTargets(registry, { to, port }) {
52
+ if (Array.isArray(to) && to.length) {
53
+ const known = new Set(registry.map((t) => t.id));
54
+ const unknown = to.filter((id) => !known.has(id));
55
+ if (unknown.length) throw new Error(`unknown target id(s): ${unknown.join(', ')} (known: ${registry.map((t) => t.id).join(', ')})`);
56
+ return registry.filter((t) => to.includes(t.id));
57
+ }
58
+ const policy = (port && port.targets) || 'installed';
59
+ if (Array.isArray(policy)) return registry.filter((t) => policy.includes(t.id));
60
+ if (policy === 'all') return registry.slice();
61
+ return registry.filter((t) => t.installed);
62
+ }
63
+
64
+ function rollUp(components) {
65
+ const seen = new Set(Object.values(components).map((r) => r.status));
66
+ for (const status of SEVERITY) if (seen.has(status)) return status;
67
+ return 'synced';
68
+ }
69
+
70
+ function countFiles(report) {
71
+ const totals = { written: 0, wouldWrite: 0, unchanged: 0, skipped: 0, dropped: 0 };
72
+ for (const target of Object.values(report.targets)) {
73
+ for (const result of Object.values(target.components || {})) {
74
+ for (const file of result.files || []) {
75
+ if (file.action === 'written' || file.action === 'linked') totals.written++;
76
+ else if (file.action === 'would-write' || file.action === 'would-link') totals.wouldWrite++;
77
+ else if (file.action === 'unchanged') totals.unchanged++;
78
+ else if (String(file.action || '').startsWith('skipped')) totals.skipped++;
79
+ }
80
+ totals.dropped += (result.dropped || []).length;
81
+ }
82
+ }
83
+ return totals;
84
+ }
85
+
86
+ /** The compact matrix: one row per target, one cell per component. */
87
+ function formatTable(report) {
88
+ const ids = Object.keys(report.targets);
89
+ const nameWidth = Math.max(6, ...ids.map((id) => report.targets[id].name.length));
90
+ const head = ['Target'.padEnd(nameWidth), ...bundleMod.COMPONENTS.map((c) => ABBR[c])].join(' ') + ' dropped';
91
+ const lines = [head, '-'.repeat(head.length)];
92
+ for (const id of ids) {
93
+ const t = report.targets[id];
94
+ const cells = bundleMod.COMPONENTS.map((c) => {
95
+ if (t.status === 'source') return GLYPH.source.padEnd(2);
96
+ const result = (t.components || {})[c];
97
+ return (result ? GLYPH[result.status] || '?' : GLYPH.unsupported).padEnd(2);
98
+ });
99
+ const dropped = String(t.dropped || 0).padStart(7);
100
+ const note = t.status === 'source' ? ' source of truth' : t.installed ? (t.note ? ` ${t.note}` : '') : ' not installed';
101
+ lines.push(`${t.name.padEnd(nameWidth)} ${cells.join(' ')} ${dropped}${note}`);
102
+ }
103
+ const totals = report.totals;
104
+ const verb = report.mode === 'apply' ? 'files written' : 'files would change';
105
+ const changed = report.mode === 'apply' ? totals.written : totals.wouldWrite;
106
+ lines.push('');
107
+ lines.push(`${ids.length} targets, ${changed} ${verb}, ${totals.unchanged} unchanged, ${totals.skipped} skipped, ${totals.dropped} dropped [${report.mode}]`);
108
+ lines.push(LEGEND);
109
+ return lines.join('\n');
110
+ }
111
+
112
+ /**
113
+ * apply({ bundle, bundleDir, home, port, to, check, dryRun, force, storageDir, adapters, registry, log, quiet }) -> report
114
+ *
115
+ * `adapters` maps a target id (or an adapter id) to an already-loaded module and
116
+ * replaces the require — the tests inject stubs through it. `registry` is an
117
+ * already-expanded target list for a host that ships its own.
118
+ */
119
+ function apply(options = {}) {
120
+ const {
121
+ bundleDir, home = os.homedir(), to, check = false, dryRun = false, force = false,
122
+ adapters = {}, quiet = false, registry: givenRegistry = null,
123
+ } = options;
124
+ const log = options.log || ((line) => { if (!quiet) console.log(line); });
125
+ const storageDir = options.storageDir || DEFAULT_STORAGE;
126
+
127
+ const bundle = options.bundle || bundleMod.load(bundleDir || bundleMod.DEFAULT_DIR);
128
+ if (!bundle) throw new Error('no bundle captured yet; run npm run capture');
129
+ const port = options.port || loadPort();
130
+
131
+ const registry = givenRegistry || loadRegistry(home);
132
+ const selected = selectTargets(registry, { to: Array.isArray(to) ? to : to ? String(to).split(',') : null, port });
133
+ const sourceId = bundle.manifest && bundle.manifest.source;
134
+
135
+ const stateFile = path.join(storageDir, 'harness-state.json');
136
+ const backupsDir = path.join(storageDir, 'backups');
137
+ const state = readState(stateFile);
138
+ const readOnly = Boolean(check || dryRun);
139
+
140
+ const report = {
141
+ appliedAt: new Date().toISOString(),
142
+ source: sourceId || null,
143
+ mode: check ? 'check' : dryRun ? 'dry-run' : 'apply',
144
+ bundle: {
145
+ source: sourceId || null,
146
+ capturedAt: (bundle.manifest && bundle.manifest.capturedAt) || null,
147
+ components: (bundle.manifest && bundle.manifest.components) || {},
148
+ },
149
+ targets: {},
150
+ };
151
+
152
+ for (const target of selected) {
153
+ const row = {
154
+ id: target.id,
155
+ name: target.name,
156
+ category: target.category || 'Agent',
157
+ installed: target.installed,
158
+ adapter: target.adapter || 'generic',
159
+ home: target.home || null,
160
+ status: 'synced',
161
+ components: {},
162
+ dropped: 0,
163
+ };
164
+ report.targets[target.id] = row;
165
+
166
+ if (target.id === sourceId) {
167
+ row.status = 'source';
168
+ row.note = 'source of truth; never written';
169
+ continue;
170
+ }
171
+
172
+ let adapter = adapters[target.id] || adapters[target.adapter || 'generic'];
173
+ if (!adapter) {
174
+ try {
175
+ adapter = loadTargetAdapter(target.adapter || 'generic');
176
+ } catch (err) {
177
+ row.status = 'error';
178
+ row.error = err.message;
179
+ row.note = err.message;
180
+ for (const component of bundleMod.COMPONENTS) row.components[component] = { status: 'error', files: [], dropped: [], error: err.message };
181
+ continue;
182
+ }
183
+ }
184
+
185
+ const targetState = state.targets[target.id] || (state.targets[target.id] = {});
186
+ targetState.files = targetState.files || {};
187
+ targetState.drift = targetState.drift || {};
188
+ targetState.owned = targetState.owned || {};
189
+
190
+ const ctx = {
191
+ target,
192
+ bundle,
193
+ port,
194
+ home,
195
+ // check is the "write nothing" flag adapters gate every deletion and prune
196
+ // on, so a --dry-run must set it too. dryRun stays separate: it only means
197
+ // "say more about what would change".
198
+ check: readOnly,
199
+ dryRun: Boolean(dryRun),
200
+ force: Boolean(force),
201
+ state: targetState,
202
+ write: common.makeWriter({ state: targetState, backupsDir, tag: target.id, check: readOnly, force, log }),
203
+ // a generated file about to be pruned gets the same backup an overwrite gets
204
+ backup: (file) => (fs.existsSync(file) ? common.backupFile(backupsDir, target.id, file) : null),
205
+ link: (src, dest) => common.link(src, dest, { check: readOnly }),
206
+ log,
207
+ };
208
+
209
+ for (const component of bundleMod.COMPONENTS) {
210
+ if (typeof adapter[component] !== 'function') {
211
+ row.components[component] = { status: 'unsupported', files: [], dropped: [], note: `adapter has no ${component}` };
212
+ continue;
213
+ }
214
+ let result;
215
+ try {
216
+ result = adapter[component](ctx) || {};
217
+ } catch (err) {
218
+ result = { status: 'error', error: err.message };
219
+ }
220
+ result.files = result.files || [];
221
+ result.dropped = result.dropped || [];
222
+ result.status = result.status || 'synced';
223
+ row.components[component] = result;
224
+ row.dropped += result.dropped.length;
225
+ }
226
+ row.status = rollUp(row.components);
227
+ }
228
+
229
+ report.totals = Object.assign({ targets: selected.length }, countFiles(report));
230
+ report.totals.syncedTargets = Object.values(report.targets).filter((t) => t.status === 'synced' || t.status === 'source').length;
231
+ report.stale = Object.values(report.targets).some((t) =>
232
+ Object.values(t.components || {}).some((r) =>
233
+ r.status === 'stale' || r.status === 'written' ||
234
+ (r.files || []).some((f) => f.action === 'would-write' || f.action === 'would-link')));
235
+
236
+ if (!readOnly) {
237
+ fs.mkdirSync(storageDir, { recursive: true });
238
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n', 'utf8');
239
+ }
240
+ fs.mkdirSync(storageDir, { recursive: true });
241
+ fs.writeFileSync(path.join(storageDir, 'harness-report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
242
+
243
+ log(formatTable(report));
244
+ return report;
245
+ }
246
+
247
+ module.exports = { apply, selectTargets, formatTable, loadTargetAdapter, readState, GLYPH, ABBR, LEGEND, DEFAULT_STORAGE };
@@ -0,0 +1,243 @@
1
+ /**
2
+ * engine/harness/bundle.cjs — the client-neutral harness bundle.
3
+ *
4
+ * On disk: <repo>/harness/ (gitignored by default; it holds machine paths).
5
+ * In memory: the object returned by createBundle(). See README.md for the shape.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const { ROOT, looksSecret, findSecrets, redactSecrets, urlCarriesCredential, parseFrontmatter, renderFrontmatter } = require('./common.cjs');
12
+
13
+ const BUNDLE_VERSION = '1';
14
+ const DEFAULT_DIR = path.join(ROOT, 'harness');
15
+ const COMPONENTS = ['rules', 'identity', 'hooks', 'skills', 'agents', 'commands', 'mcp', 'permissions'];
16
+ const MODEL_TIERS = ['fable', 'opus', 'sonnet', 'haiku', 'inherit'];
17
+
18
+ function createBundle(source = 'unknown', sourceHome = '') {
19
+ return {
20
+ manifest: { version: BUNDLE_VERSION, source, sourceHome, capturedAt: null, components: {} },
21
+ rules: '',
22
+ identity: '',
23
+ hooks: { dialect: 'claude', events: {} },
24
+ mcp: { servers: {} },
25
+ agents: [], // [{ name, meta: { name, description, model, tools, readonly }, body }]
26
+ commands: [], // [{ name, meta: { description, 'argument-hint' }, body }]
27
+ skills: { sourceDir: '', skills: [] }, // [{ name, path }]
28
+ permissions: { allow: [], deny: [], ask: [] },
29
+ };
30
+ }
31
+
32
+ function counts(bundle) {
33
+ const hookCount = Object.values(bundle.hooks.events || {}).reduce((n, groups) => n + groups.reduce((m, g) => m + (g.hooks || []).length, 0), 0);
34
+ return {
35
+ rules: bundle.rules ? 1 : 0,
36
+ identity: bundle.identity ? 1 : 0,
37
+ hooks: hookCount,
38
+ skills: bundle.skills.skills.length,
39
+ agents: bundle.agents.length,
40
+ commands: bundle.commands.length,
41
+ mcp: Object.keys(bundle.mcp.servers).length,
42
+ permissions: bundle.permissions.allow.length + bundle.permissions.deny.length + bundle.permissions.ask.length,
43
+ };
44
+ }
45
+
46
+ // A long value with no key context is only called a credential when it also
47
+ // has the shape of one (findSecrets covers the known prefixes).
48
+ const highLike = (v) => v.length >= 32 && !/\s/.test(v) && !/[\\/]/.test(v);
49
+
50
+ /** Structural validation. Returns an array of problems (empty = valid). */
51
+ function validate(bundle) {
52
+ const problems = [];
53
+ if (!bundle || typeof bundle !== 'object') return ['bundle is not an object'];
54
+ if (typeof bundle.rules !== 'string' || !bundle.rules.trim()) problems.push('rules.md is empty');
55
+ if (!bundle.hooks || typeof bundle.hooks.events !== 'object') problems.push('hooks.json needs an events object');
56
+ for (const [event, groups] of Object.entries((bundle.hooks && bundle.hooks.events) || {})) {
57
+ if (!Array.isArray(groups)) { problems.push(`hooks.events.${event} must be an array`); continue; }
58
+ groups.forEach((g, i) => {
59
+ if (!Array.isArray(g.hooks)) problems.push(`hooks.events.${event}[${i}].hooks must be an array`);
60
+ else g.hooks.forEach((h, j) => {
61
+ if (h.type !== 'command' || typeof h.command !== 'string' || !h.command) problems.push(`hooks.events.${event}[${i}].hooks[${j}] needs type "command" and a command string`);
62
+ });
63
+ });
64
+ }
65
+ // A credential anywhere in the bundle is a credential copied into every
66
+ // client: every free-text field and every command line is scanned, not only
67
+ // the two env maps.
68
+ if (findSecrets(bundle.rules).length) problems.push('rules.md carries a credential-shaped value');
69
+ if (findSecrets(bundle.identity).length) problems.push('identity.md carries a credential-shaped value');
70
+ for (const [event, groups] of Object.entries((bundle.hooks && bundle.hooks.events) || {})) {
71
+ if (!Array.isArray(groups)) continue;
72
+ groups.forEach((g, i) => (g.hooks || []).forEach((h, j) => {
73
+ if (h && typeof h.command === 'string' && findSecrets(h.command).length) problems.push(`hooks.events.${event}[${i}].hooks[${j}].command carries a credential-shaped value`);
74
+ }));
75
+ }
76
+ for (const [name, s] of Object.entries((bundle.mcp && bundle.mcp.servers) || {})) {
77
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) problems.push(`mcp server name "${name}" is not a safe identifier`);
78
+ for (const a of s.args || []) if (typeof a === 'string' && (findSecrets(a).length || looksSecret('arg', a) && highLike(a))) problems.push(`mcp.${name}.args carries a credential-shaped value`);
79
+ if (typeof s.url === 'string' && (findSecrets(s.url).length || urlCarriesCredential(s.url))) problems.push(`mcp.${name}.url carries a credential (userinfo, a secret query value or a token in the path)`);
80
+ if (!['stdio', 'http', 'sse'].includes(s.transport)) problems.push(`mcp.${name}.transport must be stdio, http or sse`);
81
+ if (s.transport === 'stdio' && !s.command) problems.push(`mcp.${name} (stdio) needs a command`);
82
+ if (s.transport !== 'stdio' && !s.url) problems.push(`mcp.${name} (${s.transport}) needs a url`);
83
+ for (const [k, v] of Object.entries(s.env || {})) if (looksSecret(k, v)) problems.push(`mcp.${name}.env.${k} looks like a secret; capture must replace it with \${${k}}`);
84
+ for (const [k, v] of Object.entries(s.headers || {})) if (looksSecret(k, v)) problems.push(`mcp.${name}.headers.${k} looks like a secret; capture must replace it with \${${k}}`);
85
+ }
86
+ for (const a of bundle.agents || []) {
87
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(a.name)) problems.push(`agent name "${a.name}" must be kebab-case`);
88
+ if (!a.meta || typeof a.meta.description !== 'string') problems.push(`agent ${a.name} needs a description`);
89
+ if (findSecrets(a.body).length) problems.push(`agent ${a.name} carries a credential-shaped value`);
90
+ }
91
+ for (const c of bundle.commands || []) {
92
+ if (!/^[A-Za-z0-9_-]+$/.test(c.name)) problems.push(`command name "${c.name}" is not a safe file name`);
93
+ if (findSecrets(c.body).length) problems.push(`command ${c.name} carries a credential-shaped value`);
94
+ }
95
+ for (const s of (bundle.skills && bundle.skills.skills) || []) {
96
+ if (!/^[A-Za-z0-9_.-]+$/.test(s.name) || s.name === '.' || s.name === '..') problems.push(`skill name "${s.name}" is not a safe directory name`);
97
+ if (!path.isAbsolute(s.path || '')) problems.push(`skill ${s.name} needs an absolute path`);
98
+ }
99
+ return problems;
100
+ }
101
+
102
+ /**
103
+ * Make a captured bundle saveable without losing the harness: free text is
104
+ * redacted in place, and a hook handler, an MCP argument or URL, an agent or a
105
+ * command that cannot be carried safely is dropped. Returns the warnings, one
106
+ * per change, so the operator sees exactly what did not travel and why.
107
+ * capture() calls this before save(); validate() still refuses what remains.
108
+ */
109
+ function sanitize(bundle) {
110
+ const warnings = [];
111
+ const redactText = (label, text) => {
112
+ const hits = findSecrets(text);
113
+ if (!hits.length) return text;
114
+ warnings.push(`${label}: ${hits.length} credential-shaped value(s) redacted; the bundle carries [REDACTED] instead`);
115
+ return redactSecrets(text);
116
+ };
117
+ bundle.rules = redactText('rules', bundle.rules);
118
+ bundle.identity = redactText('identity', bundle.identity);
119
+ for (const [event, groups] of Object.entries((bundle.hooks && bundle.hooks.events) || {})) {
120
+ if (!Array.isArray(groups)) continue;
121
+ for (const g of groups) {
122
+ const kept = [];
123
+ for (const h of g.hooks || []) {
124
+ if (h && typeof h.command === 'string' && findSecrets(h.command).length) warnings.push(`hooks.${event}: a handler command carries a credential-shaped value; the hook is not carried`);
125
+ else kept.push(h);
126
+ }
127
+ g.hooks = kept;
128
+ }
129
+ bundle.hooks.events[event] = groups.filter((g) => (g.hooks || []).length);
130
+ if (!bundle.hooks.events[event].length) delete bundle.hooks.events[event];
131
+ }
132
+ for (const [name, s] of Object.entries((bundle.mcp && bundle.mcp.servers) || {})) {
133
+ const badArg = (s.args || []).some((a) => typeof a === 'string' && (findSecrets(a).length || (looksSecret('arg', a) && highLike(a))));
134
+ const badUrl = typeof s.url === 'string' && (findSecrets(s.url).length || urlCarriesCredential(s.url));
135
+ if (badArg || badUrl) {
136
+ warnings.push(`mcp.${name}: ${badUrl ? 'the url' : 'an argument'} carries a credential; the server is not carried (move it to an env reference)`);
137
+ delete bundle.mcp.servers[name];
138
+ }
139
+ }
140
+ bundle.agents = (bundle.agents || []).filter((a) => {
141
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(a.name)) { warnings.push(`agent "${a.name}" is not kebab-case; not carried`); return false; }
142
+ a.body = redactText(`agent ${a.name}`, a.body);
143
+ return true;
144
+ });
145
+ bundle.commands = (bundle.commands || []).filter((c) => {
146
+ if (!/^[A-Za-z0-9_-]+$/.test(c.name)) { warnings.push(`command "${c.name}" is not a safe file name; not carried`); return false; }
147
+ c.body = redactText(`command ${c.name}`, c.body);
148
+ return true;
149
+ });
150
+ bundle.skills.skills = (bundle.skills.skills || []).filter((s) => {
151
+ if (!/^[A-Za-z0-9_.-]+$/.test(s.name) || s.name === '.' || s.name === '..') { warnings.push(`skill "${s.name}" is not a safe directory name; not carried`); return false; }
152
+ return true;
153
+ });
154
+ return warnings;
155
+ }
156
+
157
+ function canonical(v) {
158
+ if (Array.isArray(v)) return v.map(canonical);
159
+ if (v && typeof v === 'object') {
160
+ const o = {};
161
+ for (const k of Object.keys(v).sort()) o[k] = canonical(v[k]);
162
+ return o;
163
+ }
164
+ return v;
165
+ }
166
+
167
+ /**
168
+ * A content hash of everything a target renders from: the same source state
169
+ * captured twice gives the same fingerprint, whatever the capture time. The
170
+ * manifest is left out (it carries the timestamp). A host uses it to answer
171
+ * "did the harness change since the last sync" without diffing files.
172
+ */
173
+ function fingerprint(bundle) {
174
+ const body = canonical({
175
+ rules: bundle.rules, identity: bundle.identity, hooks: bundle.hooks, mcp: bundle.mcp,
176
+ skills: bundle.skills, permissions: bundle.permissions,
177
+ agents: (bundle.agents || []).map((a) => ({ name: a.name, meta: a.meta, body: a.body })),
178
+ commands: (bundle.commands || []).map((c) => ({ name: c.name, meta: c.meta, body: c.body })),
179
+ });
180
+ return crypto.createHash('sha256').update(JSON.stringify(body), 'utf8').digest('hex');
181
+ }
182
+
183
+ function save(bundle, dir = DEFAULT_DIR) {
184
+ const problems = validate(bundle);
185
+ if (problems.length) throw new Error(`refusing to save an invalid bundle:\n - ${problems.join('\n - ')}`);
186
+ bundle.manifest.capturedAt = bundle.manifest.capturedAt || new Date().toISOString();
187
+ bundle.manifest.components = counts(bundle);
188
+ bundle.manifest.fingerprint = fingerprint(bundle);
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ const w = (name, text) => fs.writeFileSync(path.join(dir, name), text, 'utf8');
191
+ w('manifest.json', JSON.stringify(bundle.manifest, null, 2) + '\n');
192
+ w('rules.md', bundle.rules.trimEnd() + '\n');
193
+ if (bundle.identity) w('identity.md', bundle.identity.trimEnd() + '\n');
194
+ else if (fs.existsSync(path.join(dir, 'identity.md'))) fs.unlinkSync(path.join(dir, 'identity.md'));
195
+ w('hooks.json', JSON.stringify(bundle.hooks, null, 2) + '\n');
196
+ w('mcp.json', JSON.stringify(bundle.mcp, null, 2) + '\n');
197
+ w('skills.json', JSON.stringify(bundle.skills, null, 2) + '\n');
198
+ w('permissions.json', JSON.stringify(bundle.permissions, null, 2) + '\n');
199
+ for (const [sub, items] of [['agents', bundle.agents], ['commands', bundle.commands]]) {
200
+ const subdir = path.join(dir, sub);
201
+ fs.rmSync(subdir, { recursive: true, force: true });
202
+ fs.mkdirSync(subdir, { recursive: true });
203
+ for (const it of items) fs.writeFileSync(path.join(subdir, `${it.name}.md`), renderFrontmatter(it.meta, it.body), 'utf8');
204
+ }
205
+ return dir;
206
+ }
207
+
208
+ function load(dir = DEFAULT_DIR) {
209
+ if (!fs.existsSync(path.join(dir, 'manifest.json'))) return null;
210
+ const read = (name, fallback) => (fs.existsSync(path.join(dir, name)) ? fs.readFileSync(path.join(dir, name), 'utf8') : fallback);
211
+ const json = (name, fallback) => { const t = read(name, null); return t == null ? fallback : JSON.parse(t); };
212
+ const bundle = createBundle();
213
+ bundle.manifest = json('manifest.json', bundle.manifest);
214
+ bundle.rules = read('rules.md', '');
215
+ bundle.identity = read('identity.md', '');
216
+ bundle.hooks = json('hooks.json', bundle.hooks);
217
+ bundle.mcp = json('mcp.json', bundle.mcp);
218
+ bundle.skills = json('skills.json', bundle.skills);
219
+ bundle.permissions = json('permissions.json', bundle.permissions);
220
+ for (const sub of ['agents', 'commands']) {
221
+ const subdir = path.join(dir, sub);
222
+ if (!fs.existsSync(subdir)) continue;
223
+ for (const f of fs.readdirSync(subdir).filter((n) => n.endsWith('.md')).sort()) {
224
+ const { meta, body } = parseFrontmatter(fs.readFileSync(path.join(subdir, f), 'utf8'));
225
+ bundle[sub].push({ name: f.replace(/\.md$/, ''), meta, body: body.trim() });
226
+ }
227
+ }
228
+ const problems = validate(bundle);
229
+ if (problems.length) throw new Error(`harness bundle at ${dir} is invalid:\n - ${problems.join('\n - ')}`);
230
+ return bundle;
231
+ }
232
+
233
+ /**
234
+ * Normalise a model reference into a tier when possible. "claude-opus-5" -> "opus",
235
+ * "sonnet" -> "sonnet", "gpt-5.6-terra" -> unchanged (raw id, targets map by ladder or pass through).
236
+ */
237
+ function modelTier(model) {
238
+ const m = String(model || 'inherit').toLowerCase();
239
+ for (const tier of MODEL_TIERS) if (m === tier || m.includes(tier)) return tier;
240
+ return model;
241
+ }
242
+
243
+ module.exports = { BUNDLE_VERSION, DEFAULT_DIR, COMPONENTS, MODEL_TIERS, createBundle, counts, validate, sanitize, save, load, modelTier, fingerprint };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * engine/harness/capture.cjs — read ONE live client into the neutral bundle.
3
+ *
4
+ * The source client is the harness the user actually drives. Everything else is
5
+ * a rendering of it. Nothing here knows a client format: the per-client reader
6
+ * is `sources/<id>.cjs` and the contract is engine/harness/README.md.
7
+ *
8
+ * Also the home of `loadRegistry()` — the target list with every path expanded
9
+ * and `installed` decided by what is on disk. apply and status both read it.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const { ROOT, expandPath } = require('./common.cjs');
17
+ const bundleMod = require('./bundle.cjs');
18
+
19
+ const TARGETS_FILE = path.join(ROOT, 'core', 'templates', 'targets.json');
20
+ const PORT_FILE = path.join(ROOT, 'core', 'port.json');
21
+ const SOURCES_DIR = path.join(__dirname, 'sources');
22
+
23
+ /** Registry fields that hold a path and therefore get expanded against `home`. */
24
+ const PATH_FIELDS = [
25
+ 'home', 'rulesFile', 'traitsFile', 'hooksConfigFile', 'skillsDir',
26
+ 'agentsDir', 'commandsDir', 'mcpConfigFile', 'permissionsFile',
27
+ ];
28
+
29
+ /**
30
+ * The target registry with every path field expanded to an absolute path and
31
+ * `installed` read from disk. A target with no `home` (the generic system card)
32
+ * is a file we own, so it is always installed.
33
+ */
34
+ function loadRegistry(home = os.homedir(), { file = TARGETS_FILE, targets = null } = {}) {
35
+ const raw = Array.isArray(targets) ? { targets } : JSON.parse(fs.readFileSync(file, 'utf8'));
36
+ return (raw.targets || []).map((t) => {
37
+ const target = Object.assign({}, t);
38
+ for (const field of PATH_FIELDS) if (t[field]) target[field] = expandPath(t[field], home);
39
+ if (Array.isArray(t.sharedSkillDirs)) target.sharedSkillDirs = t.sharedSkillDirs.map((p) => expandPath(p, home));
40
+ target.installed = t.home ? fs.existsSync(target.home) : true;
41
+ return target;
42
+ });
43
+ }
44
+
45
+ /** core/port.json — the port policy. Missing file = defaults, never a crash. */
46
+ function loadPort(file = PORT_FILE) {
47
+ try {
48
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
49
+ } catch (_) {
50
+ return { source: 'auto', targets: 'installed' };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Which client is the source of truth, decided from disk alone: the rules file
56
+ * a user of that client necessarily has. Returns null when neither is present.
57
+ */
58
+ function detectSource(home = os.homedir()) {
59
+ if (fs.existsSync(path.join(home, '.claude', 'CLAUDE.md'))) return 'claude';
60
+ if (fs.existsSync(path.join(home, '.codex', 'AGENTS.md'))) return 'codex';
61
+ return null;
62
+ }
63
+
64
+ function resolveSourceId({ from, port, home }) {
65
+ if (from) return from;
66
+ if (port && port.source && port.source !== 'auto') return port.source;
67
+ const detected = detectSource(home);
68
+ if (detected) return detected;
69
+ throw new Error('no source client found; pass --from');
70
+ }
71
+
72
+ /** Load `sources/<id>.cjs`, telling the user exactly which module is missing. */
73
+ function loadSourceAdapter(id) {
74
+ const file = path.join(SOURCES_DIR, `${id}.cjs`);
75
+ try {
76
+ return require(file);
77
+ } catch (err) {
78
+ if (!fs.existsSync(file)) {
79
+ throw new Error(`missing module engine/harness/sources/${id}.cjs — no capture adapter for source client "${id}" (it must export { id, capture({ home, target, port }) })`);
80
+ }
81
+ throw err;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * capture({ from, home, port, outDir, registry, sources }) -> { bundle, warnings, dir }
87
+ * Reads the source client and writes <repo>/harness/ (or outDir).
88
+ *
89
+ * `registry` is an already-expanded target list (loadRegistry()) for a host
90
+ * that ships its own; `sources` maps a source id to an adapter module and
91
+ * replaces the require, the way `adapters` does for apply().
92
+ */
93
+ function capture({ from, home = os.homedir(), port, outDir, registry, sources = {} } = {}) {
94
+ const policy = port || loadPort();
95
+ const id = resolveSourceId({ from, port: policy, home });
96
+ const target = (registry || loadRegistry(home)).find((t) => t.id === id);
97
+ if (!target) throw new Error(`source client "${id}" is not in the target registry`);
98
+
99
+ const adapter = sources[id] || loadSourceAdapter(id);
100
+ if (typeof adapter.capture !== 'function') throw new Error(`engine/harness/sources/${id}.cjs does not export capture()`);
101
+
102
+ const result = adapter.capture({ home, target, port: policy }) || {};
103
+ const bundle = result.bundle;
104
+ if (!bundle) throw new Error(`engine/harness/sources/${id}.cjs returned no bundle`);
105
+ const warnings = result.warnings || [];
106
+ // Whatever the adapter kept, a credential does not travel: free text is
107
+ // redacted, an unsafe handler or server or a malformed item is dropped with
108
+ // a warning, and save() still refuses anything that slipped through.
109
+ warnings.push(...bundleMod.sanitize(bundle));
110
+
111
+ bundle.manifest.source = bundle.manifest.source && bundle.manifest.source !== 'unknown' ? bundle.manifest.source : id;
112
+ bundle.manifest.sourceHome = bundle.manifest.sourceHome || (target.home || home);
113
+ bundle.manifest.capturedAt = new Date().toISOString();
114
+
115
+ const dir = bundleMod.save(bundle, outDir || bundleMod.DEFAULT_DIR);
116
+ return { bundle, warnings, dir };
117
+ }
118
+
119
+ module.exports = { capture, detectSource, resolveSourceId, loadRegistry, loadPort, TARGETS_FILE, PORT_FILE };