@ucsandman/legcli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/NOTICE +8 -0
  3. package/README.md +639 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +43 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +205 -0
  8. package/docs/README.md +5 -1
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +22 -0
  11. package/docs/board-guide.md +33 -1
  12. package/docs/cli-contracts.md +36 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +23 -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/docs/history.md +172 -0
  19. package/docs/runtime-tap.md +156 -0
  20. package/fixtures/verified.json +1 -1
  21. package/package.json +7 -3
  22. package/scripts/build-docs-site.mjs +18 -4
  23. package/scripts/check-branding.mjs +118 -0
  24. package/scripts/check-claims.mjs +1 -1
  25. package/scripts/license-sign.mjs +1 -1
  26. package/scripts/limits-table.mjs +1 -1
  27. package/scripts/live-limits.mjs +1 -1
  28. package/scripts/npm-publish-gate.mjs +114 -0
  29. package/scripts/probe.mjs +4 -3
  30. package/scripts/seed-fake-cards.mjs +4 -3
  31. package/scripts/seed-floor-board.mjs +5 -4
  32. package/scripts/seed-wes-board.mjs +5 -4
  33. package/scripts/stripe-setup.mjs +1 -1
  34. package/scripts/sync-harness-engine.mjs +159 -0
  35. package/scripts/sync-leg-agents.mjs +127 -0
  36. package/src/accounts.mjs +6 -4
  37. package/src/adapters/codex.mjs +1 -1
  38. package/src/attach.mjs +125 -23
  39. package/src/auth.mjs +2 -2
  40. package/src/board/board.css +23 -1
  41. package/src/board/board.js +17 -5
  42. package/src/board/history.js +377 -0
  43. package/src/board/index.html +33 -0
  44. package/src/board/sessions.js +95 -7
  45. package/src/bundle.mjs +54 -8
  46. package/src/chain.mjs +1 -1
  47. package/src/contract.mjs +4 -3
  48. package/src/fsx.mjs +5 -2
  49. package/src/handoff.mjs +6 -6
  50. package/src/harness/cli.mjs +281 -0
  51. package/src/harness/fingerprint.mjs +68 -0
  52. package/src/harness/index.mjs +407 -0
  53. package/src/harness/registry.mjs +124 -0
  54. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  55. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  56. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  57. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  74. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  75. package/src/history/cli.mjs +159 -0
  76. package/src/history/common.mjs +119 -0
  77. package/src/history/index.mjs +429 -0
  78. package/src/history/providers/agy.mjs +91 -0
  79. package/src/history/providers/claude.mjs +161 -0
  80. package/src/history/providers/codex.mjs +133 -0
  81. package/src/history/providers/copilot.mjs +94 -0
  82. package/src/history/providers/grok.mjs +138 -0
  83. package/src/history/worktrees.mjs +116 -0
  84. package/src/hook.mjs +49 -49
  85. package/src/land.mjs +7 -35
  86. package/src/launcher.mjs +38 -26
  87. package/src/ledger.mjs +6 -6
  88. package/src/license.mjs +10 -9
  89. package/src/live-capture.mjs +1 -1
  90. package/src/mergequeue.mjs +5 -5
  91. package/src/orchestrator.mjs +28 -4
  92. package/src/preferences.mjs +37 -3
  93. package/src/redact.mjs +24 -6
  94. package/src/resume.mjs +17 -15
  95. package/src/runner.mjs +2 -2
  96. package/src/scheduler.mjs +1 -1
  97. package/src/server.mjs +224 -18
  98. package/src/session-detail.mjs +15 -1
  99. package/src/sessions.mjs +15 -3
  100. package/src/share.mjs +2 -2
  101. package/src/stations/agent.mjs +1 -1
  102. package/src/sync/dashclaw.mjs +4 -4
  103. package/src/synthesis.mjs +165 -0
  104. package/src/taps/agy.mjs +2 -2
  105. package/src/taps/claude-usage.mjs +1 -1
  106. package/src/taps/claude.mjs +177 -170
  107. package/src/taps/codex.mjs +286 -286
  108. package/src/taps/grok.mjs +2 -2
  109. package/src/taps/mod.mjs +340 -0
  110. package/src/trust.mjs +205 -36
  111. package/src/usage.mjs +5 -1
  112. package/src/worktree.mjs +6 -5
  113. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  114. package/fixtures/live/agy/err.log +0 -0
  115. package/fixtures/live/agy/out.log +0 -1
  116. package/fixtures/live/agy/supervisor.log +0 -2
  117. package/fixtures/live/claude/err.log +0 -0
  118. package/fixtures/live/claude/out.log +0 -1
  119. package/fixtures/live/claude/supervisor.log +0 -2
  120. package/fixtures/live/codex/err.log +0 -1
  121. package/fixtures/live/codex/out.log +0 -8
  122. package/fixtures/live/codex/supervisor.log +0 -2
  123. package/fixtures/live/grok/err.log +0 -32
  124. package/fixtures/live/grok/out.log +0 -7
  125. package/fixtures/live/grok/supervisor.log +0 -2
@@ -0,0 +1,832 @@
1
+ /**
2
+ * engine/harness/targets/codex.cjs — render the bundle into the Codex CLI.
3
+ *
4
+ * Codex adopted a near-clone of Claude Code's hook dialect, so most of the work
5
+ * is a rename (Claude tool tokens -> Codex tool tokens) plus one thing no other
6
+ * target needs: Codex refuses to run a hook it has not been shown in `/hooks`,
7
+ * unless config.toml already carries a `[hooks.state]` entry whose `trusted_hash`
8
+ * matches Codex's own hash of that hook's identity. Reproducing that hash is what
9
+ * makes a ported harness live on the next run instead of after a manual review;
10
+ * `selfTestTrustHash()` keeps a value Codex itself wrote as the proof that the
11
+ * scheme has not changed under us.
12
+ *
13
+ * config.toml is the user's file. Every write here happens inside a marked region
14
+ * (hooks, skills, mcp); everything outside is preserved.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+ const common = require('../common.cjs');
21
+ const toml = require('../toml.cjs');
22
+ const { stripSections } = common;
23
+
24
+ const ID = 'codex';
25
+ const COMPONENTS = ['rules', 'identity', 'hooks', 'skills', 'agents', 'commands', 'mcp', 'permissions'];
26
+
27
+ // Events Codex 0.153 exposes (developers.openai.com/codex/hooks, read 2026-09-05).
28
+ const CODEX_EVENTS = new Set([
29
+ 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse',
30
+ 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt',
31
+ ]);
32
+
33
+ // Claude tool names -> Codex matcher tokens. `null` drops the token; a token that
34
+ // is not listed passes through untouched (Codex-native names, `mcp__.*`, regexes).
35
+ const TOOL_MAP = {
36
+ Bash: 'Bash', PowerShell: 'Bash',
37
+ Edit: 'Edit', Write: 'Write', MultiEdit: 'Edit', NotebookEdit: 'Edit',
38
+ Agent: 'Agent', Task: 'Agent', Workflow: 'Agent',
39
+ Read: null, Glob: null, Grep: null, TaskStop: null, WebFetch: null, WebSearch: null,
40
+ };
41
+
42
+ const HANDLER_KEYS = ['type', 'command', 'timeout', 'statusMessage', 'async', 'additionalContextLimit'];
43
+
44
+ // The personal predecessor of this adapter (~/.claude/tools/harness-sync/sync.cjs)
45
+ // wrote its own regions into the same file. Remove them so the two do not fight.
46
+ const LEGACY_HOOK_REGION = /\n*# >>> harness-sync hook trust start[\s\S]*?# <<< harness-sync hook trust end\n?/g;
47
+ const LEGACY_SKILL_REGION = /\n*# >>> harness-sync skills start[\s\S]*?# <<< harness-sync skills end\n?/g;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Small shared helpers
51
+ // ---------------------------------------------------------------------------
52
+ const readText = common.readText;
53
+ const isTrue = (v) => v === true || v === 'true'; // frontmatter round-trips booleans as strings
54
+ const tildeOf = (p, ctx) => common.tildePath(p, ctx.home);
55
+ /** Both --check and --dry-run mean "touch nothing"; ctx.write already knows, direct fs calls do not. */
56
+ const readOnly = (ctx) => Boolean(ctx.check || ctx.dryRun);
57
+
58
+ function statusOf(files, extra = []) {
59
+ const actions = files.map((f) => f.action).concat(extra);
60
+ // A real directory we refuse to replace is a steady state (reported in
61
+ // `dropped`), not a hand edit: only a hand-edited file makes the component "skipped".
62
+ if (actions.some((a) => a === 'skipped-hand-edited')) return 'skipped';
63
+ if (actions.some((a) => a === 'would-write' || a === 'would-link' || a === 'would-prune' || a === 'would-remove')) return 'stale';
64
+ if (actions.some((a) => a === 'written' || a === 'linked' || a === 'pruned' || a === 'removed')) return 'written';
65
+ return 'synced';
66
+ }
67
+
68
+ /** A user-supplied exclusion regex must not crash the port; treat a bad one as a literal. */
69
+ function matcherFor(pattern) {
70
+ try { return new RegExp(pattern); } catch (_) { return { test: (s) => String(s).includes(pattern) }; }
71
+ }
72
+
73
+ /** The script a hook command runs, for the human-readable list in AGENTS.md. */
74
+ function scriptName(command) {
75
+ const m = String(command).match(/([A-Za-z0-9_-]+)\.(?:cjs|mjs|js|ps1|py|sh)\b/);
76
+ return m ? m[1] : String(command).trim().split(/\s+/)[0];
77
+ }
78
+
79
+ function readdirSafe(dir) {
80
+ try { return fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; }
81
+ }
82
+
83
+ /** `ctx.state.owned.codex.<kind>`, created on demand. */
84
+ function ownedList(ctx, kind) {
85
+ ctx.state.owned = ctx.state.owned || {};
86
+ ctx.state.owned[ID] = ctx.state.owned[ID] || {};
87
+ const current = ctx.state.owned[ID][kind];
88
+ return Array.isArray(current) ? current : [];
89
+ }
90
+ function setOwned(ctx, kind, names) {
91
+ ctx.state.owned = ctx.state.owned || {};
92
+ ctx.state.owned[ID] = ctx.state.owned[ID] || {};
93
+ ctx.state.owned[ID][kind] = names.slice().sort();
94
+ }
95
+
96
+ /**
97
+ * Put `body` in the marked region of `text`, cleaning what `clean` names outside it.
98
+ *
99
+ * common.replaceRegion always appends, and three components (hooks, skills, mcp)
100
+ * share config.toml: re-appending an unchanged region would reorder the file on
101
+ * every run and the port would never report "synced". So a region that already
102
+ * holds exactly what we would write, in a file that needs no cleaning, is left
103
+ * where it is.
104
+ */
105
+ function upsertRegion(text, markers, body, clean) {
106
+ const strip = (t) => common.replaceRegion(t, markers, '');
107
+ const stripped = strip(text);
108
+ const base = clean ? clean(stripped) : stripped;
109
+ const existing = common.readRegion(text, markers);
110
+ const desired = String(body).trimEnd();
111
+ const current = existing === null ? '' : existing.trimEnd();
112
+ if (current === desired && base === stripped) return text;
113
+ return common.replaceRegion(base, markers, body);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Hook translation (shared by rules() and hooks(): AGENTS.md lists exactly the
118
+ // hooks config.toml will carry, so the two can never disagree)
119
+ // ---------------------------------------------------------------------------
120
+ function translateMatcher(matcher) {
121
+ if (matcher == null || matcher === '' || matcher === '*') return undefined; // omit = match all
122
+ const out = [];
123
+ for (const token of String(matcher).split('|')) {
124
+ const mapped = Object.prototype.hasOwnProperty.call(TOOL_MAP, token) ? TOOL_MAP[token] : token;
125
+ if (mapped && !out.includes(mapped)) out.push(mapped);
126
+ }
127
+ return out.length ? out.join('|') : null; // null = every token dropped, drop the group
128
+ }
129
+
130
+ function translateHooks(ctx) {
131
+ const events = {};
132
+ const dropped = [];
133
+ const kept = [];
134
+ const excludes = ((ctx.port && ctx.port.hooks && ctx.port.hooks.exclude) || [])
135
+ .filter((e) => e && e.match)
136
+ .map((e) => ({ re: matcherFor(e.match), reason: e.reason || `excluded by core/port.json (${e.match})` }));
137
+
138
+ for (const [event, groups] of Object.entries((ctx.bundle.hooks && ctx.bundle.hooks.events) || {})) {
139
+ if (!CODEX_EVENTS.has(event)) {
140
+ dropped.push({ item: `hook event ${event}`, reason: 'no such Codex event' });
141
+ continue;
142
+ }
143
+ for (const group of groups || []) {
144
+ const matcher = translateMatcher(group.matcher);
145
+ if (matcher === null) {
146
+ dropped.push({ item: `${event} [${group.matcher}]`, reason: 'no Codex tool behind this matcher' });
147
+ continue;
148
+ }
149
+ const handlers = [];
150
+ for (const h of (group && group.hooks) || []) {
151
+ if (!h || h.type !== 'command' || !h.command) continue;
152
+ const ex = excludes.find((e) => e.re.test(h.command));
153
+ if (ex) {
154
+ dropped.push({ item: `${event}: ${scriptName(h.command)}`, reason: ex.reason });
155
+ continue;
156
+ }
157
+ const out = {};
158
+ for (const key of HANDLER_KEYS) if (h[key] !== undefined && h[key] !== null) out[key] = h[key];
159
+ // A `~/` inside a hook command is expanded here (port.json stays
160
+ // machine-neutral; Codex does not expand it on Windows).
161
+ if (typeof out.command === 'string') out.command = out.command.replace(/(^|["'\s])~\//g, `$1${String(ctx.home).replace(/\\/g, '/')}/`);
162
+ out.type = 'command';
163
+ handlers.push(out);
164
+ kept.push(h.command);
165
+ }
166
+ if (!handlers.length) continue;
167
+ (events[event] = events[event] || []).push(matcher === undefined ? { hooks: handlers } : { matcher, hooks: handlers });
168
+ }
169
+ }
170
+
171
+ // Target-only hooks from core/port.json, already in the Codex dialect.
172
+ const extra = (ctx.port && ctx.port.hooks && ctx.port.hooks.extra && ctx.port.hooks.extra[ID]) || {};
173
+ for (const [event, groups] of Object.entries(extra)) {
174
+ if (!CODEX_EVENTS.has(event)) {
175
+ dropped.push({ item: `port.json hooks.extra.${ID}.${event}`, reason: 'no such Codex event' });
176
+ continue;
177
+ }
178
+ for (const group of groups || []) {
179
+ const handlers = [];
180
+ for (const h of (group && group.hooks) || []) {
181
+ if (!h || h.type !== 'command' || !h.command) continue;
182
+ const out = {};
183
+ for (const key of HANDLER_KEYS) if (h[key] !== undefined && h[key] !== null) out[key] = h[key];
184
+ // A `~/` inside a hook command is expanded here (port.json stays
185
+ // machine-neutral; Codex does not expand it on Windows).
186
+ if (typeof out.command === 'string') out.command = out.command.replace(/(^|["'\s])~\//g, `$1${String(ctx.home).replace(/\\/g, '/')}/`);
187
+ out.type = 'command';
188
+ handlers.push(out);
189
+ kept.push(h.command);
190
+ }
191
+ if (!handlers.length) continue;
192
+ (events[event] = events[event] || []).push(group.matcher != null && group.matcher !== ''
193
+ ? { matcher: group.matcher, hooks: handlers }
194
+ : { hooks: handlers });
195
+ }
196
+ }
197
+
198
+ return { events, dropped, names: [...new Set(kept.map(scriptName))].sort() };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Trust hashes
203
+ //
204
+ // Codex records trust per hook as sha256 over a normalized identity
205
+ // (codex-rs/hooks/src/engine/discovery.rs::hook_hash, tag rust-v0.153.4):
206
+ // identity = { event_name: <snake label>, matcher?, hooks: [normalized handler] }
207
+ // normalized handler = { type:"command", command, async, timeout (default 600),
208
+ // statusMessage?, additionalContextLimit? (omitted when 2500) }
209
+ // hash = sha256( canonical JSON: keys sorted recursively, compact )
210
+ // ---------------------------------------------------------------------------
211
+ const EVENT_LABEL = {
212
+ PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use',
213
+ PreCompact: 'pre_compact', PostCompact: 'post_compact', SessionStart: 'session_start', SessionEnd: 'session_end',
214
+ UserPromptSubmit: 'user_prompt_submit', SubagentStart: 'subagent_start', SubagentStop: 'subagent_stop',
215
+ Stop: 'stop', Interrupt: 'interrupt',
216
+ };
217
+ const NO_MATCHER_EVENTS = new Set(['UserPromptSubmit', 'Stop', 'Interrupt']);
218
+ const CONTEXT_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'SubagentStart']);
219
+
220
+ function canonical(v) {
221
+ if (Array.isArray(v)) return v.map(canonical);
222
+ if (v && typeof v === 'object') {
223
+ const o = {};
224
+ for (const k of Object.keys(v).sort()) o[k] = canonical(v[k]);
225
+ return o;
226
+ }
227
+ return v;
228
+ }
229
+
230
+ function hookHash(event, matcher, h) {
231
+ let timeout = h.timeout == null ? 600 : Number(h.timeout);
232
+ if (event === 'SessionEnd' || event === 'Interrupt') timeout = Math.min(Math.max(h.timeout == null ? 1 : Number(h.timeout), 1), 3);
233
+ else timeout = Math.max(timeout, 1);
234
+ const handler = { type: 'command', command: h.command, async: !!h.async, timeout };
235
+ if (h.statusMessage) handler.statusMessage = h.statusMessage;
236
+ if (CONTEXT_EVENTS.has(event) && h.additionalContextLimit != null && h.additionalContextLimit !== 2500) {
237
+ handler.additionalContextLimit = h.additionalContextLimit;
238
+ }
239
+ const identity = { event_name: EVENT_LABEL[event], hooks: [handler] };
240
+ if (!NO_MATCHER_EVENTS.has(event) && matcher != null) identity.matcher = matcher;
241
+ return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(canonical(identity))).digest('hex');
242
+ }
243
+
244
+ /**
245
+ * The proof that the scheme above still matches Codex's. The constant is a hash
246
+ * Codex itself wrote into config.toml on 2026-09-05 for the hook described here;
247
+ * it is a test vector, not configuration. When this fails, the hooks are still
248
+ * written but the trust entries are withheld, because a wrong hash is worse than
249
+ * no hash: Codex would silently never run them.
250
+ */
251
+ function selfTestTrustHash() {
252
+ const known = hookHash('PreToolUse', 'Bash|Edit|Write|MultiEdit|apply_patch|mcp__.*', {
253
+ command: 'node "C:/Projects/agnostic-ai/engine/hooks/dashclaw-guard.cjs"',
254
+ timeout: 60,
255
+ });
256
+ return known === 'sha256:ada757977119bf80c0f1c6fecb2e0ce58394b725fe690d40dcd2a4fbc41f4f9c';
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // rules
261
+ // ---------------------------------------------------------------------------
262
+ const TOOL_TABLE = [
263
+ '| Rule says | In Codex |',
264
+ '|---|---|',
265
+ '| Bash / PowerShell | `shell` (`Bash` at the hook layer, `shell_command` in transcripts) |',
266
+ '| Edit / Write / MultiEdit | `apply_patch` |',
267
+ '| Read / Grep / Glob | `shell` (`cat`, `rg`, `ls`) |',
268
+ '| Agent / Task | `spawn_agent` with `agent_type` |',
269
+ '| Workflow | no equivalent; fan out with `spawn_agent` and collect with `wait_agent` |',
270
+ '| Artifact | no equivalent; write a file and say where it is |',
271
+ ];
272
+
273
+ /** Codex's own model line, read from config.toml so nothing is asserted from memory. */
274
+ function codexModelLine(configFile) {
275
+ const text = readText(configFile);
276
+ if (text == null) return { model: '(unset)', effort: '(unset)' };
277
+ const { data } = toml.parse(text);
278
+ return {
279
+ model: typeof data.model === 'string' && data.model ? data.model : '(unset)',
280
+ effort: typeof data.model_reasoning_effort === 'string' && data.model_reasoning_effort ? data.model_reasoning_effort : '(unset)',
281
+ };
282
+ }
283
+
284
+ function differencesSection(ctx) {
285
+ const configFile = ctx.target.hooksConfigFile || path.join(ctx.target.home, 'config.toml');
286
+ const { model, effort } = codexModelLine(configFile);
287
+ const hooks = translateHooks(ctx).names;
288
+ const agents = (ctx.bundle.agents || []).map((a) => `\`${a.name}\``);
289
+ const prompts = (ctx.bundle.commands || []).map((c) => `\`/prompts:${c.name}\``);
290
+ const list = (items) => (items.length ? items.join(', ') : '(none)');
291
+ const skillsLine = ctx.target.skillsDir && ctx.bundle.skills && ctx.bundle.skills.sourceDir
292
+ ? `Skills are not copied: each one is linked from \`${tildeOf(ctx.bundle.skills.sourceDir, ctx)}\` into \`${tildeOf(ctx.target.skillsDir, ctx)}\`, so an edit at the source is live here immediately.`
293
+ : 'Skills are not ported to this client.';
294
+ return [
295
+ '## How this harness differs from the source',
296
+ '',
297
+ 'The safety layer is shared, not reimplemented: the hook scripts below are the',
298
+ 'same files the source client runs, generated into',
299
+ `\`${tildeOf(configFile, ctx)}\` and pre-trusted, so a fix lands in both at once.`,
300
+ '',
301
+ 'Tool-name mapping when a rule below names a source tool:',
302
+ '',
303
+ ...TOOL_TABLE,
304
+ '',
305
+ `Model: Codex runs \`${model}\` at \`${effort}\` per \`${tildeOf(configFile, ctx)}\` (read at`,
306
+ 'generation time, never asserted from memory). Verify a model id resolves before',
307
+ 'writing it anywhere; a wrong id crashes the run.',
308
+ '',
309
+ `Hooks active in this harness: ${list(hooks.map((n) => `\`${n}\``))}.`,
310
+ '',
311
+ `Subagents (\`spawn_agent\` with \`agent_type\`): ${list(agents)}.`,
312
+ '',
313
+ `Slash commands: ${list(prompts)}.`,
314
+ '',
315
+ skillsLine,
316
+ ].join('\n');
317
+ }
318
+
319
+ function rules(ctx) {
320
+ try {
321
+ const target = ctx.target;
322
+ if (!target.rulesFile) return { status: 'unsupported', files: [], dropped: [], note: 'no rulesFile in the target registry' };
323
+ const preamble = String(target.preamble || '').trimEnd();
324
+ const header = preamble.split(/\r?\n/, 1)[0].trim();
325
+ const dropSections = (ctx.port && ctx.port.rules && ctx.port.rules.dropSectionsForTargets) || [];
326
+ const body = stripSections(ctx.bundle.rules || '', dropSections);
327
+ const source = (ctx.bundle.manifest && ctx.bundle.manifest.source) || 'source';
328
+
329
+ const parts = [preamble, '', differencesSection(ctx), '', '---', '', '# The agreement', '', body];
330
+ // A target-specific addendum (core/port.json rules.addenda.<id> -> a markdown
331
+ // file in the repo) carries guidance that only makes sense in this client.
332
+ const addendumRel = ctx.port && ctx.port.rules && ctx.port.rules.addenda && ctx.port.rules.addenda[ID];
333
+ const addendum = addendumRel ? readText(path.resolve((ctx.port && ctx.port.baseDir) || common.ROOT, addendumRel)) : null;
334
+ if (addendum && addendum.trim()) parts.push('', '---', '', addendum.trim());
335
+ if (ctx.bundle.identity) parts.push('', '---', '', '# Identity', '', String(ctx.bundle.identity).trim());
336
+ parts.push('', `<!-- ${common.GENERATED_MARK} from the ${source} harness -->`, '');
337
+ const content = parts.join('\n');
338
+
339
+ const res = ctx.write(target.rulesFile, content, { header });
340
+ const dropped = dropSections.map((s) => ({ item: `rules section "${s}"`, reason: 'core/port.json rules.dropSectionsForTargets' }));
341
+ return {
342
+ status: statusOf([{ path: target.rulesFile, action: res.action }]),
343
+ files: [{ path: target.rulesFile, action: res.action }],
344
+ dropped,
345
+ note: `identity ${ctx.bundle.identity ? 'inlined' : 'absent'}, ${dropSections.length} section(s) dropped`,
346
+ };
347
+ } catch (err) {
348
+ return { status: 'error', files: [], dropped: [], error: err.message };
349
+ }
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // identity — Codex has no separate identity file
354
+ // ---------------------------------------------------------------------------
355
+ function identity() {
356
+ return { status: 'synced', files: [], dropped: [], note: 'inlined in AGENTS.md' };
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // hooks
361
+ // ---------------------------------------------------------------------------
362
+ function renderHookTables(events) {
363
+ const lines = [];
364
+ for (const [event, groups] of Object.entries(events)) {
365
+ for (const group of groups) {
366
+ lines.push(`[[hooks.${event}]]`);
367
+ if (group.matcher != null) lines.push(`matcher = ${common.tomlStr(group.matcher)}`);
368
+ for (const h of group.hooks) {
369
+ lines.push(`[[hooks.${event}.hooks]]`);
370
+ for (const key of HANDLER_KEYS) {
371
+ const v = h[key];
372
+ if (v === undefined || v === null) continue;
373
+ lines.push(`${key} = ${typeof v === 'string' ? common.tomlStr(v) : String(v)}`);
374
+ }
375
+ }
376
+ lines.push('');
377
+ }
378
+ }
379
+ return lines;
380
+ }
381
+
382
+ /** Codex writes the state key as a literal path; quote it the way TOML allows. */
383
+ function stateKey(configFile, event, gi, hi) {
384
+ const key = `${configFile}:${EVENT_LABEL[event]}:${gi}:${hi}`;
385
+ return key.includes("'") ? common.tomlStr(key) : `'${key}'`;
386
+ }
387
+
388
+ /**
389
+ * Strip `[hooks.state.'<this config file>:...']` blocks that live outside our
390
+ * region — left by Codex itself or by an older sync. A block is its header plus
391
+ * the non-blank, non-table lines under it, so neighbouring tables survive.
392
+ * Codex writes the path double-quoted with escaped separators, the predecessor
393
+ * wrote it single-quoted and literal; both forms are matched.
394
+ */
395
+ function strayStateRegex(configFile) {
396
+ const forms = [...new Set([configFile, configFile.replace(/\\/g, '\\\\')])].map(common.escapeRe);
397
+ return new RegExp(`\\[hooks\\.state\\.(?:'|")(?:${forms.join('|')}):[^'"]*(?:'|")\\]\\r?\\n(?:[^\\n\\[][^\\n]*\\r?\\n?)*`, 'g');
398
+ }
399
+
400
+ function hooks(ctx) {
401
+ try {
402
+ const configFile = ctx.target.hooksConfigFile;
403
+ if (!configFile) return { status: 'unsupported', files: [], dropped: [], note: 'no hooksConfigFile in the target registry' };
404
+ const { events, dropped, names } = translateHooks(ctx);
405
+ const notes = [];
406
+
407
+ const trusted = selfTestTrustHash();
408
+ if (!trusted) {
409
+ notes.push('trust-hash self-test failed: Codex changed its hook identity scheme; open /hooks in Codex to trust them');
410
+ }
411
+
412
+ const entries = [];
413
+ if (trusted) {
414
+ for (const [event, groups] of Object.entries(events)) {
415
+ groups.forEach((group, gi) => group.hooks.forEach((h, hi) => {
416
+ entries.push(`[hooks.state.${stateKey(configFile, event, gi, hi)}]\nenabled = true\ntrusted_hash = "${hookHash(event, group.matcher, h)}"`);
417
+ }));
418
+ }
419
+ }
420
+
421
+ const body = [...renderHookTables(events), ...(entries.length ? [entries.join('\n\n')] : [])].join('\n').trimEnd();
422
+ const text = readText(configFile) || '';
423
+ const stray = strayStateRegex(configFile);
424
+ if (LEGACY_HOOK_REGION.test(text)) {
425
+ LEGACY_HOOK_REGION.lastIndex = 0;
426
+ dropped.push({ item: 'harness-sync hook trust region', reason: 'replaced legacy harness-sync region' });
427
+ }
428
+ LEGACY_HOOK_REGION.lastIndex = 0;
429
+ const clean = (t) => t.replace(LEGACY_HOOK_REGION, '\n').replace(stray, '');
430
+ const next = upsertRegion(text, common.regionMarkers('hooks'), body, clean);
431
+
432
+ const res = ctx.write(configFile, next, { region: true });
433
+ const files = [{ path: configFile, action: res.action }];
434
+
435
+ // Codex warns when hooks are configured in two places. Archiving is the
436
+ // operator's call: a rename here could disable a hook they still rely on.
437
+ const legacyJson = path.join(ctx.target.home, 'hooks.json');
438
+ const legacy = common.readJSON(legacyJson);
439
+ if (legacy && typeof legacy === 'object' && legacy.hooks && typeof legacy.hooks === 'object') {
440
+ dropped.push({ item: tildeOf(legacyJson, ctx), reason: 'legacy hooks.json present: Codex warns when hooks live in both files; archive it by hand' });
441
+ files.push({ path: legacyJson, action: 'inspected' });
442
+ }
443
+
444
+ const count = Object.values(events).reduce((n, g) => n + g.reduce((m, x) => m + x.hooks.length, 0), 0);
445
+ notes.push(`${count} hook(s) in ${Object.keys(events).length} event(s): ${names.join(', ') || '(none)'}${trusted ? `, ${entries.length} pre-trusted` : ''}`);
446
+ return { status: statusOf(files), files, dropped, note: notes.join(' | ') };
447
+ } catch (err) {
448
+ return { status: 'error', files: [], dropped: [], error: err.message };
449
+ }
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // skills
454
+ // ---------------------------------------------------------------------------
455
+ /**
456
+ * Real (non-link) skill directories under the Codex skills dir that duplicate a
457
+ * skill Codex already reads elsewhere, plus anything core/port.json names in
458
+ * skills.codexDisable. Codex re-sends its whole skill catalog every turn, so a
459
+ * skill listed twice is paid for twice.
460
+ */
461
+ function duplicateSkillPaths(ctx) {
462
+ const found = [];
463
+ const skillsDir = ctx.target.skillsDir;
464
+ const roots = [ctx.bundle.skills && ctx.bundle.skills.sourceDir, ...(ctx.target.sharedSkillDirs || [])].filter(Boolean);
465
+ for (const e of readdirSafe(skillsDir)) {
466
+ if (e.name.startsWith('.') || e.isSymbolicLink() || !e.isDirectory()) continue;
467
+ const own = path.join(skillsDir, e.name, 'SKILL.md');
468
+ if (!fs.existsSync(own)) continue;
469
+ if (roots.some((r) => fs.existsSync(path.join(r, e.name, 'SKILL.md')))) found.push(own);
470
+ }
471
+ for (const pattern of (ctx.port && ctx.port.skills && ctx.port.skills.codexDisable) || []) {
472
+ let candidates = [ctx.target.home];
473
+ for (const part of String(pattern).split('/')) {
474
+ candidates = candidates.flatMap((base) => (part === '*'
475
+ ? readdirSafe(base).map((e) => path.join(base, e.name))
476
+ : [path.join(base, part)]));
477
+ }
478
+ for (const c of candidates) if (fs.existsSync(c)) found.push(c);
479
+ }
480
+ return [...new Set(found)].sort();
481
+ }
482
+
483
+ function skills(ctx) {
484
+ try {
485
+ const skillsDir = ctx.target.skillsDir;
486
+ if (!skillsDir) return { status: 'unsupported', files: [], dropped: [], note: 'no skillsDir in the target registry' };
487
+ // A whole-directory junction (what the legacy sync created) would send every
488
+ // per-skill link into someone else's directory. Refuse and say so.
489
+ const dirLink = common.readLinkTarget(skillsDir);
490
+ if (dirLink !== null) {
491
+ return {
492
+ status: 'skipped',
493
+ files: [{ path: skillsDir, action: 'skipped-real-directory' }],
494
+ dropped: [{ item: `skills dir ${tildeOf(skillsDir, ctx)}`, reason: `it is a link to ${dirLink}; per-skill links would land there` }],
495
+ note: `skills dir is a link to ${dirLink}; remove that link to get per-skill links`,
496
+ };
497
+ }
498
+ const dropped = [];
499
+ const files = [];
500
+ const exclude = (ctx.port && ctx.port.skills && ctx.port.skills.exclude) || {};
501
+ const shared = ctx.target.sharedSkillDirs || [];
502
+
503
+ const wanted = [];
504
+ for (const skill of (ctx.bundle.skills && ctx.bundle.skills.skills) || []) {
505
+ if (exclude[skill.name]) { dropped.push({ item: `skill ${skill.name}`, reason: exclude[skill.name] }); continue; }
506
+ const nativeDir = shared.find((d) => fs.existsSync(path.join(d, skill.name, 'SKILL.md')));
507
+ if (nativeDir) {
508
+ dropped.push({ item: `skill ${skill.name}`, reason: `already in the shared skills dir ${tildeOf(nativeDir, ctx)}, which Codex reads natively; linking it again would list it twice` });
509
+ continue;
510
+ }
511
+ wanted.push(skill);
512
+ }
513
+
514
+ const dry = readOnly(ctx);
515
+ if (!dry) fs.mkdirSync(skillsDir, { recursive: true });
516
+ const linked = [];
517
+ for (const skill of wanted) {
518
+ const dest = path.join(skillsDir, skill.name);
519
+ const res = common.link(skill.path, dest, { check: dry });
520
+ files.push({ path: dest, action: res.action });
521
+ if (res.action === 'skipped-real-directory') {
522
+ dropped.push({ item: `skill ${skill.name}`, reason: `a real directory already lives at ${tildeOf(dest, ctx)}; it was left alone` });
523
+ continue;
524
+ }
525
+ linked.push(skill.name);
526
+ }
527
+
528
+ // Prune only links this adapter created: a real directory or someone else's
529
+ // link is never touched, whatever its name.
530
+ const wantedNames = new Set(wanted.map((s) => s.name));
531
+ const keep = new Set(linked);
532
+ for (const name of ownedList(ctx, 'skills')) {
533
+ if (keep.has(name)) continue;
534
+ const dest = path.join(skillsDir, name);
535
+ const target = common.readLinkTarget(dest);
536
+ if (target === null) continue; // gone, or no longer a link: not ours to remove
537
+ const reason = wantedNames.has(name) ? 'dangling' : 'no longer wanted';
538
+ if (dry) { files.push({ path: dest, action: 'would-prune' }); continue; }
539
+ if (!fs.existsSync(target) || !wantedNames.has(name)) {
540
+ fs.rmSync(dest, { recursive: true, force: true });
541
+ files.push({ path: dest, action: 'pruned' });
542
+ dropped.push({ item: `skill link ${name}`, reason: `pruned (${reason})` });
543
+ }
544
+ }
545
+ if (!dry) setOwned(ctx, 'skills', linked);
546
+
547
+ // Duplicate-disable region in config.toml.
548
+ const configFile = ctx.target.hooksConfigFile;
549
+ let disabled = [];
550
+ if (configFile && readText(configFile) != null) {
551
+ disabled = duplicateSkillPaths(ctx);
552
+ const body = disabled.map((p) => `[[skills.config]]\npath = ${common.tomlStr(p)}\nenabled = false`).join('\n\n');
553
+ const text = readText(configFile) || '';
554
+ if (LEGACY_SKILL_REGION.test(text)) {
555
+ LEGACY_SKILL_REGION.lastIndex = 0;
556
+ dropped.push({ item: 'harness-sync skills region', reason: 'replaced legacy harness-sync region' });
557
+ }
558
+ LEGACY_SKILL_REGION.lastIndex = 0;
559
+ const next = upsertRegion(text, common.regionMarkers('skills'), body, (t) => t.replace(LEGACY_SKILL_REGION, '\n'));
560
+ const res = ctx.write(configFile, next, { region: true });
561
+ files.push({ path: configFile, action: res.action });
562
+ }
563
+
564
+ return {
565
+ status: statusOf(files),
566
+ files,
567
+ dropped,
568
+ note: `${linked.length} linked, ${dropped.length} not ported, ${disabled.length} duplicate(s) disabled in config.toml`,
569
+ };
570
+ } catch (err) {
571
+ return { status: 'error', files: [], dropped: [], error: err.message };
572
+ }
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // agents
577
+ // ---------------------------------------------------------------------------
578
+ function agentHeader(name, source, model, effort, readonly) {
579
+ const modelLine = model
580
+ ? `in Codex the model is fixed by this file (${model}, ${effort}).`
581
+ : 'in Codex this agent inherits the main loop\'s model.';
582
+ return `Generated by agnostic-ai from the ${source} harness agent ${name}. Codex port: "Agent tool"/"subagent_type" `
583
+ + 'means spawn_agent with agent_type; Bash/PowerShell is the shell tool; Edit/Write is apply_patch; '
584
+ + `Read/Grep/Glob are shell reads. Rules referring to a model guard describe the source harness; ${modelLine}`
585
+ + (readonly ? ' This agent is read-only (sandbox_mode = "read-only").' : '');
586
+ }
587
+
588
+ function agents(ctx) {
589
+ try {
590
+ const dir = ctx.target.agentsDir;
591
+ if (!dir) return { status: 'unsupported', files: [], dropped: [], note: 'no agentsDir in the target registry' };
592
+ const ladder = (ctx.port && ctx.port.agents && ctx.port.agents.modelLadder && ctx.port.agents.modelLadder[ID]) || {};
593
+ const source = (ctx.bundle.manifest && ctx.bundle.manifest.source) || 'source';
594
+ const files = [];
595
+ const dropped = [];
596
+ const written = [];
597
+ const slugs = new Set();
598
+
599
+ for (const agent of ctx.bundle.agents || []) {
600
+ const meta = agent.meta || {};
601
+ const requested = meta.model || 'inherit';
602
+ let model = null;
603
+ let effort = null;
604
+ if (requested !== 'inherit') {
605
+ if (ladder[requested]) [model, effort] = ladder[requested];
606
+ else { model = requested; effort = 'medium'; } // a raw model id passes through
607
+ }
608
+ if (model) slugs.add(model);
609
+ const readonly = isTrue(meta.readonly);
610
+ const lines = [
611
+ `# ${common.GENERATED_MARK} from ${source} agent ${agent.name}`,
612
+ `name = ${common.tomlStr(agent.name)}`,
613
+ `description = ${common.tomlStr(meta.description || agent.name)}`,
614
+ ];
615
+ if (model) {
616
+ lines.push(`model = ${common.tomlStr(model)}`);
617
+ lines.push(`model_reasoning_effort = ${common.tomlStr(effort)}`);
618
+ }
619
+ if (readonly) lines.push('sandbox_mode = "read-only"');
620
+ lines.push(`developer_instructions = ${common.tomlMultiline(`${agentHeader(agent.name, source, model, effort, readonly)}\n\n${String(agent.body).trim()}`)}`);
621
+ lines.push('');
622
+ const file = path.join(dir, `${agent.name}.toml`);
623
+ const res = ctx.write(file, lines.join('\n'), { header: `# ${common.GENERATED_MARK} from ${source} agent ${agent.name}` });
624
+ files.push({ path: file, action: res.action });
625
+ if (res.action !== 'skipped-hand-edited') written.push(`${agent.name}.toml`);
626
+ }
627
+
628
+ // Prune agent files this adapter wrote whose source agent is gone.
629
+ const keep = new Set(ctx.bundle.agents.map((a) => `${a.name}.toml`));
630
+ for (const name of ownedList(ctx, 'agents')) {
631
+ if (keep.has(name)) continue;
632
+ const file = path.join(dir, name);
633
+ if (!fs.existsSync(file)) continue;
634
+ if (readOnly(ctx)) { files.push({ path: file, action: 'would-remove' }); continue; }
635
+ if (ctx.backup) ctx.backup(file);
636
+ fs.unlinkSync(file);
637
+ delete ctx.state.files[file];
638
+ files.push({ path: file, action: 'removed' });
639
+ dropped.push({ item: `agent ${name}`, reason: 'no longer in the bundle; the generated file was removed (backed up)' });
640
+ }
641
+ if (!readOnly(ctx)) setOwned(ctx, 'agents', written);
642
+
643
+ // A model id Codex cannot resolve crashes the spawn, so say so before it runs.
644
+ const notes = [`${written.length} agent(s)`];
645
+ const cache = readText(path.join(ctx.target.home, 'models_cache.json'));
646
+ if (cache != null) {
647
+ const unknown = [...slugs].filter((s) => !cache.includes(s));
648
+ if (unknown.length) notes.push(`model id(s) not in models_cache.json: ${unknown.join(', ')}`);
649
+ }
650
+ return { status: statusOf(files), files, dropped, note: notes.join(' | ') };
651
+ } catch (err) {
652
+ return { status: 'error', files: [], dropped: [], error: err.message };
653
+ }
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // commands -> ~/.codex/prompts/<name>.md, invoked as /prompts:<name>
658
+ // ---------------------------------------------------------------------------
659
+ function commands(ctx) {
660
+ try {
661
+ const dir = ctx.target.commandsDir;
662
+ if (!dir) return { status: 'unsupported', files: [], dropped: [], note: 'no commandsDir in the target registry' };
663
+ const files = [];
664
+ const dropped = [];
665
+ const written = [];
666
+
667
+ for (const command of ctx.bundle.commands || []) {
668
+ const meta = {};
669
+ if (command.meta && command.meta.description) meta.description = command.meta.description;
670
+ if (command.meta && command.meta['argument-hint']) meta['argument-hint'] = command.meta['argument-hint'];
671
+ const file = path.join(dir, `${command.name}.md`);
672
+ const res = ctx.write(file, common.renderFrontmatter(meta, command.body));
673
+ files.push({ path: file, action: res.action });
674
+ if (res.action !== 'skipped-hand-edited') written.push(`${command.name}.md`);
675
+ }
676
+
677
+ const keep = new Set(ctx.bundle.commands.map((c) => `${c.name}.md`));
678
+ for (const name of ownedList(ctx, 'commands')) {
679
+ if (keep.has(name)) continue;
680
+ const file = path.join(dir, name);
681
+ if (!fs.existsSync(file)) continue;
682
+ if (readOnly(ctx)) { files.push({ path: file, action: 'would-remove' }); continue; }
683
+ if (ctx.backup) ctx.backup(file);
684
+ fs.unlinkSync(file);
685
+ delete ctx.state.files[file];
686
+ files.push({ path: file, action: 'removed' });
687
+ dropped.push({ item: `command ${name}`, reason: 'no longer in the bundle; the generated file was removed (backed up)' });
688
+ }
689
+ if (!readOnly(ctx)) setOwned(ctx, 'commands', written);
690
+
691
+ return { status: statusOf(files), files, dropped, note: `${written.length} prompt(s) as /prompts:<name>` };
692
+ } catch (err) {
693
+ return { status: 'error', files: [], dropped: [], error: err.message };
694
+ }
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // mcp
699
+ // ---------------------------------------------------------------------------
700
+ const BARE_KEY = /^[A-Za-z0-9_-]+$/;
701
+ const tomlKey = (k) => (BARE_KEY.test(k) ? k : common.tomlStr(k));
702
+ const envRef = (v) => (typeof v === 'string' ? (v.match(/^\$\{([A-Za-z0-9_]+)\}$/) || [])[1] : undefined);
703
+ const inlineTable = (pairs) => `{ ${pairs.map(([k, v]) => `${tomlKey(k)} = ${common.tomlStr(v)}`).join(', ')} }`;
704
+
705
+ function renderServer(name, server) {
706
+ const lines = [`[mcp_servers.${tomlKey(name)}]`];
707
+ if (server.transport === 'stdio') {
708
+ lines.push(`command = ${common.tomlStr(server.command)}`);
709
+ if (Array.isArray(server.args) && server.args.length) {
710
+ lines.push(`args = [${server.args.map((a) => common.tomlStr(a)).join(', ')}]`);
711
+ }
712
+ const literal = Object.entries(server.env || {}).filter(([, v]) => !envRef(v));
713
+ const refs = Object.entries(server.env || {}).filter(([, v]) => envRef(v));
714
+ if (literal.length) lines.push(`env = ${inlineTable(literal)}`);
715
+ // A "${NAME}" placeholder means the value never left the source machine:
716
+ // Codex reads NAME from the environment it was launched with.
717
+ if (refs.length) lines.push(`env_vars = [${refs.map(([k]) => common.tomlStr(k)).join(', ')}]`);
718
+ if (server.cwd) lines.push(`cwd = ${common.tomlStr(server.cwd)}`);
719
+ } else {
720
+ lines.push(`url = ${common.tomlStr(server.url)}`);
721
+ const literal = Object.entries(server.headers || {}).filter(([, v]) => !envRef(v));
722
+ const refs = Object.entries(server.headers || {}).filter(([, v]) => envRef(v));
723
+ if (literal.length) lines.push(`http_headers = ${inlineTable(literal)}`);
724
+ if (refs.length) lines.push(`env_http_headers = ${inlineTable(refs.map(([k, v]) => [k, envRef(v)]))}`);
725
+ }
726
+ return lines.join('\n');
727
+ }
728
+
729
+ function mcp(ctx) {
730
+ try {
731
+ const configFile = ctx.target.mcpConfigFile;
732
+ if (!configFile) return { status: 'unsupported', files: [], dropped: [], note: 'no mcpConfigFile in the target registry' };
733
+ const markers = common.regionMarkers('mcp');
734
+ const text = readText(configFile) || '';
735
+ // What the user configured themselves: everything the file says with our own
736
+ // region taken out. A second table for the same server breaks Codex's load.
737
+ const { data } = toml.parse(common.replaceRegion(text, markers, ''));
738
+ const existing = new Set(Object.keys((data && data.mcp_servers) || {}));
739
+ const exclude = (ctx.port && ctx.port.mcp && ctx.port.mcp.exclude) || {};
740
+
741
+ const dropped = [];
742
+ const blocks = [];
743
+ for (const [name, server] of Object.entries((ctx.bundle.mcp && ctx.bundle.mcp.servers) || {})) {
744
+ if (exclude[name]) { dropped.push({ item: `mcp ${name}`, reason: exclude[name] }); continue; }
745
+ if (existing.has(name)) { dropped.push({ item: `mcp ${name}`, reason: 'already configured in config.toml' }); continue; }
746
+ if (server.transport === 'sse') { dropped.push({ item: `mcp ${name}`, reason: 'Codex has no SSE transport' }); continue; }
747
+ blocks.push(renderServer(name, server));
748
+ }
749
+
750
+ const next = upsertRegion(text, markers, blocks.join('\n\n'));
751
+ const res = ctx.write(configFile, next, { region: true });
752
+ const files = [{ path: configFile, action: res.action }];
753
+ return { status: statusOf(files), files, dropped, note: `${blocks.length} server(s) written, ${dropped.length} not ported` };
754
+ } catch (err) {
755
+ return { status: 'error', files: [], dropped: [], error: err.message };
756
+ }
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // permissions -> Starlark prefix rules
761
+ // ---------------------------------------------------------------------------
762
+ const DECISION = { allow: 'allow', deny: 'forbidden', ask: 'prompt' };
763
+
764
+ /** `Bash(git *)` -> `["git"]`; anything Codex cannot express as a leading-argument match is dropped. */
765
+ function toPrefixRule(pattern, decision) {
766
+ const m = String(pattern).match(/^Bash\(([\s\S]*)\)$/);
767
+ if (!m) {
768
+ const tool = (String(pattern).match(/^([A-Za-z_][A-Za-z0-9_]*)\(/) || [])[1];
769
+ return { drop: tool ? `${tool}() has no Codex equivalent; only Bash(...) becomes a prefix rule` : 'not a Bash(...) pattern; only Bash(...) becomes a prefix rule' };
770
+ }
771
+ // Claude Code's `Bash(git push --force:*)` means "the command starts with
772
+ // `git push --force`": the `:*` is a whole-string prefix operator, which is
773
+ // exactly a Codex prefix rule. Strip it before tokenising.
774
+ const tokens = m[1].trim().replace(/:\*$/, '').trim().split(/\s+/).filter(Boolean);
775
+ if (tokens[tokens.length - 1] === '*') tokens.pop(); // trailing * IS a prefix rule
776
+ if (!tokens.length) return { drop: 'matches every command; a Codex prefix rule needs at least one leading argument' };
777
+ const bad = tokens.find((t) => t.includes('*'));
778
+ if (bad) return { drop: `"${bad}" is a partial-token wildcard; Codex prefix rules match whole leading arguments` };
779
+ return { line: `prefix_rule(pattern = [${tokens.map((t) => JSON.stringify(t)).join(', ')}], decision = ${JSON.stringify(decision)})` };
780
+ }
781
+
782
+ function permissions(ctx) {
783
+ try {
784
+ const file = ctx.target.permissionsFile;
785
+ if (!file) return { status: 'unsupported', files: [], dropped: [], note: 'no permissionsFile in the target registry' };
786
+ const source = (ctx.bundle.manifest && ctx.bundle.manifest.source) || 'source';
787
+ const dropped = [];
788
+ const seen = new Set();
789
+ const sections = [];
790
+ // forbidden first, then prompt, then allow: a broad allow written above a
791
+ // narrow deny would make the deny unreachable under first-match evaluation.
792
+ for (const kind of ['deny', 'ask', 'allow']) {
793
+ const lines = [];
794
+ for (const pattern of (ctx.bundle.permissions && ctx.bundle.permissions[kind]) || []) {
795
+ const out = toPrefixRule(pattern, DECISION[kind]);
796
+ if (out.drop) { dropped.push({ item: `${kind}: ${pattern}`, reason: out.drop }); continue; }
797
+ if (seen.has(out.line)) continue;
798
+ seen.add(out.line);
799
+ lines.push(out.line);
800
+ }
801
+ if (lines.length) sections.push(`# ${kind} -> ${DECISION[kind]}\n${lines.join('\n')}`);
802
+ }
803
+
804
+ if (!sections.length) {
805
+ // Nothing survived. Remove the file only if this adapter wrote it before.
806
+ if (!Object.prototype.hasOwnProperty.call(ctx.state.files, file) || !fs.existsSync(file)) {
807
+ return { status: 'synced', files: [], dropped, note: 'no Bash(...) permission maps to a Codex prefix rule' };
808
+ }
809
+ if (readOnly(ctx)) return { status: 'stale', files: [{ path: file, action: 'would-remove' }], dropped, note: 'the generated rules file is now empty' };
810
+ if (ctx.backup) ctx.backup(file);
811
+ fs.unlinkSync(file);
812
+ delete ctx.state.files[file];
813
+ return { status: 'written', files: [{ path: file, action: 'removed' }], dropped, note: 'no rule survived translation; the generated file was removed' };
814
+ }
815
+
816
+ const header = `# ${common.GENERATED_MARK} from the ${source} harness — do not hand-edit.`;
817
+ const content = [header, '# Edit permissions in the source client and re-run the port.', '', ...sections, ''].join('\n');
818
+ const res = ctx.write(file, content, { header });
819
+ const files = [{ path: file, action: res.action }];
820
+ return { status: statusOf(files), files, dropped, note: `${seen.size} prefix rule(s), ${dropped.length} not ported` };
821
+ } catch (err) {
822
+ return { status: 'error', files: [], dropped: [], error: err.message };
823
+ }
824
+ }
825
+
826
+ module.exports = {
827
+ id: ID,
828
+ components: COMPONENTS,
829
+ rules, identity, hooks, skills, agents, commands, mcp, permissions,
830
+ // exported for the port's own tests
831
+ _internal: { translateHooks, translateMatcher, hookHash, selfTestTrustHash, toPrefixRule, upsertRegion },
832
+ };