@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,87 @@
1
+ /**
2
+ * engine/harness/targets/cursor.cjs — Cursor.
3
+ *
4
+ * Rules (.mdc), skills, agents, commands and mcp.json all come from the generic
5
+ * adapter; only the hook surface is Cursor's own.
6
+ *
7
+ * ~/.cursor/hooks.json = { "version": 1, "hooks": { "<event>": [ { command, matcher? } ] } }
8
+ *
9
+ * Cursor's events are camelCase and its payload dialect is its own, so every
10
+ * ported group becomes one entry whose command runs engine/hooks/shim.cjs with
11
+ * the group's guards chained behind `++`. Entries the operator or another tool
12
+ * put in the file are never touched: ownership is the exact JSON of what this
13
+ * adapter inserted last run.
14
+ *
15
+ * Dialect facts: Cursor hooks documentation, read 2026-09-06.
16
+ */
17
+
18
+ const common = require('../common.cjs');
19
+ const generic = require('./generic.cjs');
20
+
21
+ const I = generic._internals;
22
+
23
+ const EVENT_MAP = {
24
+ PreToolUse: 'preToolUse',
25
+ PostToolUse: 'postToolUse',
26
+ PostToolUseFailure: 'postToolUseFailure',
27
+ Stop: 'stop',
28
+ SessionStart: 'sessionStart',
29
+ SessionEnd: 'sessionEnd',
30
+ UserPromptSubmit: 'beforeSubmitPrompt',
31
+ SubagentStart: 'subagentStart',
32
+ SubagentStop: 'subagentStop',
33
+ PreCompact: 'preCompact',
34
+ };
35
+
36
+ // Claude tool token -> Cursor tool name. A token with no Cursor tool behind it
37
+ // is dropped, and a group whose every token drops is not ported at all.
38
+ const MATCHER_MAP = {
39
+ Bash: 'Shell', PowerShell: 'Shell',
40
+ Read: 'Read',
41
+ Write: 'Write|Edit', Edit: 'Write|Edit', MultiEdit: 'Write|Edit', NotebookEdit: 'Write|Edit',
42
+ Agent: 'Task', Task: 'Task', Workflow: 'Task',
43
+ 'mcp__.*': 'MCP',
44
+ };
45
+
46
+ // Only the tool events take a matcher.
47
+ const TOOL_EVENTS = new Set(['preToolUse', 'postToolUse', 'postToolUseFailure']);
48
+
49
+ function hooks(ctx) {
50
+ const file = I.at(ctx, ctx.target.hooksConfigFile);
51
+ if (!file) return I.unsupported('no hooks config file in the target registry');
52
+ const config = I.readUserJson(file);
53
+ 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` };
54
+
55
+ const dropped = [];
56
+ const next = {};
57
+
58
+ for (const [event, groups] of Object.entries(ctx.bundle.hooks.events || {})) {
59
+ const cursorEvent = EVENT_MAP[event];
60
+ if (!cursorEvent) { dropped.push({ item: `hook event ${event}`, reason: 'no Cursor event' }); continue; }
61
+ for (const group of groups || []) {
62
+ const { kept, dropped: gone } = I.portedHandlers(ctx, event, group);
63
+ dropped.push(...gone);
64
+ if (!kept.length) continue;
65
+ let matcher;
66
+ if (TOOL_EVENTS.has(cursorEvent)) {
67
+ matcher = I.translateMatcher(group.matcher, MATCHER_MAP, { keepUnknown: false });
68
+ if (matcher === null) { dropped.push({ item: `${event} [${group.matcher}]`, reason: 'no Cursor tool behind this matcher' }); continue; }
69
+ }
70
+ const entry = { command: I.shimCommand('cursor', cursorEvent, kept.map((h) => h.command)) };
71
+ if (matcher !== undefined) entry.matcher = matcher;
72
+ (next[cursorEvent] = next[cursorEvent] || []).push(entry);
73
+ }
74
+ }
75
+ for (const [event, groups] of Object.entries(I.extraGroups(ctx))) {
76
+ (next[event] = next[event] || []).push(...groups);
77
+ }
78
+
79
+ config.version = config.version || 1;
80
+ config.hooks = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks) ? config.hooks : {};
81
+ const keys = I.applyOwnedGroups(config.hooks, I.owned(ctx, 'hooks'), next);
82
+ const res = I.writeUserJson(ctx, file, config);
83
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id].hooks = keys;
84
+ return I.result(ctx, [{ path: file, action: res.action }], dropped, `${keys.length} hook entr(ies) via the shim`);
85
+ }
86
+
87
+ module.exports = Object.assign({}, generic, { id: 'cursor', hooks });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * engine/harness/targets/gemini.cjs — Gemini CLI.
3
+ *
4
+ * Rules, skills and MCP come straight from the generic adapter. Two surfaces
5
+ * are Gemini's own:
6
+ *
7
+ * hooks ~/.gemini/settings.json -> "hooks". Gemini has its own event and
8
+ * tool names, and its own payload dialect, so every ported hook runs
9
+ * through engine/hooks/shim.cjs. Gemini MERGES the results of every
10
+ * hook registered for one event and the last reason wins, so all the
11
+ * guards for one (event, matcher) pair are chained inside ONE entry
12
+ * with `++`; the shim stops at the first deny and that reason is the
13
+ * only one Gemini sees.
14
+ * commands ~/.gemini/commands/<name>.toml, not markdown.
15
+ *
16
+ * Dialect facts: Gemini CLI hooks documentation, read 2026-09-06.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const common = require('../common.cjs');
22
+ const generic = require('./generic.cjs');
23
+
24
+ const I = generic._internals;
25
+
26
+ const EVENT_MAP = {
27
+ PreToolUse: 'BeforeTool',
28
+ PostToolUse: 'AfterTool',
29
+ UserPromptSubmit: 'BeforeAgent',
30
+ Stop: 'AfterAgent',
31
+ SessionStart: 'SessionStart',
32
+ SessionEnd: 'SessionEnd',
33
+ PreCompact: 'PreCompress',
34
+ };
35
+
36
+ // Claude tool token -> Gemini CLI tool name. null drops the token.
37
+ const MATCHER_MAP = {
38
+ Bash: 'run_shell_command', PowerShell: 'run_shell_command',
39
+ Write: 'write_file',
40
+ Edit: 'replace', MultiEdit: 'replace', NotebookEdit: 'replace',
41
+ Read: 'read_file', Glob: 'glob',
42
+ Grep: 'search_file_content',
43
+ WebFetch: 'web_fetch', WebSearch: 'google_web_search',
44
+ Agent: null, Task: null, Workflow: null,
45
+ };
46
+
47
+ // Only the tool events take a matcher; the rest fire once per turn.
48
+ const TOOL_EVENTS = new Set(['BeforeTool', 'AfterTool']);
49
+
50
+ function hooks(ctx) {
51
+ const file = I.at(ctx, ctx.target.hooksConfigFile);
52
+ if (!file) return I.unsupported('no hooks config file in the target registry');
53
+ const settings = I.readUserJson(file);
54
+ 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` };
55
+
56
+ const dropped = [];
57
+ const buckets = new Map(); // `${geminiEvent} ${matcher ?? ''}` -> handlers[]
58
+
59
+ for (const [event, groups] of Object.entries(ctx.bundle.hooks.events || {})) {
60
+ const geminiEvent = EVENT_MAP[event];
61
+ if (!geminiEvent) { dropped.push({ item: `hook event ${event}`, reason: 'no Gemini CLI event' }); continue; }
62
+ for (const group of groups || []) {
63
+ const { kept, dropped: gone } = I.portedHandlers(ctx, event, group);
64
+ dropped.push(...gone);
65
+ if (!kept.length) continue;
66
+ let matcher;
67
+ if (TOOL_EVENTS.has(geminiEvent)) {
68
+ matcher = I.translateMatcher(group.matcher, MATCHER_MAP, { keepUnknown: true });
69
+ if (matcher === null) { dropped.push({ item: `${event} [${group.matcher}]`, reason: 'no Gemini CLI tool behind this matcher' }); continue; }
70
+ }
71
+ const key = `${geminiEvent} ${matcher === undefined ? '' : matcher}`;
72
+ if (!buckets.has(key)) buckets.set(key, { event: geminiEvent, matcher, handlers: [] });
73
+ buckets.get(key).handlers.push(...kept);
74
+ }
75
+ }
76
+
77
+ const next = {};
78
+ for (const { event, matcher, handlers } of buckets.values()) {
79
+ const hook = { type: 'command', command: I.shimCommand('gemini', event, handlers.map((h) => h.command)) };
80
+ const timeout = I.chainTimeout(handlers);
81
+ if (timeout !== undefined) hook.timeout = timeout;
82
+ const group = matcher === undefined ? { hooks: [hook] } : { matcher, hooks: [hook] };
83
+ (next[event] = next[event] || []).push(group);
84
+ }
85
+ for (const [event, groups] of Object.entries(I.extraGroups(ctx))) {
86
+ (next[event] = next[event] || []).push(...groups);
87
+ }
88
+
89
+ settings.hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {};
90
+ const keys = I.applyOwnedGroups(settings.hooks, I.owned(ctx, 'hooks'), next);
91
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
92
+ const res = I.writeUserJson(ctx, file, settings);
93
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id].hooks = keys;
94
+ return I.result(ctx, [{ path: file, action: res.action }], dropped, `${keys.length} chained hook entr(ies) via the shim`);
95
+ }
96
+
97
+ function commands(ctx) {
98
+ const dir = I.at(ctx, ctx.target.commandsDir);
99
+ if (!dir) return I.unsupported('client has no custom-command directory');
100
+ const files = [];
101
+ const dropped = [];
102
+ const keep = [];
103
+ const header = `# ${common.GENERATED_MARK} from the ${ctx.bundle.manifest.source} harness`;
104
+
105
+ for (const command of ctx.bundle.commands) {
106
+ const file = path.join(dir, `${command.name}.toml`);
107
+ if (fs.existsSync(file) && !I.weWroteIt(ctx, 'commands', file)) {
108
+ dropped.push({ item: `command ${command.name}`, reason: `${common.tildePath(file, ctx.home)} already exists and was not created by the harness` });
109
+ continue;
110
+ }
111
+ // Gemini interpolates {{args}}; Claude and Codex write $ARGUMENTS.
112
+ const prompt = String(command.body).replace(/\$ARGUMENTS/g, '{{args}}').trim();
113
+ const body = [
114
+ header,
115
+ `description = ${common.tomlStr(command.meta.description || command.name)}`,
116
+ `prompt = ${common.tomlMultiline(prompt)}`,
117
+ '',
118
+ ].join('\n');
119
+ const res = ctx.write(file, body);
120
+ files.push({ path: file, action: res.action });
121
+ // Still wanted even when the write was refused; see generic.renderMarkdownDir.
122
+ keep.push(file);
123
+ }
124
+ files.push(...I.pruneFiles(ctx, 'commands', keep));
125
+ return I.result(ctx, files, dropped, `${keep.length} .toml command(s)`);
126
+ }
127
+
128
+ module.exports = Object.assign({}, generic, { id: 'gemini', hooks, commands });
@@ -0,0 +1,424 @@
1
+ /**
2
+ * engine/harness/targets/generic.cjs — the default adapter.
3
+ *
4
+ * Every registry entry in core/templates/targets.json without its own
5
+ * `adapter` is rendered by this module. It carries the surfaces that almost
6
+ * every client shares (a rules file, a skills directory, a markdown commands
7
+ * directory, a markdown agents directory, an `mcpServers` JSON file) and
8
+ * declares the rest unsupported with a reason.
9
+ *
10
+ * A specific adapter reuses these components with
11
+ * module.exports = Object.assign({}, generic, { id, hooks(ctx) {...} });
12
+ * so a client only writes the code for the surfaces it does differently.
13
+ *
14
+ * Contract: engine/harness/README.md -> "Adapter contract".
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const common = require('../common.cjs');
20
+ const { stripSections } = common;
21
+
22
+ const COMPONENTS = ['rules', 'identity', 'hooks', 'skills', 'agents', 'commands', 'mcp', 'permissions'];
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Shared helpers (exported so claude/gemini/agy/cursor reuse them verbatim)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** The mutable per-target ownership record inside `storage/harness-state.json`. */
29
+ function owned(ctx, kind) {
30
+ ctx.state.owned = ctx.state.owned || {};
31
+ const id = ctx.target.id;
32
+ ctx.state.owned[id] = ctx.state.owned[id] || {};
33
+ if (!Array.isArray(ctx.state.owned[id][kind])) ctx.state.owned[id][kind] = [];
34
+ return ctx.state.owned[id][kind];
35
+ }
36
+
37
+ /** Derive the ComponentResult status from what actually happened to the files. */
38
+ function statusFrom(files, ctx) {
39
+ const actions = files.map((f) => f.action);
40
+ if (actions.includes('skipped-hand-edited')) return 'skipped';
41
+ if (actions.some((a) => a === 'written' || a === 'linked' || a === 'pruned' || a === 'removed')) return 'written';
42
+ if (actions.some((a) => a === 'would-write' || a === 'would-link' || a === 'would-prune')) return ctx.check || ctx.dryRun ? 'stale' : 'written';
43
+ return 'synced';
44
+ }
45
+
46
+ const result = (ctx, files, dropped, note) => {
47
+ const out = { status: statusFrom(files, ctx), files, dropped };
48
+ if (note) out.note = note;
49
+ return out;
50
+ };
51
+
52
+ const unsupported = (note) => ({ status: 'unsupported', files: [], dropped: [], note });
53
+
54
+ /** Expand a registry path that may or may not already be absolute. */
55
+ const at = (ctx, p) => (p ? common.expandPath(p, ctx.home) : '');
56
+
57
+ /** Canonical JSON (keys sorted, compact) so an inserted object is recognisable on re-read. */
58
+ function canonical(v) {
59
+ if (Array.isArray(v)) return v.map(canonical);
60
+ if (v && typeof v === 'object') {
61
+ const o = {};
62
+ for (const k of Object.keys(v).sort()) o[k] = canonical(v[k]);
63
+ return o;
64
+ }
65
+ return v;
66
+ }
67
+ const stableKey = (v) => JSON.stringify(canonical(v));
68
+
69
+ /**
70
+ * Read a JSON file the user also owns.
71
+ * missing -> {}, unparseable -> null (the caller must then touch nothing).
72
+ */
73
+ function readUserJson(file) {
74
+ if (!fs.existsSync(file)) return {};
75
+ const parsed = common.readJSON(file);
76
+ return parsed === undefined ? null : parsed || {};
77
+ }
78
+
79
+ const writeUserJson = (ctx, file, obj) =>
80
+ ctx.write(file, JSON.stringify(obj, null, 2) + '\n', { region: true });
81
+
82
+ /**
83
+ * Replace the hook groups this harness owns inside a client's event map and add
84
+ * the new ones. Groups the user (or another tool) put there are never touched:
85
+ * ownership is the exact canonical JSON of what we inserted last time.
86
+ *
87
+ * `events` is mutated in place. Returns the new owned-key list.
88
+ */
89
+ function applyOwnedGroups(events, ownedKeys, next) {
90
+ const previous = new Set(ownedKeys);
91
+ for (const [event, groups] of Object.entries(events)) {
92
+ if (!Array.isArray(groups)) continue;
93
+ const kept = groups.filter((g) => !previous.has(stableKey(g)));
94
+ if (kept.length) events[event] = kept;
95
+ else delete events[event];
96
+ }
97
+ const keys = [];
98
+ for (const [event, groups] of Object.entries(next)) {
99
+ for (const g of groups) {
100
+ events[event] = events[event] || [];
101
+ events[event].push(g);
102
+ keys.push(stableKey(g));
103
+ }
104
+ }
105
+ return keys;
106
+ }
107
+
108
+ /** Every hook handler in the bundle, flattened, with the port exclusions applied. */
109
+ function portedHandlers(ctx, event, group) {
110
+ const excludes = ((ctx.port.hooks && ctx.port.hooks.exclude) || []).map((e) => ({ re: new RegExp(e.match), reason: e.reason }));
111
+ const kept = [];
112
+ const dropped = [];
113
+ for (const h of group.hooks || []) {
114
+ if (h.type !== 'command' || !h.command) continue;
115
+ const hit = excludes.find((e) => e.re.test(h.command));
116
+ // the drop names the script, never the command line: a command can carry an argument nobody should log
117
+ if (hit) { dropped.push({ item: `${event}: ${scriptName(h.command)}`, reason: hit.reason }); continue; }
118
+ // the shim splits a chain on ' ++ '; a command carrying it would run as two
119
+ if (h.command.includes(' ++ ')) { dropped.push({ item: `${event}: ${scriptName(h.command)}`, reason: "the command contains ' ++ ', the shim's chain separator" }); continue; }
120
+ kept.push(h);
121
+ }
122
+ return { kept, dropped };
123
+ }
124
+
125
+ /** The script a hook command runs, for a drop line (the same rule as the Codex adapter). */
126
+ function scriptName(command) {
127
+ const m = String(command).match(/([A-Za-z0-9_-]+)\.(?:cjs|mjs|js|ps1|py|sh)\b/);
128
+ return m ? m[1] : String(command).trim().split(/\s+/)[0];
129
+ }
130
+
131
+ /** Client-dialect hooks the operator added for this target only (core/port.json hooks.extra). */
132
+ const extraGroups = (ctx) => ((ctx.port.hooks && ctx.port.hooks.extra) || {})[ctx.target.id] || {};
133
+
134
+ /** Translate a Claude matcher through a token map. undefined = match all, null = drop the group. */
135
+ function translateMatcher(matcher, map, { keepUnknown }) {
136
+ if (matcher == null || matcher === '' || matcher === '*') return undefined;
137
+ const out = [];
138
+ for (const token of String(matcher).split('|')) {
139
+ const known = Object.prototype.hasOwnProperty.call(map, token);
140
+ const mapped = known ? map[token] : keepUnknown ? token : null;
141
+ if (!mapped) continue;
142
+ for (const t of String(mapped).split('|')) if (!out.includes(t)) out.push(t);
143
+ }
144
+ return out.length ? out.join('|') : null;
145
+ }
146
+
147
+ /** `node "<shim>" --client <c> --event <e> -- cmd1 ++ cmd2` */
148
+ function shimCommand(client, event, commands) {
149
+ const shim = common.shimPath().replace(/\\/g, '/');
150
+ return `node "${shim}" --client ${client} --event ${event} -- ${commands.join(' ++ ')}`;
151
+ }
152
+
153
+ /** Largest timeout in a chain, or undefined when the source set none. */
154
+ function chainTimeout(handlers) {
155
+ const values = handlers.map((h) => h.timeout).filter((t) => typeof t === 'number');
156
+ return values.length ? Math.max(...values) : undefined;
157
+ }
158
+
159
+ /** A generated markdown file is ours when the state says we wrote it. */
160
+ const weWroteIt = (ctx, kind, file) =>
161
+ owned(ctx, kind).includes(file) || Object.prototype.hasOwnProperty.call(ctx.state.files || {}, file);
162
+
163
+ /** Delete generated files this adapter created that no longer have a source. */
164
+ function pruneFiles(ctx, kind, keep) {
165
+ const files = [];
166
+ const keepSet = new Set(keep);
167
+ for (const file of owned(ctx, kind)) {
168
+ if (keepSet.has(file) || !fs.existsSync(file)) continue;
169
+ if (ctx.check || ctx.dryRun) { files.push({ path: file, action: 'would-prune' }); continue; }
170
+ if (ctx.backup) ctx.backup(file);
171
+ fs.rmSync(file, { force: true });
172
+ delete ctx.state.files[file];
173
+ files.push({ path: file, action: 'pruned' });
174
+ }
175
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id][kind] = keep;
176
+ return files;
177
+ }
178
+
179
+ /** Render one markdown file per bundle item into a client directory, then prune. */
180
+ function renderMarkdownDir(ctx, kind, dir, items, metaFor) {
181
+ const files = [];
182
+ const dropped = [];
183
+ const keep = [];
184
+ for (const item of items) {
185
+ const file = path.join(dir, `${item.name}.md`);
186
+ if (fs.existsSync(file) && !weWroteIt(ctx, kind, file)) {
187
+ dropped.push({ item: `${kind.slice(0, -1)} ${item.name}`, reason: `${common.tildePath(file, ctx.home)} already exists and was not created by the harness` });
188
+ continue;
189
+ }
190
+ // No `header` claim here: `---` starts every frontmatter file, so claiming it
191
+ // would make ctx.write treat a hand-edited agent as ours and overwrite it.
192
+ // Ownership of these files is state.owned / state.files, which is exact.
193
+ const res = ctx.write(file, common.renderFrontmatter(metaFor(item), item.body));
194
+ files.push({ path: file, action: res.action });
195
+ // Still wanted even when the write was refused: a hand-edited file must be
196
+ // left for the user to resolve, never pruned as if the bundle had dropped it.
197
+ keep.push(file);
198
+ }
199
+ files.push(...pruneFiles(ctx, kind, keep));
200
+ return { files, dropped };
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // MCP rendering
205
+ // ---------------------------------------------------------------------------
206
+
207
+ // Which JSON shape a client wants for a non-stdio server.
208
+ // typed -> { type: "http"|"sse", url, headers } (Claude Code)
209
+ // plain -> { url, headers } (Cursor, Windsurf, Cline)
210
+ // gemini -> { httpUrl } for http, { url } for sse (Gemini CLI, Antigravity)
211
+ const HTTP_SHAPE = { gemini: 'gemini', agy: 'gemini', cursor: 'plain', windsurf: 'plain', cline: 'plain' };
212
+
213
+ // Clients that expand ${VAR} in an MCP value themselves. The rest get a note
214
+ // naming the variables the user has to have exported.
215
+ const EXPANDS_ENV = new Set(['claude', 'cursor']);
216
+
217
+ function renderServer(id, s) {
218
+ if (s.transport === 'stdio') {
219
+ const out = { command: s.command };
220
+ if (s.args && s.args.length) out.args = s.args;
221
+ if (s.env && Object.keys(s.env).length) out.env = s.env;
222
+ if (s.cwd) out.cwd = s.cwd;
223
+ return out;
224
+ }
225
+ const shape = HTTP_SHAPE[id] || 'typed';
226
+ const headers = s.headers && Object.keys(s.headers).length ? s.headers : null;
227
+ let out;
228
+ if (shape === 'gemini') out = s.transport === 'http' ? { httpUrl: s.url } : { url: s.url };
229
+ else if (shape === 'plain') out = { url: s.url };
230
+ else out = { type: s.transport, url: s.url };
231
+ if (headers) out.headers = headers;
232
+ return out;
233
+ }
234
+
235
+ function renderOpencodeServer(s) {
236
+ if (s.transport === 'stdio') {
237
+ const out = { type: 'local', command: [s.command, ...(s.args || [])], enabled: true };
238
+ if (s.env && Object.keys(s.env).length) out.environment = s.env;
239
+ return out;
240
+ }
241
+ if (s.transport !== 'http') return null;
242
+ const out = { type: 'remote', url: s.url, enabled: true };
243
+ if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
244
+ return out;
245
+ }
246
+
247
+ const envRefs = (s) => {
248
+ const names = [];
249
+ for (const v of [...Object.values(s.env || {}), ...Object.values(s.headers || {})]) {
250
+ const m = /^\$\{([A-Za-z0-9_]+)\}$/.exec(String(v));
251
+ if (m && !names.includes(m[1])) names.push(m[1]);
252
+ }
253
+ return names;
254
+ };
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Components
258
+ // ---------------------------------------------------------------------------
259
+
260
+ function rules(ctx) {
261
+ const file = at(ctx, ctx.target.rulesFile);
262
+ if (!file) return unsupported('no rules file in the target registry');
263
+ const preamble = String(ctx.target.preamble || '').trim();
264
+ const drop = (ctx.port.rules && ctx.port.rules.dropSectionsForTargets) || [];
265
+ const parts = [];
266
+ if (preamble) parts.push(preamble);
267
+ parts.push(stripSections(ctx.bundle.rules, drop));
268
+ // A target-specific addendum (core/port.json rules.addenda.<id> -> a markdown file in the repo).
269
+ const addendumRel = ctx.port.rules && ctx.port.rules.addenda && ctx.port.rules.addenda[ctx.target.id];
270
+ const addendum = addendumRel ? common.readText(path.resolve(ctx.port.baseDir || common.ROOT, addendumRel)) : null;
271
+ if (addendum && addendum.trim()) parts.push('---\n\n' + addendum.trim());
272
+ if (ctx.bundle.identity && !ctx.target.traitsFile) parts.push('---\n\n' + ctx.bundle.identity.trim());
273
+ parts.push(`<!-- ${common.GENERATED_MARK} from the ${ctx.bundle.manifest.source} harness -->`);
274
+ let content = parts.join('\n\n') + '\n';
275
+ // README rule 1: a file this adapter owns says so on its first line, which is
276
+ // also how ctx.write recognises the file as ours after a hand edit. A
277
+ // frontmatter file (Cursor's .mdc) has to keep `---` first, so there the claim
278
+ // stays in the body and ownership falls back to the header line below.
279
+ // A frontmatter preamble (Cursor's .mdc) has to keep `---` first, so it carries
280
+ // no first-line claim and ctx.write falls back to state.files, which also means
281
+ // a hand edit there is reported instead of overwritten.
282
+ if (!preamble.startsWith('---') && !content.split('\n', 1)[0].includes(common.GENERATED_MARK)) {
283
+ content = `<!-- ${common.GENERATED_MARK}. Do not edit; edit the source client and re-run the port. -->\n${content}`;
284
+ }
285
+ const res = ctx.write(file, content);
286
+ return result(ctx, [{ path: file, action: res.action }], [], drop.length ? `dropped section(s): ${drop.join(', ')}` : undefined);
287
+ }
288
+
289
+ function identity(ctx) {
290
+ const file = at(ctx, ctx.target.traitsFile);
291
+ if (!file) return { status: 'synced', files: [], dropped: [], note: 'inlined in rules file' };
292
+ if (!ctx.bundle.identity) return { status: 'synced', files: [], dropped: [], note: 'the bundle carries no identity file' };
293
+ const body = `# ${path.basename(file)}\n\n<!-- ${common.GENERATED_MARK} from the ${ctx.bundle.manifest.source} harness -->\n\n${ctx.bundle.identity.trim()}\n`;
294
+ const res = ctx.write(file, body);
295
+ return result(ctx, [{ path: file, action: res.action }], []);
296
+ }
297
+
298
+ function skills(ctx) {
299
+ const dir = at(ctx, ctx.target.skillsDir);
300
+ if (!dir) return unsupported('client has no skills directory');
301
+ // An older sync junctioned some clients' whole skills dir at a shared source
302
+ // directory. Linking per skill inside it would write into the link's real
303
+ // target, i.e. into another repo. Leave it alone and say so.
304
+ const dirLink = common.readLinkTarget(dir);
305
+ if (dirLink !== null) {
306
+ return { status: 'skipped', files: [], dropped: [], note: `skills dir is a link to ${common.tildePath(dirLink, ctx.home)}; remove that link to get per-skill links` };
307
+ }
308
+ const exclude = (ctx.port.skills && ctx.port.skills.exclude) || {};
309
+ const shared = (ctx.target.sharedSkillDirs || []).map((d) => at(ctx, d));
310
+ const files = [];
311
+ const dropped = [];
312
+ const wanted = [];
313
+ for (const skill of ctx.bundle.skills.skills || []) {
314
+ if (exclude[skill.name]) { dropped.push({ item: `skill ${skill.name}`, reason: exclude[skill.name] }); continue; }
315
+ const native = shared.find((d) => fs.existsSync(path.join(d, skill.name, 'SKILL.md')));
316
+ if (native) { dropped.push({ item: `skill ${skill.name}`, reason: `already in the shared skills dir ${common.tildePath(native, ctx.home)}, which the client reads natively` }); continue; }
317
+ wanted.push(skill);
318
+ }
319
+ const wantedNames = new Set(wanted.map((s) => s.name));
320
+
321
+ // Prune first: a dangling link has to go before the same name is re-linked.
322
+ for (const name of owned(ctx, 'skills')) {
323
+ const dest = path.join(dir, name);
324
+ const target = common.readLinkTarget(dest);
325
+ if (target === null) continue; // a real directory or already gone: not ours to remove
326
+ if (wantedNames.has(name) && fs.existsSync(target)) continue;
327
+ if (ctx.check || ctx.dryRun) { files.push({ path: dest, action: 'would-prune' }); continue; }
328
+ common.unlinkIfLink(dest);
329
+ files.push({ path: dest, action: 'pruned' });
330
+ }
331
+
332
+ const linked = [];
333
+ for (const skill of wanted) {
334
+ const dest = path.join(dir, skill.name);
335
+ const res = ctx.link(skill.path, dest);
336
+ files.push({ path: dest, action: res.action });
337
+ if (res.action === 'skipped-real-directory') dropped.push({ item: `skill ${skill.name}`, reason: 'a real directory already sits at that path' });
338
+ else linked.push(skill.name);
339
+ }
340
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id].skills = linked;
341
+ return result(ctx, files, dropped, `${linked.length} skill(s) linked into ${common.tildePath(dir, ctx.home)}`);
342
+ }
343
+
344
+ function commands(ctx) {
345
+ const dir = at(ctx, ctx.target.commandsDir);
346
+ if (!dir) return unsupported('client has no slash-command directory');
347
+ const { files, dropped } = renderMarkdownDir(ctx, 'commands', dir, ctx.bundle.commands, (c) => ({
348
+ name: c.name,
349
+ description: c.meta.description,
350
+ }));
351
+ return result(ctx, files, dropped);
352
+ }
353
+
354
+ function agents(ctx) {
355
+ const dir = at(ctx, ctx.target.agentsDir);
356
+ if (!dir) return unsupported('client has no subagent directory');
357
+ const { files, dropped } = renderMarkdownDir(ctx, 'agents', dir, ctx.bundle.agents, (a) => ({
358
+ name: a.name,
359
+ description: a.meta.description,
360
+ model: 'inherit',
361
+ ...(String(a.meta.readonly) === 'true' ? { readonly: true } : {}),
362
+ }));
363
+ return result(ctx, files, dropped, 'model: inherit — the client picks the model, the harness only carries the role');
364
+ }
365
+
366
+ function mcp(ctx) {
367
+ const file = at(ctx, ctx.target.mcpConfigFile);
368
+ if (!file) return unsupported('client has no MCP configuration file');
369
+ const format = ctx.target.mcpFormat || 'mcpServers-json';
370
+ if (format !== 'mcpServers-json' && format !== 'opencode-json') {
371
+ return unsupported(`mcpFormat "${format}" needs a client-specific adapter`);
372
+ }
373
+ const config = readUserJson(file);
374
+ 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` };
375
+
376
+ const key = format === 'opencode-json' ? 'mcp' : 'mcpServers';
377
+ config[key] = config[key] && typeof config[key] === 'object' ? config[key] : {};
378
+ const servers = config[key];
379
+ const exclude = (ctx.port.mcp && ctx.port.mcp.exclude) || {};
380
+ const dropped = [];
381
+ const previouslyOwned = owned(ctx, 'mcp');
382
+ const nowOwned = [];
383
+ const envNames = [];
384
+
385
+ for (const [name, server] of Object.entries(ctx.bundle.mcp.servers || {})) {
386
+ if (exclude[name]) { dropped.push({ item: `mcp ${name}`, reason: exclude[name] }); continue; }
387
+ if (Object.prototype.hasOwnProperty.call(servers, name) && !previouslyOwned.includes(name)) {
388
+ dropped.push({ item: `mcp ${name}`, reason: 'already configured by the user' });
389
+ continue;
390
+ }
391
+ const rendered = format === 'opencode-json' ? renderOpencodeServer(server) : renderServer(ctx.target.id, server);
392
+ if (!rendered) { dropped.push({ item: `mcp ${name}`, reason: `OpenCode has no ${server.transport} transport` }); continue; }
393
+ servers[name] = rendered;
394
+ nowOwned.push(name);
395
+ for (const n of envRefs(server)) if (!envNames.includes(n)) envNames.push(n);
396
+ }
397
+ for (const name of previouslyOwned) {
398
+ if (!nowOwned.includes(name) && Object.prototype.hasOwnProperty.call(servers, name)) delete servers[name];
399
+ }
400
+ if (!Object.keys(servers).length) delete config[key];
401
+
402
+ const res = writeUserJson(ctx, file, config);
403
+ if (!ctx.check && !ctx.dryRun) ctx.state.owned[ctx.target.id].mcp = nowOwned;
404
+ const note = envNames.length && !EXPANDS_ENV.has(ctx.target.id)
405
+ ? `${nowOwned.length} server(s); export ${envNames.join(', ')} yourself — this client does not expand \${VAR}`
406
+ : `${nowOwned.length} server(s)`;
407
+ return result(ctx, [{ path: file, action: res.action }], dropped, note);
408
+ }
409
+
410
+ const hooks = () => unsupported('client has no hook surface the harness can drive');
411
+ const permissions = () => unsupported('client has no machine-readable permission surface');
412
+
413
+ module.exports = {
414
+ id: 'generic',
415
+ components: COMPONENTS,
416
+ rules, identity, hooks, skills, agents, commands, mcp, permissions,
417
+ // internals reused by the client-specific adapters and by the tests
418
+ _internals: {
419
+ owned, statusFrom, result, unsupported, at, canonical, stableKey,
420
+ readUserJson, writeUserJson, applyOwnedGroups, portedHandlers, extraGroups,
421
+ translateMatcher, shimCommand, chainTimeout, weWroteIt, pruneFiles,
422
+ renderMarkdownDir, renderServer, renderOpencodeServer, envRefs, HTTP_SHAPE,
423
+ },
424
+ };