@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,149 @@
1
+ /**
2
+ * engine/harness/toml.cjs — a small TOML reader for the subset client configs use.
3
+ *
4
+ * Handles: top-level key/value, [table], [table."quoted key"], [[array.of.tables]],
5
+ * basic/literal/multiline strings, integers, floats, booleans, arrays (multi-line),
6
+ * inline tables, comments. Unknown constructs are recorded in `warnings`, never
7
+ * thrown, because a config we cannot fully read must still be readable in part.
8
+ *
9
+ * Writing is done by the adapters through managed regions (common.replaceRegion)
10
+ * with common.tomlStr / tomlMultiline for values; there is no serializer here.
11
+ */
12
+
13
+ function parse(text) {
14
+ const root = {};
15
+ const warnings = [];
16
+ let current = root;
17
+ const lines = String(text).split(/\r?\n/);
18
+ let i = 0;
19
+
20
+ const unescape = (s) => s.replace(/\\(u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)/g, (m, e) => {
21
+ switch (e[0]) {
22
+ case 'n': return '\n'; case 't': return '\t'; case 'r': return '\r'; case '"': return '"'; case '\\': return '\\';
23
+ case 'b': return '\b'; case 'f': return '\f';
24
+ case 'u': case 'U': return String.fromCodePoint(parseInt(e.slice(1), 16));
25
+ default: return m;
26
+ }
27
+ });
28
+
29
+ // Parse a value starting at `s`; returns [value, rest]. `more()` pulls the next
30
+ // physical line for multi-line arrays and strings.
31
+ function parseValue(s, more) {
32
+ s = s.trimStart();
33
+ if (s.startsWith('"""')) {
34
+ let body = s.slice(3);
35
+ if (body.startsWith('\n')) body = body.slice(1);
36
+ let acc = '';
37
+ for (;;) {
38
+ const end = body.indexOf('"""');
39
+ if (end !== -1 && !/\\$/.test(body.slice(0, end))) { acc += body.slice(0, end); return [unescape(acc.replace(/^\n/, '')), body.slice(end + 3)]; }
40
+ acc += body + '\n';
41
+ const next = more();
42
+ if (next == null) return [unescape(acc), ''];
43
+ body = next;
44
+ }
45
+ }
46
+ if (s.startsWith("'''")) {
47
+ let body = s.slice(3).replace(/^\n/, '');
48
+ let acc = '';
49
+ for (;;) {
50
+ const end = body.indexOf("'''");
51
+ if (end !== -1) { acc += body.slice(0, end); return [acc, body.slice(end + 3)]; }
52
+ acc += body + '\n';
53
+ const next = more();
54
+ if (next == null) return [acc, ''];
55
+ body = next;
56
+ }
57
+ }
58
+ if (s[0] === '"') {
59
+ let j = 1; let out = '';
60
+ while (j < s.length && s[j] !== '"') { if (s[j] === '\\') { out += s[j] + s[j + 1]; j += 2; } else out += s[j++]; }
61
+ return [unescape(out), s.slice(j + 1)];
62
+ }
63
+ if (s[0] === "'") { const j = s.indexOf("'", 1); return [s.slice(1, j), s.slice(j + 1)]; }
64
+ if (s[0] === '[') {
65
+ const arr = []; let rest = s.slice(1);
66
+ for (;;) {
67
+ rest = rest.replace(/^\s*(#[^\n]*)?/, '');
68
+ if (!rest) { const next = more(); if (next == null) return [arr, '']; rest = next; continue; }
69
+ if (rest[0] === ']') return [arr, rest.slice(1)];
70
+ if (rest[0] === ',') { rest = rest.slice(1); continue; }
71
+ const [v, r] = parseValue(rest, more); arr.push(v); rest = r;
72
+ }
73
+ }
74
+ if (s[0] === '{') {
75
+ const obj = {}; let rest = s.slice(1);
76
+ for (;;) {
77
+ rest = rest.trimStart();
78
+ if (rest[0] === '}') return [obj, rest.slice(1)];
79
+ if (rest[0] === ',') { rest = rest.slice(1); continue; }
80
+ const km = rest.match(/^("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*=\s*/);
81
+ if (!km) { warnings.push(`unparseable inline table near: ${rest.slice(0, 40)}`); return [obj, '']; }
82
+ const key = km[1].replace(/^["'](.*)["']$/, '$1');
83
+ const [v, r] = parseValue(rest.slice(km[0].length), more); obj[key] = v; rest = r;
84
+ }
85
+ }
86
+ const m = s.match(/^(true|false|[+-]?(?:inf|nan)|[+-]?\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?|0x[0-9a-fA-F_]+|0o[0-7_]+|0b[01_]+|\d{4}-\d{2}-\d{2}[^\s,\]}]*)/);
87
+ if (m) {
88
+ const raw = m[1]; const rest = s.slice(raw.length);
89
+ if (raw === 'true') return [true, rest];
90
+ if (raw === 'false') return [false, rest];
91
+ if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return [raw, rest];
92
+ const num = Number(raw.replace(/_/g, ''));
93
+ return [Number.isNaN(num) ? raw : num, rest];
94
+ }
95
+ warnings.push(`unparseable value: ${s.slice(0, 40)}`);
96
+ return [s, ''];
97
+ }
98
+
99
+ const splitKey = (k) => {
100
+ const parts = []; let rest = k.trim();
101
+ while (rest) {
102
+ const m = rest.match(/^("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*(\.\s*)?/);
103
+ if (!m) { warnings.push(`unparseable key: ${k}`); return parts; }
104
+ parts.push(m[1].startsWith('"') ? unescape(m[1].slice(1, -1)) : m[1].replace(/^'(.*)'$/, '$1'));
105
+ rest = rest.slice(m[0].length);
106
+ }
107
+ return parts;
108
+ };
109
+
110
+ const descend = (obj, parts, arrayLeaf) => {
111
+ let node = obj;
112
+ parts.forEach((p, idx) => {
113
+ const last = idx === parts.length - 1;
114
+ if (last && arrayLeaf) {
115
+ if (!Array.isArray(node[p])) node[p] = [];
116
+ const entry = {}; node[p].push(entry); node = entry;
117
+ return;
118
+ }
119
+ if (node[p] === undefined) node[p] = {};
120
+ if (Array.isArray(node[p])) {
121
+ // [[a]] then [a.b]: the sub-table belongs to the LAST element of the array.
122
+ if (!node[p].length) node[p].push({});
123
+ node = node[p][node[p].length - 1];
124
+ } else {
125
+ node = node[p];
126
+ }
127
+ });
128
+ return node;
129
+ };
130
+
131
+ while (i < lines.length) {
132
+ let line = lines[i++];
133
+ const trimmed = line.trim();
134
+ if (!trimmed || trimmed.startsWith('#')) continue;
135
+ let m;
136
+ if ((m = trimmed.match(/^\[\[(.+)\]\]\s*(#.*)?$/))) { current = descend(root, splitKey(m[1]), true); continue; }
137
+ if ((m = trimmed.match(/^\[(.+)\]\s*(#.*)?$/))) { current = descend(root, splitKey(m[1]), false); continue; }
138
+ m = line.match(/^\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+(?:\s*\.\s*[A-Za-z0-9_.-]+)*)\s*=\s*(.*)$/);
139
+ if (!m) { warnings.push(`line ${i}: ${trimmed.slice(0, 60)}`); continue; }
140
+ const keyParts = splitKey(m[1]);
141
+ const [value] = parseValue(m[2], () => (i < lines.length ? lines[i++] : null));
142
+ const leaf = keyParts.pop();
143
+ const holder = keyParts.length ? descend(current, keyParts, false) : current;
144
+ holder[leaf] = value;
145
+ }
146
+ return { data: root, warnings };
147
+ }
148
+
149
+ module.exports = { parse };
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/hooks/shim.cjs — run an unmodified Claude Code hook under another client.
4
+ *
5
+ * Hooks are written once, in the Claude Code dialect (JSON on stdin, a decision
6
+ * on stdout or exit 2). Cursor, Gemini CLI and Antigravity each speak their own
7
+ * dialect. Rather than maintain a second copy of every guard, this translates
8
+ * the payload in, runs the real guards, and translates the decision back out.
9
+ *
10
+ * node shim.cjs --client cursor --event preToolUse -- node a.cjs ++ node b.cjs
11
+ *
12
+ * `++` chains several guards inside ONE hook entry. That is not a convenience:
13
+ * Gemini and agy merge the results of every hook registered for an event and the
14
+ * LAST result's reason wins, so a guard that denies with an explanation gets its
15
+ * explanation blanked by any later no-opinion hook. One entry per event means one
16
+ * result, and the reason survives. The first deny/ask in the chain wins and
17
+ * short-circuits.
18
+ *
19
+ * Fail-open by design: an unparseable payload, a crashed guard, a bad argument
20
+ * list or an internal error all print `{}` and exit 0. A broken shim must never
21
+ * wedge the client it is guarding.
22
+ *
23
+ * Codex needs no shim; its dialect is a near clone of Claude Code's.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const { spawnSync } = require('child_process');
28
+
29
+ const PER_COMMAND_TIMEOUT_MS = 25_000;
30
+
31
+ const HELP = `engine/hooks/shim.cjs — run Claude Code hooks under another client.
32
+
33
+ Usage:
34
+ node shim.cjs --client <cursor|gemini|agy> --event <clientEvent> -- <cmd...> [++ <cmd...>]*
35
+
36
+ Options:
37
+ --client the client whose dialect is on stdin and expected on stdout
38
+ --event the client's own event name (preToolUse, BeforeTool, PreToolUse, ...)
39
+ -- everything after this is the guard chain; ++ separates commands
40
+ --help this text
41
+
42
+ Reads the client's hook payload as JSON on stdin, translates it to the Claude
43
+ Code shape, runs every command in the chain with that payload, and translates
44
+ the first deny/ask back into the client's dialect. Exits 0 always, except a
45
+ Gemini BeforeTool deny, which also exits 2 with the reason on stderr.
46
+
47
+ Examples:
48
+ node shim.cjs --client cursor --event beforeShellExecution -- node ~/.claude/hooks/secret-guard.cjs
49
+ node shim.cjs --client gemini --event BeforeTool -- node a.cjs ++ node b.cjs
50
+ `;
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Arguments
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const stripQuotes = (s) => (/^".*"$/.test(s) && s.length > 1 ? s.slice(1, -1) : s);
57
+
58
+ /** Split one command STRING into argv, honouring double quotes. */
59
+ function tokenize(text) {
60
+ const out = [];
61
+ let current = '';
62
+ let quoted = false;
63
+ let started = false;
64
+ for (const ch of String(text)) {
65
+ if (ch === '"') { quoted = !quoted; started = true; continue; }
66
+ if (!quoted && /\s/.test(ch)) {
67
+ if (current || started) { out.push(current); current = ''; started = false; }
68
+ continue;
69
+ }
70
+ current += ch;
71
+ started = true;
72
+ }
73
+ if (current || started) out.push(current);
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The chain arrives either already split into argv by the client's shell, or as
79
+ * one quoted string per command when the shell left the quotes alone. Both work.
80
+ */
81
+ function splitChain(tokens) {
82
+ const parts = [[]];
83
+ for (const t of tokens) {
84
+ if (t === '++') parts.push([]);
85
+ else parts[parts.length - 1].push(t);
86
+ }
87
+ return parts
88
+ .filter((p) => p.length)
89
+ .map((p) => (p.length === 1 && /\s/.test(p[0]) ? tokenize(p[0]) : p.map(stripQuotes)))
90
+ .filter((p) => p.length);
91
+ }
92
+
93
+ function parseArgs(argv) {
94
+ const out = { client: '', event: '', help: false, chain: [] };
95
+ let i = 0;
96
+ for (; i < argv.length; i++) {
97
+ const a = argv[i];
98
+ if (a === '--') { i++; break; }
99
+ if (a === '--client') out.client = argv[++i] || '';
100
+ else if (a === '--event') out.event = argv[++i] || '';
101
+ else if (a === '--help' || a === '-h') out.help = true;
102
+ }
103
+ out.chain = splitChain(argv.slice(i));
104
+ return out;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Dialect maps
109
+ // ---------------------------------------------------------------------------
110
+
111
+ const CURSOR_EVENTS = {
112
+ preToolUse: 'PreToolUse', beforeShellExecution: 'PreToolUse', beforeMCPExecution: 'PreToolUse', beforeReadFile: 'PreToolUse',
113
+ postToolUse: 'PostToolUse', postToolUseFailure: 'PostToolUse', afterShellExecution: 'PostToolUse',
114
+ afterMCPExecution: 'PostToolUse', afterFileEdit: 'PostToolUse',
115
+ beforeSubmitPrompt: 'UserPromptSubmit', sessionStart: 'SessionStart', sessionEnd: 'SessionEnd',
116
+ preCompact: 'PreCompact', stop: 'Stop', afterAgentResponse: 'Stop',
117
+ subagentStart: 'SubagentStart', subagentStop: 'SubagentStop',
118
+ };
119
+ const CURSOR_TOOLS = { Shell: 'Bash', Read: 'Read', Write: 'Write', Edit: 'Edit', Task: 'Agent' };
120
+ const CURSOR_PRE = new Set(['preToolUse', 'beforeShellExecution', 'beforeMCPExecution', 'beforeReadFile']);
121
+
122
+ const GEMINI_EVENTS = {
123
+ BeforeTool: 'PreToolUse', BeforeToolSelection: 'PreToolUse', AfterTool: 'PostToolUse',
124
+ BeforeAgent: 'UserPromptSubmit', AfterAgent: 'Stop',
125
+ SessionStart: 'SessionStart', SessionEnd: 'SessionEnd', PreCompress: 'PreCompact',
126
+ };
127
+ const GEMINI_TOOLS = {
128
+ run_shell_command: 'Bash', write_file: 'Write', replace: 'Edit', read_file: 'Read',
129
+ glob: 'Glob', list_directory: 'Glob', grep_search: 'Grep', search_file_content: 'Grep',
130
+ web_fetch: 'WebFetch', google_web_search: 'WebSearch',
131
+ };
132
+ // Gemini's read_file names the path `absolute_path`; the guards read `file_path`.
133
+ const GEMINI_ARGS = { absolute_path: 'file_path' };
134
+
135
+ // agy is camelCase protojson with PascalCase tool args.
136
+ const AGY_TOOLS = {
137
+ run_command: 'Bash', write_to_file: 'Write', replace_file_content: 'Edit', view_file: 'Read',
138
+ grep_search: 'Grep', list_dir: 'Glob',
139
+ invoke_subagent: 'Agent', define_subagent: 'Agent', manage_subagents: 'Agent',
140
+ read_url_content: 'WebFetch', search_web: 'WebSearch',
141
+ };
142
+ const AGY_ARGS = {
143
+ CommandLine: 'command', Cwd: 'cwd', TargetFile: 'file_path', AbsolutePath: 'file_path',
144
+ CodeContent: 'content', TargetContent: 'old_string', ReplacementContent: 'new_string',
145
+ Query: 'pattern', SearchDirectory: 'path',
146
+ };
147
+
148
+ /**
149
+ * agy caps and ASCII-folds the reason it shows. The guards' teaching text is the
150
+ * payload; the typography is not.
151
+ */
152
+ function asciiReason(s) {
153
+ return String(s || '')
154
+ .replace(/[—–]/g, '-')
155
+ .replace(/[‘’]/g, "'")
156
+ .replace(/[“”]/g, '"')
157
+ .replace(/[…]/g, '...')
158
+ .replace(/[→]/g, '->')
159
+ .replace(/[^\x20-\x7E]/g, '')
160
+ .replace(/\s+/g, ' ')
161
+ .trim()
162
+ .slice(0, 400);
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Payload in: client dialect -> Claude Code shape
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Returns { payload, renames } where `renames` maps a Claude tool_input key back
171
+ * to the client's own key, so a rewriting guard's updatedInput can be handed
172
+ * back in the client's spelling.
173
+ */
174
+ function translateIn(client, event, raw) {
175
+ const renames = {};
176
+ const base = {
177
+ session_id: raw.session_id || raw.conversation_id || raw.conversationId || client,
178
+ transcript_path: raw.transcript_path || raw.transcriptPath || null,
179
+ cwd: raw.cwd || (Array.isArray(raw.workspacePaths) && raw.workspacePaths[0]) || process.cwd(),
180
+ permission_mode: raw.permission_mode || 'default',
181
+ stop_hook_active: false,
182
+ };
183
+
184
+ if (client === 'cursor') {
185
+ let toolName = CURSOR_TOOLS[raw.tool_name] || raw.tool_name || '';
186
+ let toolInput = raw.tool_input && typeof raw.tool_input === 'object' ? Object.assign({}, raw.tool_input) : {};
187
+ if (event === 'beforeShellExecution' || event === 'afterShellExecution') {
188
+ toolName = 'Bash';
189
+ toolInput = { command: raw.command };
190
+ if (raw.cwd) toolInput.cwd = raw.cwd;
191
+ } else if (event === 'beforeMCPExecution' || event === 'afterMCPExecution') {
192
+ toolName = /^mcp__/.test(String(raw.tool_name || '')) ? raw.tool_name : `mcp__${raw.tool_name || 'call'}`;
193
+ } else if (event === 'beforeReadFile') {
194
+ toolName = 'Read';
195
+ if (!toolInput.file_path) toolInput.file_path = raw.file_path || raw.path;
196
+ } else if (event === 'afterFileEdit') {
197
+ toolName = 'Edit';
198
+ if (!toolInput.file_path) toolInput.file_path = raw.file_path || raw.path;
199
+ }
200
+ return {
201
+ payload: Object.assign(base, {
202
+ hook_event_name: CURSOR_EVENTS[event] || event,
203
+ model: raw.model || 'cursor',
204
+ tool_name: toolName,
205
+ tool_input: toolInput,
206
+ prompt: raw.prompt,
207
+ cursor: { event, tool_use_id: raw.tool_use_id, sandbox: raw.sandbox },
208
+ }),
209
+ renames,
210
+ };
211
+ }
212
+
213
+ if (client === 'gemini') {
214
+ const toolInput = {};
215
+ for (const [k, v] of Object.entries(raw.tool_input || {})) {
216
+ const key = GEMINI_ARGS[k] || k;
217
+ if (key !== k) renames[key] = k;
218
+ toolInput[key] = v;
219
+ }
220
+ return {
221
+ payload: Object.assign(base, {
222
+ hook_event_name: GEMINI_EVENTS[event] || event,
223
+ model: raw.model || 'gemini',
224
+ tool_name: GEMINI_TOOLS[raw.tool_name] || raw.tool_name || '',
225
+ tool_input: toolInput,
226
+ prompt: raw.prompt,
227
+ gemini: { event, tool_name: raw.tool_name },
228
+ }),
229
+ renames,
230
+ };
231
+ }
232
+
233
+ // agy
234
+ const toolCall = raw.toolCall || {};
235
+ const toolInput = {};
236
+ for (const [k, v] of Object.entries(toolCall.args || {})) {
237
+ if (k === 'toolAction' || k === 'toolSummary') continue;
238
+ const key = AGY_ARGS[k] || k;
239
+ if (key !== k) renames[key] = k;
240
+ toolInput[key] = v;
241
+ }
242
+ return {
243
+ payload: Object.assign(base, {
244
+ hook_event_name: event,
245
+ model: raw.modelName || 'agy',
246
+ tool_name: AGY_TOOLS[toolCall.name] || toolCall.name || '',
247
+ tool_input: toolInput,
248
+ agy: {
249
+ stepIdx: raw.stepIdx, invocationNum: raw.invocationNum,
250
+ terminationReason: raw.terminationReason, error: raw.error,
251
+ },
252
+ }),
253
+ renames,
254
+ };
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Run the chain
259
+ // ---------------------------------------------------------------------------
260
+
261
+ function runChain(chain, payload) {
262
+ const input = JSON.stringify(payload);
263
+ let decision = null;
264
+ let reason = '';
265
+ let context = '';
266
+ let updatedInput = null;
267
+
268
+ for (const cmd of chain) {
269
+ let res;
270
+ try {
271
+ res = spawnSync(cmd[0], cmd.slice(1), {
272
+ input, encoding: 'utf8', shell: false,
273
+ timeout: PER_COMMAND_TIMEOUT_MS, windowsHide: true,
274
+ });
275
+ } catch (_) {
276
+ continue; // a broken guard must never wedge the client
277
+ }
278
+ if (!res || res.error) continue;
279
+
280
+ const stdout = (res.stdout || '').trim();
281
+ const stderr = (res.stderr || '').trim();
282
+ let inner = null;
283
+ if (stdout.startsWith('{')) { try { inner = JSON.parse(stdout); } catch (_) { inner = null; } }
284
+ const hso = (inner && inner.hookSpecificOutput) || {};
285
+
286
+ // Claude guards signal a block two ways: exit 2 with the reason on stderr, or
287
+ // a permissionDecision / decision of "deny" (Stop hooks say "block").
288
+ let d = hso.permissionDecision || (inner && (inner.permissionDecision || inner.decision)) || null;
289
+ if (d === 'block') d = 'deny';
290
+ if (res.status === 2 && !d && stderr) d = 'deny';
291
+
292
+ const r = hso.permissionDecisionReason || (inner && (inner.permissionDecisionReason || inner.reason)) || stderr || '';
293
+ const c = hso.additionalContext || (inner && inner.additionalContext) || (inner ? '' : stdout) || '';
294
+ if (c && !context) context = String(c);
295
+
296
+ const u = hso.updatedInput || (inner && inner.updatedInput);
297
+ if (u && typeof u === 'object' && Object.keys(u).length) updatedInput = Object.assign({}, updatedInput || {}, u);
298
+
299
+ if (d === 'deny' || d === 'ask' || d === 'force_ask') { decision = d; reason = String(r); break; }
300
+ }
301
+ return { decision, reason, context, updatedInput };
302
+ }
303
+
304
+ /** Claude tool_input keys -> the client's own keys, for a rewrite handed back. */
305
+ function rename(obj, renames) {
306
+ const out = {};
307
+ for (const [k, v] of Object.entries(obj || {})) out[renames[k] || k] = v;
308
+ return out;
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Decision out: Claude shape -> client dialect
313
+ // ---------------------------------------------------------------------------
314
+
315
+ function cursorOut(event, { decision, reason, context, updatedInput }, renames) {
316
+ if (CURSOR_PRE.has(event)) {
317
+ if (decision === 'deny') return { permission: 'deny', agent_message: reason, user_message: reason };
318
+ if (decision === 'ask' || decision === 'force_ask') return { permission: 'ask', agent_message: reason, user_message: reason };
319
+ // A rewrite only takes effect alongside an explicit allow. With no decision
320
+ // and no rewrite we say nothing, so Cursor's own permission prompts stand.
321
+ if (updatedInput) return { permission: 'allow', updated_input: rename(updatedInput, renames) };
322
+ return {};
323
+ }
324
+ if (event === 'beforeSubmitPrompt') {
325
+ if (decision) return { continue: false, user_message: reason };
326
+ return context ? { additional_context: context } : {};
327
+ }
328
+ if (event === 'stop' || event === 'afterAgentResponse') {
329
+ return decision ? { followup_message: reason } : {};
330
+ }
331
+ if (event === 'sessionStart') return context ? { additional_context: context } : {};
332
+ return {};
333
+ }
334
+
335
+ function geminiOut(event, { decision, reason, context, updatedInput }, renames, toolInput) {
336
+ if (decision) {
337
+ // Gemini's BeforeTool has no "ask"; a guard that wants a human in the loop
338
+ // is honoured as a deny that says so, rather than silently letting it pass.
339
+ const text = decision === 'deny' ? reason : `Confirm first: ${reason}`;
340
+ return { body: { decision: 'deny', reason: text }, denyReason: text };
341
+ }
342
+ if (updatedInput) {
343
+ const merged = rename(Object.assign({}, toolInput, updatedInput), renames);
344
+ return { body: { hookSpecificOutput: { tool_input: merged } } };
345
+ }
346
+ if (context) return { body: { hookSpecificOutput: { additionalContext: context } } };
347
+ return { body: {} };
348
+ }
349
+
350
+ function agyOut(event, { decision, reason, context, updatedInput }, renames) {
351
+ const out = {};
352
+ if (event === 'PreToolUse') {
353
+ out.decision = 'allow';
354
+ if (decision === 'deny' || decision === 'ask' || decision === 'force_ask') {
355
+ out.decision = decision;
356
+ const r = asciiReason(reason);
357
+ if (r) out.reason = r;
358
+ } else if (updatedInput) {
359
+ // A rewriting guard returns updatedInput and no decision at all, so this
360
+ // must not be gated on decision === "allow". agy merges `overwrite`
361
+ // shallowly into the tool call's args.
362
+ out.overwrite = rename(updatedInput, renames);
363
+ }
364
+ } else if (event === 'Stop') {
365
+ // Claude: {"decision":"block"} keeps the agent working. agy: "continue".
366
+ if (decision === 'deny') {
367
+ out.decision = 'continue';
368
+ const r = asciiReason(reason);
369
+ if (r) out.reason = r;
370
+ }
371
+ } else if (event === 'PreInvocation' || event === 'PostInvocation') {
372
+ if (context) out.injectSteps = [{ ephemeralMessage: String(context).slice(0, 20_000) }];
373
+ if (decision === 'deny' && event === 'PostInvocation') out.terminationBehavior = 'force_continue';
374
+ }
375
+ // PostToolUse: agy expects {} — nothing to translate.
376
+ return out;
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+
381
+ function main() {
382
+ const args = parseArgs(process.argv.slice(2));
383
+ if (args.help) { process.stdout.write(HELP); return 0; }
384
+ if (!['cursor', 'gemini', 'agy'].includes(args.client) || !args.event || !args.chain.length) {
385
+ process.stdout.write('{}');
386
+ return 0;
387
+ }
388
+
389
+ let raw;
390
+ try {
391
+ raw = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
392
+ } catch (_) {
393
+ process.stdout.write('{}');
394
+ return 0;
395
+ }
396
+ if (!raw || typeof raw !== 'object') raw = {};
397
+
398
+ const { payload, renames } = translateIn(args.client, args.event, raw);
399
+ const outcome = runChain(args.chain, payload);
400
+
401
+ if (args.client === 'cursor') {
402
+ process.stdout.write(JSON.stringify(cursorOut(args.event, outcome, renames)));
403
+ return 0;
404
+ }
405
+ if (args.client === 'agy') {
406
+ process.stdout.write(JSON.stringify(agyOut(args.event, outcome, renames)));
407
+ return 0;
408
+ }
409
+ const { body, denyReason } = geminiOut(args.event, outcome, renames, payload.tool_input);
410
+ process.stdout.write(JSON.stringify(body));
411
+ // Gemini honours both channels for BeforeTool; exit 2 + stderr is the one it
412
+ // reports verbatim to the model.
413
+ if (denyReason && args.event === 'BeforeTool') {
414
+ process.stderr.write(denyReason + '\n');
415
+ return 2;
416
+ }
417
+ return 0;
418
+ }
419
+
420
+ if (require.main === module) {
421
+ let code = 0;
422
+ try {
423
+ code = main();
424
+ } catch (_) {
425
+ process.stdout.write('{}');
426
+ code = 0;
427
+ }
428
+ process.exit(code);
429
+ }
430
+
431
+ module.exports = { parseArgs, splitChain, tokenize, translateIn, runChain, cursorOut, geminiOut, agyOut, asciiReason };
package/src/hook.mjs CHANGED
@@ -1,49 +1,49 @@
1
- #!/usr/bin/env node
2
- // hook — the process Claude Code runs for a Baton session's hooks and status
3
- // line (wired by src/taps/claude.mjs through `--settings`). Reads the JSON
4
- // payload on stdin, updates the session record, exits 0 always: a broken hook
5
- // must never stall the user's session.
6
- // node hook.mjs claude-hook --session <id>
7
- // node hook.mjs claude-statusline --session <id>
8
- // The status line entry records rate_limits when a Claude Code build runs it
9
- // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Baton line.
10
- import { appendFileSync } from 'node:fs'
11
- import { join } from 'node:path'
12
- import { handleHook, handleStatusline } from './taps/claude.mjs'
13
- import { sessionDir } from './sessions.mjs'
14
- import { captureLive } from './live-capture.mjs'
15
-
16
- function readStdin() {
17
- return new Promise((resolve) => {
18
- let d = ''
19
- const t = setTimeout(() => resolve(d), 4000)
20
- process.stdin.setEncoding('utf8')
21
- process.stdin.on('data', (c) => { d += c })
22
- process.stdin.on('end', () => { clearTimeout(t); resolve(d) })
23
- process.stdin.on('error', () => { clearTimeout(t); resolve(d) })
24
- })
25
- }
26
-
27
- const [kind, ...rest] = process.argv.slice(2)
28
- const i = rest.indexOf('--session')
29
- const sessionId = i !== -1 ? rest[i + 1] : null
30
-
31
- try {
32
- const raw = await readStdin()
33
- let payload = {}
34
- try { payload = JSON.parse(raw) } catch {}
35
- if (!sessionId) process.exit(0)
36
- if (kind === 'claude-hook') {
37
- const line = handleHook(sessionId, payload)
38
- try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} ${payload.hook_event_name ?? '?'} ${line}\n`) } catch {}
39
- // the first real StopFailure per error kind is kept as evidence (never a simulated one)
40
- if (payload.hook_event_name === 'StopFailure' && payload.error) {
41
- try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
42
- }
43
- } else if (kind === 'claude-statusline') {
44
- const { text } = handleStatusline(sessionId, payload)
45
- try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
46
- process.stdout.write(text + '\n')
47
- }
48
- } catch {}
49
- process.exit(0)
1
+ #!/usr/bin/env node
2
+ // hook — the process Claude Code runs for a Leg session's hooks and status
3
+ // line (wired by src/taps/claude.mjs through `--settings`). Reads the JSON
4
+ // payload on stdin, updates the session record, exits 0 always: a broken hook
5
+ // must never stall the user's session.
6
+ // node hook.mjs claude-hook --session <id>
7
+ // node hook.mjs claude-statusline --session <id>
8
+ // The status line entry records rate_limits when a Claude Code build runs it
9
+ // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Leg line.
10
+ import { appendFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { handleHook, handleStatusline } from './taps/claude.mjs'
13
+ import { sessionDir } from './sessions.mjs'
14
+ import { captureLive } from './live-capture.mjs'
15
+
16
+ function readStdin() {
17
+ return new Promise((resolve) => {
18
+ let d = ''
19
+ const t = setTimeout(() => resolve(d), 4000)
20
+ process.stdin.setEncoding('utf8')
21
+ process.stdin.on('data', (c) => { d += c })
22
+ process.stdin.on('end', () => { clearTimeout(t); resolve(d) })
23
+ process.stdin.on('error', () => { clearTimeout(t); resolve(d) })
24
+ })
25
+ }
26
+
27
+ const [kind, ...rest] = process.argv.slice(2)
28
+ const i = rest.indexOf('--session')
29
+ const sessionId = i !== -1 ? rest[i + 1] : null
30
+
31
+ try {
32
+ const raw = await readStdin()
33
+ let payload = {}
34
+ try { payload = JSON.parse(raw) } catch {}
35
+ if (!sessionId) process.exit(0)
36
+ if (kind === 'claude-hook') {
37
+ const line = handleHook(sessionId, payload)
38
+ try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} ${payload.hook_event_name ?? '?'} ${line}\n`) } catch {}
39
+ // the first real StopFailure per error kind is kept as evidence (never a simulated one)
40
+ if (payload.hook_event_name === 'StopFailure' && payload.error) {
41
+ try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
42
+ }
43
+ } else if (kind === 'claude-statusline') {
44
+ const { text } = handleStatusline(sessionId, payload)
45
+ try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
46
+ process.stdout.write(text + '\n')
47
+ }
48
+ } catch {}
49
+ process.exit(0)