@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,171 @@
1
+ /**
2
+ * engine/harness/status.cjs — what every client actually looks like right now.
3
+ *
4
+ * Reads the targets from disk (an apply run in check mode: nothing is written)
5
+ * and reports per component whether it is in sync. Nothing is asserted from
6
+ * memory; a claim here always came from a file this process just read.
7
+ *
8
+ * `renderHtml(report)` is the human surface: one standalone page, no assets.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const bundleMod = require('./bundle.cjs');
16
+ const { apply, DEFAULT_STORAGE } = require('./apply.cjs');
17
+
18
+ const NO_BUNDLE = 'no bundle captured yet; run npm run capture';
19
+
20
+ /**
21
+ * status({ home, port, storageDir, json, explain, bundleDir, html, quiet })
22
+ * -> { syncState, targets, report }
23
+ * syncState is 'synced' | 'stale' | 'error' | 'no-bundle'.
24
+ */
25
+ function status(options = {}) {
26
+ const {
27
+ home = os.homedir(), port, to, bundleDir, json = false, explain = false,
28
+ html = false, quiet = false, adapters, registry,
29
+ } = options;
30
+ const storageDir = options.storageDir || DEFAULT_STORAGE;
31
+ const log = options.log || ((line) => { if (!quiet && !json) console.log(line); });
32
+
33
+ const bundle = options.bundle || bundleMod.load(bundleDir || bundleMod.DEFAULT_DIR);
34
+ if (!bundle) {
35
+ log(NO_BUNDLE);
36
+ return { syncState: 'no-bundle', targets: {}, report: null, message: NO_BUNDLE };
37
+ }
38
+
39
+ const report = apply({ bundle, home, port, to, check: true, storageDir, adapters, registry, log });
40
+ const anyError = Object.values(report.targets).some((t) => t.status === 'error');
41
+ const syncState = anyError ? 'error' : report.stale ? 'stale' : 'synced';
42
+ report.syncState = syncState;
43
+
44
+ if (explain) log(formatDropped(report));
45
+ if (html) {
46
+ const file = path.join(storageDir, 'harness-status.html');
47
+ fs.mkdirSync(storageDir, { recursive: true });
48
+ fs.writeFileSync(file, renderHtml(report), 'utf8');
49
+ report.htmlFile = file;
50
+ log(`status page: ${file}`);
51
+ }
52
+ if (json) console.log(JSON.stringify(report, null, 2));
53
+
54
+ return { syncState, targets: report.targets, report };
55
+ }
56
+
57
+ /** Every dropped item from a report, grouped per target, with its reason. */
58
+ function formatDropped(report) {
59
+ const lines = [];
60
+ let total = 0;
61
+ for (const target of Object.values(report.targets)) {
62
+ const items = [];
63
+ for (const [component, result] of Object.entries(target.components || {})) {
64
+ for (const drop of result.dropped || []) items.push(` ${component}: ${drop.item} — ${drop.reason}`);
65
+ }
66
+ if (!items.length) continue;
67
+ total += items.length;
68
+ lines.push(` ${target.name} (${items.length})`);
69
+ lines.push(...items);
70
+ }
71
+ lines.unshift(`Dropped items: ${total} across ${Object.keys(report.targets).length} targets`);
72
+ if (!total) lines.push(' (nothing was dropped)');
73
+ return lines.join('\n');
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // HTML status page — standalone, no external assets, light and dark.
78
+ // ---------------------------------------------------------------------------
79
+ const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
80
+
81
+ const STATUS_CLASS = { synced: 'ok', written: 'ok', source: 'src', stale: 'bad', skipped: 'warn', unsupported: 'muted', error: 'bad' };
82
+ const CELL = { synced: '✓', written: '●', stale: '✗', skipped: '!', unsupported: '–', error: 'E', source: '=' };
83
+
84
+ const PAGE_CSS = `
85
+ :root { color-scheme: light dark; --bg:#ffffff; --surface:#f6f8fa; --border:#d0d7de; --text:#1f2328; --muted:#656d76; --ok:#1a7f37; --bad:#cf222e; --warn:#9a6700; --accent:#0969da; }
86
+ @media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#e6edf3; --muted:#8b949e; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --accent:#58a6ff; } }
87
+ * { box-sizing: border-box; margin: 0; padding: 0; }
88
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; padding: 28px 24px; }
89
+ .wrap { max-width: 1100px; margin: 0 auto; }
90
+ h1 { font-size: 22px; font-weight: 650; }
91
+ h2 { font-size: 15px; font-weight: 650; margin: 28px 0 10px; }
92
+ .lede { color: var(--muted); margin-top: 6px; font-size: 14px; }
93
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; margin-top: 20px; }
94
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
95
+ .card .n { font-weight: 650; }
96
+ .card .d { font-size: 12px; color: var(--muted); margin-top: 4px; font-family: ui-monospace, Consolas, monospace; word-break: break-all; }
97
+ .pill { float: right; font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: .4px; padding: 1px 8px; border-radius: 10px; border: 1px solid currentColor; }
98
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
99
+ th, td { border-bottom: 1px solid var(--border); padding: 6px 8px; text-align: left; }
100
+ th { color: var(--muted); font-weight: 550; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; }
101
+ td.c { text-align: center; font-size: 15px; }
102
+ .ok { color: var(--ok); } .bad { color: var(--bad); } .warn { color: var(--warn); } .muted { color: var(--muted); } .src { color: var(--accent); }
103
+ .legend, .foot { color: var(--muted); font-size: 12px; margin-top: 10px; }
104
+ code, pre { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
105
+ pre { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; overflow-x: auto; }
106
+ ul { margin-left: 18px; font-size: 13px; }
107
+ li { margin: 2px 0; }
108
+ `;
109
+
110
+ function renderHtml(report) {
111
+ const targets = Object.values(report.targets || {});
112
+ const totals = report.totals || {};
113
+ const supported = (t) => Object.values(t.components || {}).filter((r) => r.status !== 'unsupported');
114
+ const syncedOf = (t) => supported(t).filter((r) => r.status === 'synced').length;
115
+
116
+ const cards = targets.map((t) => {
117
+ const cls = STATUS_CLASS[t.status] || 'muted';
118
+ const detail = t.status === 'source'
119
+ ? 'source of truth — never written to'
120
+ : `${syncedOf(t)}/${supported(t).length} components synced · ${t.dropped || 0} dropped`;
121
+ return `<div class="card"><span class="pill ${cls}">${esc(t.status)}</span><div class="n">${esc(t.name)}</div>` +
122
+ `<div class="d">${esc(t.home || t.adapter)}</div>` +
123
+ `<div class="d">${t.installed ? 'installed' : 'not installed'} · ${detail}</div></div>`;
124
+ }).join('\n');
125
+
126
+ const rows = targets.map((t) => {
127
+ const cells = bundleMod.COMPONENTS.map((c) => {
128
+ if (t.status === 'source') return `<td class="c src" title="source of truth">${CELL.source}</td>`;
129
+ const r = (t.components || {})[c] || { status: 'unsupported' };
130
+ const title = [r.note, r.error, `${(r.files || []).length} files`].filter(Boolean).join(' — ');
131
+ return `<td class="c ${STATUS_CLASS[r.status] || 'muted'}" title="${esc(r.status)}${title ? ': ' + esc(title) : ''}">${CELL[r.status] || '?'}</td>`;
132
+ }).join('');
133
+ return `<tr><td>${esc(t.name)}</td>${cells}<td>${t.dropped || 0}</td></tr>`;
134
+ }).join('\n');
135
+
136
+ const droppedBlocks = targets.map((t) => {
137
+ const items = [];
138
+ for (const [component, r] of Object.entries(t.components || {})) {
139
+ for (const d of r.dropped || []) items.push(`<li><code>${esc(component)}</code> ${esc(d.item)} — ${esc(d.reason)}</li>`);
140
+ }
141
+ return items.length ? `<h2>${esc(t.name)} — ${items.length} dropped</h2><ul>${items.join('')}</ul>` : '';
142
+ }).filter(Boolean).join('\n') || '<p class="foot">Nothing was dropped.</p>';
143
+
144
+ const b = report.bundle || {};
145
+ const counts = Object.entries(b.components || {}).map(([k, v]) => `${k} ${v}`).join(' · ') || 'none';
146
+
147
+ return `<!DOCTYPE html>
148
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
149
+ <title>Agnostic harness — port status</title><style>${PAGE_CSS}</style></head>
150
+ <body><div class="wrap">
151
+ <h1>Harness port status</h1>
152
+ <p class="lede">Your <strong>${esc(b.source || 'unknown')}</strong> harness, copied into every other AI coding client on this machine. Each cell says whether that client's rules, hooks, skills, agents, commands, MCP servers and permissions match it.</p>
153
+ <p class="foot">${targets.length} targets · ${totals.wouldWrite || 0} files out of date · ${totals.unchanged || 0} already in sync · ${totals.dropped || 0} dropped · checked ${esc(report.appliedAt)}</p>
154
+ <div class="cards">${cards}</div>
155
+ <h2>Component matrix</h2>
156
+ <table><thead><tr><th>Client</th>${bundleMod.COMPONENTS.map((c) => `<th>${esc(c)}</th>`).join('')}<th>dropped</th></tr></thead><tbody>${rows}</tbody></table>
157
+ <p class="legend">✓ in sync · ● written · ✗ out of date · ! skipped (hand-edited, needs --force) · – client has no such surface · E error · = the source client</p>
158
+ <h2>What was not ported</h2>
159
+ ${droppedBlocks}
160
+ <h2>Bundle</h2>
161
+ <p class="foot">source <code>${esc(b.source || '?')}</code> · captured ${esc(b.capturedAt || 'never')} · ${esc(counts)}</p>
162
+ <h2>Regenerate</h2>
163
+ <pre>npm run capture # re-read the source client
164
+ npm run port # capture, then write every target
165
+ npm run port:check # report only, write nothing
166
+ npm run explain # every dropped item and why</pre>
167
+ </div></body></html>
168
+ `;
169
+ }
170
+
171
+ module.exports = { status, renderHtml, formatDropped, NO_BUNDLE };
@@ -0,0 +1,113 @@
1
+ /**
2
+ * engine/harness/targets/agy.cjs — Antigravity CLI (`agy`).
3
+ *
4
+ * Rules land in ~/.gemini/GEMINI.md, which the Gemini CLI target writes too:
5
+ * the registry gives both the same rulesFile and the same preamble, so whichever
6
+ * adapter runs first writes the file and the other sees it unchanged.
7
+ *
8
+ * Hooks are agy's own dialect. The file format is verified on this machine from
9
+ * the CLI's own shipped documentation:
10
+ * ~/.gemini/antigravity-cli/builtin/skills/agy-customizations/docs/hooks.md
11
+ * "The `hooks.json` file is a JSON object where each top-level key is a hook
12
+ * name, mapping to its event configuration." PreToolUse and PostToolUse are
13
+ * *grouped* (a `matcher` + `hooks` wrapper); PreInvocation, PostInvocation and
14
+ * Stop are *flat* lists of handler objects. Each handler is
15
+ * `{ type?: "command", command, timeout?: seconds }`.
16
+ *
17
+ * That one top-level key is also the ownership boundary: the harness owns the
18
+ * key `agnostic-ai` and nothing else in the file, so a hook another plugin or
19
+ * the operator registered under its own name survives every run untouched.
20
+ *
21
+ * Every ported guard runs through engine/hooks/shim.cjs, which speaks agy's
22
+ * camelCase protojson payloads and its allow/deny/ask/force_ask decisions.
23
+ * agy merges the results of several hooks for one event and the LAST reason
24
+ * wins, so the guards for one event are chained inside ONE entry with `++`.
25
+ */
26
+
27
+ const common = require('../common.cjs');
28
+ const generic = require('./generic.cjs');
29
+
30
+ const I = generic._internals;
31
+
32
+ /** The single top-level key in hooks.json that this harness owns: the configured brand id. */
33
+ const hookName = () => common.config.brand.id;
34
+
35
+ const EVENT_MAP = {
36
+ PreToolUse: 'PreToolUse',
37
+ PostToolUse: 'PostToolUse',
38
+ UserPromptSubmit: 'PreInvocation',
39
+ Stop: 'Stop',
40
+ };
41
+
42
+ // Only these two carry a matcher/hooks wrapper; the rest are flat handler lists.
43
+ const GROUPED = new Set(['PreToolUse', 'PostToolUse']);
44
+
45
+ // Claude tool token -> agy tool name (the reverse of the adapter's own map).
46
+ const MATCHER_MAP = {
47
+ Bash: 'run_command', PowerShell: 'run_command',
48
+ Write: 'write_to_file',
49
+ Edit: 'replace_file_content', MultiEdit: 'replace_file_content', NotebookEdit: 'replace_file_content',
50
+ Read: 'view_file', Grep: 'grep_search', Glob: 'list_dir',
51
+ Agent: 'invoke_subagent', Task: 'invoke_subagent', Workflow: 'invoke_subagent',
52
+ WebFetch: 'read_url_content', WebSearch: 'search_web',
53
+ };
54
+
55
+ function hooks(ctx) {
56
+ const file = I.at(ctx, ctx.target.hooksConfigFile);
57
+ if (!file) return I.unsupported('no hooks config file in the target registry');
58
+ const config = I.readUserJson(file);
59
+ if (config === null) return { status: 'error', files: [{ path: file, action: 'unchanged' }], dropped: [], error: `${common.tildePath(file, ctx.home)} is not valid JSON; nothing was written` };
60
+
61
+ const dropped = [];
62
+ const buckets = new Map(); // `${agyEvent} ${matcher ?? ''}` -> { event, matcher, handlers }
63
+
64
+ for (const [event, groups] of Object.entries(ctx.bundle.hooks.events || {})) {
65
+ const agyEvent = EVENT_MAP[event];
66
+ if (!agyEvent) { dropped.push({ item: `hook event ${event}`, reason: 'no Antigravity event (it has only PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop)' }); continue; }
67
+ for (const group of groups || []) {
68
+ const { kept, dropped: gone } = I.portedHandlers(ctx, event, group);
69
+ dropped.push(...gone);
70
+ if (!kept.length) continue;
71
+ let matcher;
72
+ if (GROUPED.has(agyEvent)) {
73
+ matcher = I.translateMatcher(group.matcher, MATCHER_MAP, { keepUnknown: true });
74
+ if (matcher === null) { dropped.push({ item: `${event} [${group.matcher}]`, reason: 'no Antigravity tool behind this matcher' }); continue; }
75
+ }
76
+ const key = `${agyEvent} ${matcher === undefined ? '' : matcher}`;
77
+ if (!buckets.has(key)) buckets.set(key, { event: agyEvent, matcher, handlers: [] });
78
+ buckets.get(key).handlers.push(...kept);
79
+ }
80
+ }
81
+
82
+ const block = {};
83
+ for (const { event, matcher, handlers } of buckets.values()) {
84
+ const handler = { type: 'command', command: I.shimCommand('agy', event, handlers.map((h) => h.command)) };
85
+ const timeout = I.chainTimeout(handlers);
86
+ if (timeout !== undefined) handler.timeout = timeout;
87
+ block[event] = block[event] || [];
88
+ // Grouped events wrap the handler; flat events take it directly.
89
+ block[event].push(GROUPED.has(event) ? (matcher === undefined ? { hooks: [handler] } : { matcher, hooks: [handler] }) : handler);
90
+ }
91
+ for (const [event, groups] of Object.entries(I.extraGroups(ctx))) {
92
+ (block[event] = block[event] || []).push(...groups);
93
+ }
94
+
95
+ // A previous installer wrote a flat `{"preToolUse": "<command>"}` shape here.
96
+ // It is not ours, so it is left byte for byte and only reported.
97
+ for (const [name, value] of Object.entries(config)) {
98
+ if (name === hookName()) continue;
99
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
100
+ dropped.push({ item: `hooks.json key "${name}"`, reason: 'not a named-hook object (legacy installer format); left untouched because the harness does not own it' });
101
+ }
102
+ }
103
+
104
+ I.owned(ctx, 'hooks'); // initialise this target's ownership record
105
+ if (Object.keys(block).length) config[hookName()] = block;
106
+ else delete config[hookName()];
107
+ const res = I.writeUserJson(ctx, file, config);
108
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id].hooks = Object.keys(block).length ? [hookName()] : [];
109
+ const entries = Object.values(block).reduce((n, list) => n + list.length, 0);
110
+ return I.result(ctx, [{ path: file, action: res.action }], dropped, `${entries} chained entr(ies) under the "${hookName()}" hook`);
111
+ }
112
+
113
+ module.exports = Object.assign({}, generic, { id: 'agy', hooks });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * engine/harness/targets/claude.cjs — Claude Code as a TARGET.
3
+ *
4
+ * The reverse direction: a harness captured from another client (Codex, say)
5
+ * rendered back into ~/.claude. Claude Code's dialect is the bundle's own
6
+ * dialect, so hooks and matcher tokens need no shim and almost no translation.
7
+ *
8
+ * Surfaces written here:
9
+ * ~/.claude/agnostic-rules.md the agreement (plus the @import line in CLAUDE.md)
10
+ * ~/.claude/SOUL.md identity
11
+ * ~/.claude/settings.json hooks + permissions (managed keys, user keys kept)
12
+ * ~/.claude.json mcpServers
13
+ * ~/.claude/agents/*.md subagents
14
+ * ~/.claude/commands/*.md slash commands
15
+ * ~/.claude/skills/<name> junctions to the captured skills
16
+ */
17
+
18
+ const path = require('path');
19
+ const common = require('../common.cjs');
20
+ const bundleLib = require('../bundle.cjs');
21
+ const generic = require('./generic.cjs');
22
+
23
+ const I = generic._internals;
24
+
25
+ // Codex renames the edit tool; every other Claude matcher token round-trips.
26
+ const MATCHER_MAP = { apply_patch: 'Edit|Write', spawn_agent: 'Agent', wait_agent: 'Agent' };
27
+
28
+ /** `@~/.claude/agnostic-rules.md` — present in any spelling that resolves to the rules file. */
29
+ function importsRules(text, rulesFile, home) {
30
+ const want = path.resolve(rulesFile).toLowerCase();
31
+ for (const line of String(text || '').split(/\r?\n/)) {
32
+ const m = /^@(\S+)\s*$/.exec(line.trim());
33
+ if (m && path.resolve(common.expandPath(m[1], home)).toLowerCase() === want) return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function rules(ctx) {
39
+ const base = generic.rules(ctx);
40
+ if (base.status === 'unsupported' || base.status === 'error') return base;
41
+
42
+ const rulesFile = I.at(ctx, ctx.target.rulesFile);
43
+ // beside the client's configured home (CLAUDE_CONFIG_DIR moves it), not a hardcoded ~/.claude
44
+ const claudeMd = path.join(I.at(ctx, ctx.target.home) || path.join(ctx.home, '.claude'), 'CLAUDE.md');
45
+ const importLine = `@${common.tildePath(rulesFile, ctx.home)}`;
46
+ const existing = common.readText(claudeMd);
47
+
48
+ if (existing !== null && importsRules(existing, rulesFile, ctx.home)) {
49
+ base.files.push({ path: claudeMd, action: 'unchanged' });
50
+ return I.result(ctx, base.files, base.dropped, base.note);
51
+ }
52
+ // Never rewrite what is already there: append the import, or create the
53
+ // smallest file that loads the agreement.
54
+ const next = existing === null
55
+ ? `# CLAUDE.md\n\n${importLine}\n`
56
+ : `${existing.replace(/\s*$/, '')}\n\n${importLine}\n`;
57
+ const res = ctx.write(claudeMd, next, { region: true });
58
+ base.files.push({ path: claudeMd, action: res.action });
59
+ return I.result(ctx, base.files, base.dropped, `added ${importLine} to CLAUDE.md`);
60
+ }
61
+
62
+ function hooks(ctx) {
63
+ const file = I.at(ctx, ctx.target.hooksConfigFile);
64
+ if (!file) return I.unsupported('no hooks config file in the target registry');
65
+ const settings = I.readUserJson(file);
66
+ if (settings === null) return { status: 'error', files: [{ path: file, action: 'unchanged' }], dropped: [], error: `${common.tildePath(file, ctx.home)} is not valid JSON; nothing was written` };
67
+
68
+ const dropped = [];
69
+ const next = {};
70
+ for (const [event, groups] of Object.entries(ctx.bundle.hooks.events || {})) {
71
+ for (const group of groups || []) {
72
+ const { kept, dropped: gone } = I.portedHandlers(ctx, event, group);
73
+ dropped.push(...gone);
74
+ if (!kept.length) continue;
75
+ const matcher = I.translateMatcher(group.matcher, MATCHER_MAP, { keepUnknown: true });
76
+ if (matcher === null) { dropped.push({ item: `${event} [${group.matcher}]`, reason: 'every tool token in the matcher was dropped' }); continue; }
77
+ const out = matcher === undefined ? { hooks: kept } : { matcher, hooks: kept };
78
+ (next[event] = next[event] || []).push(out);
79
+ }
80
+ }
81
+ for (const [event, groups] of Object.entries(I.extraGroups(ctx))) {
82
+ (next[event] = next[event] || []).push(...groups);
83
+ }
84
+
85
+ settings.hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {};
86
+ const keys = I.applyOwnedGroups(settings.hooks, I.owned(ctx, 'hooks'), next);
87
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
88
+ const res = I.writeUserJson(ctx, file, settings);
89
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id].hooks = keys;
90
+ return I.result(ctx, [{ path: file, action: res.action }], dropped, `${keys.length} hook group(s) in settings.json`);
91
+ }
92
+
93
+ function permissions(ctx) {
94
+ const file = I.at(ctx, ctx.target.hooksConfigFile);
95
+ if (!file) return I.unsupported('no settings.json in the target registry');
96
+ const settings = I.readUserJson(file);
97
+ if (settings === null) return { status: 'error', files: [{ path: file, action: 'unchanged' }], dropped: [], error: `${common.tildePath(file, ctx.home)} is not valid JSON; nothing was written` };
98
+
99
+ settings.permissions = settings.permissions && typeof settings.permissions === 'object' ? settings.permissions : {};
100
+ const previous = new Set(I.owned(ctx, 'permissions'));
101
+ const keys = [];
102
+ for (const bucket of ['allow', 'deny', 'ask']) {
103
+ const wanted = ctx.bundle.permissions[bucket] || [];
104
+ const current = Array.isArray(settings.permissions[bucket]) ? settings.permissions[bucket] : [];
105
+ // Drop what we put there last time, keep everything the user added, re-add ours.
106
+ const merged = current.filter((e) => !previous.has(`${bucket}:${e}`));
107
+ for (const entry of wanted) {
108
+ if (!merged.includes(entry)) merged.push(entry);
109
+ keys.push(`${bucket}:${entry}`);
110
+ }
111
+ if (merged.length) settings.permissions[bucket] = merged;
112
+ else delete settings.permissions[bucket];
113
+ }
114
+ if (!Object.keys(settings.permissions).length) delete settings.permissions;
115
+ const res = I.writeUserJson(ctx, file, settings);
116
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id].permissions = keys;
117
+ return I.result(ctx, [{ path: file, action: res.action }], [], `${keys.length} permission entr(ies)`);
118
+ }
119
+
120
+ // Claude Code enforces read-only through the `tools` list, not through a flag.
121
+ // A source that only knows a sandbox mode (Codex: sandbox_mode = "read-only")
122
+ // therefore has to be given a concrete list, or the ported agent would silently
123
+ // gain write tools it did not have. This is the set this repo's own read-only
124
+ // agents carry.
125
+ const READONLY_TOOLS = 'Read, Grep, Glob';
126
+
127
+ function agents(ctx) {
128
+ const dir = I.at(ctx, ctx.target.agentsDir);
129
+ if (!dir) return I.unsupported('client has no subagent directory');
130
+ let derived = 0;
131
+ const { files, dropped } = I.renderMarkdownDir(ctx, 'agents', dir, ctx.bundle.agents, (a) => {
132
+ const readonly = String(a.meta.readonly) === 'true';
133
+ if (readonly && !a.meta.tools) derived++;
134
+ return {
135
+ name: a.name,
136
+ description: a.meta.description,
137
+ model: bundleLib.modelTier(a.meta.model || 'inherit'),
138
+ ...(a.meta.tools ? { tools: a.meta.tools } : readonly ? { tools: READONLY_TOOLS } : {}),
139
+ ...(readonly ? { readonly: true } : {}),
140
+ };
141
+ });
142
+ return I.result(ctx, files, dropped, derived ? `${derived} read-only agent(s) given the tools list "${READONLY_TOOLS}"` : undefined);
143
+ }
144
+
145
+ function commands(ctx) {
146
+ const dir = I.at(ctx, ctx.target.commandsDir);
147
+ if (!dir) return I.unsupported('client has no slash-command directory');
148
+ const { files, dropped } = I.renderMarkdownDir(ctx, 'commands', dir, ctx.bundle.commands, (c) => ({
149
+ description: c.meta.description,
150
+ ...(c.meta['argument-hint'] ? { 'argument-hint': c.meta['argument-hint'] } : {}),
151
+ }));
152
+ return I.result(ctx, files, dropped);
153
+ }
154
+
155
+ module.exports = Object.assign({}, generic, {
156
+ id: 'claude',
157
+ rules, hooks, permissions, agents, commands,
158
+ });