acuvo-code 0.2.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/hooks.mjs ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * ── ⭐⭐⭐ LIFECYCLE HOOKS — SHELL COMMANDS AROUND THE TOOL LOOP ─────────────
3
+ *
4
+ * **Every policy this CLI had was a sentence in a prompt asking the model
5
+ * nicely.** That is the defect. The package has real enforcement in two narrow
6
+ * places — `policy.mjs` withholds a tool from the OFFER, and `command.mjs`
7
+ * refuses a program at the SPAWN — and neither of them is reachable by the
8
+ * person who actually has the rule. A team that wants
9
+ *
10
+ * · format every file the agent writes
11
+ * · never let it touch `infra/` or `migrations/`
12
+ * · run the linter after each edit and tell the model what broke
13
+ * · ping me when a session ends
14
+ *
15
+ * had nowhere to put any of it, and the only remaining lever was more prose in
16
+ * the system prompt — which is a request, not a control. Claude Code ships
17
+ * hooks and it is one of the concrete reasons teams adopt an agent: the agent
18
+ * runs inside THEIR rules rather than the vendor's.
19
+ *
20
+ * ── ⭐ THE ONE BEHAVIOUR EVERYTHING ELSE IS SUBORDINATE TO ──────────────────
21
+ *
22
+ * **A `PreToolUse` hook that exits non-zero BLOCKS the tool call.** A hook that
23
+ * can observe but not refuse is a logger, and a logger is not a policy. The
24
+ * refusal is handed back as an ordinary failed tool record, so the model reads
25
+ * it the same way it reads any other refusal — and it carries the hook's own
26
+ * stderr, because "blocked" with no reason is a wall the model will walk into
27
+ * again on the next round at full token price.
28
+ *
29
+ * ── ⚠️⚠️ AND THE FAILURE MODE THIS FILE IS MOSTLY DEFENDING AGAINST ─────────
30
+ *
31
+ * This repository's signature defect is capability that is built and never
32
+ * reached (`wiring-reach.test.mjs` exists because 39% of the package was once
33
+ * imported by nothing). The hook-shaped version of that defect is **a gate that
34
+ * silently lets everything through**: an event name with a typo, a `tools:`
35
+ * entry that matches nothing, a command that is not installed on this machine,
36
+ * a hook that hangs and gets killed. Each one leaves the user believing they
37
+ * have a control they do not have — which is strictly worse than having no
38
+ * hooks, because they stop watching.
39
+ *
40
+ * So, stated as rules:
41
+ *
42
+ * 1. A configuration mistake is REFUSED AT PARSE TIME with the typo quoted.
43
+ * Nothing is dropped, ignored, or clamped into something else.
44
+ * 2. A hook that CANNOT BE RUN (spawn failure, timeout) is an ERROR, and for
45
+ * `PreToolUse` it BLOCKS. A gate that could not answer has not said yes.
46
+ * 3. A hook that ran and failed is announced on `onEvent` every time. There
47
+ * is no path through this file where a non-zero exit produces silence.
48
+ *
49
+ * ── ⚠️ WHY EVERY HOOK IS SPAWNED WITH A TIMEOUT ────────────────────────────
50
+ *
51
+ * A hook is somebody's shell command running INSIDE the agent's tool loop, on
52
+ * the critical path of every single tool call. Without a bound, the ordinary
53
+ * accidents — `git commit` opening `$EDITOR`, an `npm install` stopping on an
54
+ * audit prompt, a linter walking `node_modules`, a `curl` to a host that
55
+ * blackholes — stop the agent forever with nothing on screen, and the agent has
56
+ * no way to tell a slow hook from a hung one. `spawnBounded` already owns that
57
+ * problem for `run_command`: a timer, a process-TREE kill (so what the hook
58
+ * spawned dies too), and a settle grace so the promise cannot be left pending.
59
+ * Hooks reuse it rather than growing a second, unaudited spawner.
60
+ *
61
+ * ⭐ 30 SECONDS BY DEFAULT, and the number is chosen rather than round:
62
+ * `prettier` on a large file and `eslint` on a package are single-digit
63
+ * seconds, `tsc --noEmit` on a medium repo is tens — so the default has to
64
+ * clear a formatter comfortably while still being an interval a person will sit
65
+ * through per tool call. Anything longer is a build, and a build belongs in
66
+ * `check_acceptance`, not in a gate that runs before every write.
67
+ *
68
+ * ── ⚠️ THE SUPPLY-CHAIN NOTE, STATED RATHER THAN IMPLIED ────────────────────
69
+ *
70
+ * `.acuvo/hooks.json` is arbitrary shell that this CLI executes. It sits under
71
+ * `.acuvo/`, which `acuvo-dir.mjs` makes SELF-IGNORING (`*` in its own
72
+ * `.gitignore`), so it is a local file by construction and a `git clone` does
73
+ * not normally carry one. That is the same trust boundary `.acuvo/commands.json`
74
+ * already lives on. It is deliberately NOT the same as `.mcp.json`, which is
75
+ * committed and which `turn.mjs` had to gate behind `--dry-run`/`--no-run`
76
+ * after exactly this class of finding.
77
+ *
78
+ * ⭐ PURE, INJECTABLE, AND SPAWNER-FREE ON THE PARSE PATH. Everything above
79
+ * `createHookRunner` is a function of its arguments, and the runner takes its
80
+ * spawner as `runImpl` — so the blocking behaviour, the timeout plumbing and
81
+ * the environment can all be proven without starting a process.
82
+ */
83
+
84
+ import { readFileSync } from 'node:fs';
85
+ import { join } from 'node:path';
86
+ import { spawnBounded, buildShellInvocation, scrubEnvironment } from './command.mjs';
87
+
88
+ /** Where a workspace declares its hooks. Beside `.acuvo/commands.json`. */
89
+ export const HOOKS_CONFIG_FILE = '.acuvo/hooks.json';
90
+
91
+ /**
92
+ * The three moments. Deliberately small:
93
+ * · `PreToolUse` — before a tool runs, and it can REFUSE.
94
+ * · `PostToolUse` — after a tool ran, with its outcome. Cannot refuse.
95
+ * · `Stop` — the session is over.
96
+ *
97
+ * ⚠️ NO `SessionStart`. A hook that fires before the workspace is gathered has
98
+ * nothing to be about, and every event added here is a spawn on somebody's
99
+ * critical path — the list grows when a real request names one, not before.
100
+ */
101
+ export const HOOK_EVENTS = Object.freeze(['PreToolUse', 'PostToolUse', 'Stop']);
102
+
103
+ /** See the timeout paragraph in the header for why 30s and not a round number. */
104
+ export const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
105
+
106
+ /**
107
+ * ⚠️ A CEILING, AND A CONFIG ABOVE IT IS REFUSED RATHER THAN CLAMPED. Silently
108
+ * clamping would leave someone believing their 10-minute hook is configured
109
+ * while it is killed at two — which is the "check that cannot fail" shape, one
110
+ * layer down. Two minutes is already four times the longest plausible gate.
111
+ */
112
+ export const MAX_HOOK_TIMEOUT_MS = 120_000;
113
+
114
+ /**
115
+ * ── ⚠️ THE ENVIRONMENT HAS A HARD KERNEL LIMIT, AND `write_file` WOULD HIT IT ─
116
+ *
117
+ * Linux caps a single `execve` argument/environment string at `MAX_ARG_STRLEN`
118
+ * (128 KB) and the whole block at roughly 2 MB; Windows caps the environment
119
+ * block at 32 KB *characters*. A `write_file` call carries the file's entire
120
+ * CONTENT in its arguments — the commonest thing anyone hooks — so passing
121
+ * arguments through verbatim would make every hook on the most-hooked tool die
122
+ * with `E2BIG` or a truncated block, on exactly the files that matter most.
123
+ *
124
+ * ⭐ 4,000 characters is enough for every path, command, URL and query this CLI
125
+ * passes, and the truncation is MARKED. Silent truncation is the bug class this
126
+ * package treats as the worst it can ship.
127
+ */
128
+ export const MAX_HOOK_ENV_CHARS = 4_000;
129
+
130
+ /**
131
+ * ⚠️ A CAP ON THE COUNT, because the timeout is PER HOOK. Thirty-two hooks at
132
+ * the default bound is a 16-minute worst case on a single tool call, which is a
133
+ * hang wearing a configuration's clothes. Nobody legitimately has more.
134
+ */
135
+ export const MAX_HOOKS = 32;
136
+
137
+ /* ── configuration ───────────────────────────────────────────────────────── */
138
+
139
+ /**
140
+ * Parse a `.acuvo/hooks.json` document.
141
+ *
142
+ * The accepted shape, kept flat on purpose — Claude Code's nested
143
+ * `{event: [{matcher, hooks: [{type, command}]}]}` has three levels of nesting
144
+ * to express one fact, and every extra level is another place a typo hides:
145
+ *
146
+ * ```json
147
+ * {
148
+ * "hooks": [
149
+ * { "event": "PreToolUse", "tools": ["write_file", "edit_file"],
150
+ * "command": "node .acuvo/guard.mjs" },
151
+ * { "event": "PostToolUse", "tools": ["write_file"],
152
+ * "command": "npx prettier --write \"$ACUVO_TOOL_ARG_PATH\"" },
153
+ * { "event": "Stop", "command": "notify-send 'acuvo finished'" }
154
+ * ]
155
+ * }
156
+ * ```
157
+ *
158
+ * ⚠️ MATCHING IS EXACT NAMES PLUS `*`, NOT A REGEX. A regex matcher is how you
159
+ * get the failure this repo already measured on its own classifiers — a pattern
160
+ * that looks right, matches nothing, and reports success by staying quiet. An
161
+ * exact name can be CHECKED against the tool list, which is what turns a typo
162
+ * from a silent no-op into the error below.
163
+ *
164
+ * @param {string} text
165
+ * @param {{ label?: string, knownTools?: string[]|null }} [opts]
166
+ * `knownTools` is the dispatcher's real tool list. Optional so this file has
167
+ * no import edge to `tools.mjs`; `turn.mjs` supplies `TOOL_NAMES`.
168
+ * @returns {{ ok: true, hooks: Array<object> } | { ok: false, error: string }}
169
+ */
170
+ export function parseHooksConfig(text, { label = HOOKS_CONFIG_FILE, knownTools = null } = {}) {
171
+ let doc;
172
+ try {
173
+ doc = JSON.parse(String(text ?? ''));
174
+ } catch (err) {
175
+ return { ok: false, error: `${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
176
+ }
177
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
178
+ return { ok: false, error: `${label} must be a JSON object with a "hooks" array` };
179
+ }
180
+ const raw = doc.hooks;
181
+ if (raw === undefined) return { ok: true, hooks: [] };
182
+ if (!Array.isArray(raw)) return { ok: false, error: `${label}: "hooks" must be an array` };
183
+ if (raw.length > MAX_HOOKS) {
184
+ return { ok: false, error: `${label} declares ${raw.length} hooks; the limit is ${MAX_HOOKS} (each one has its own timeout, so the worst case is the sum)` };
185
+ }
186
+
187
+ const known = Array.isArray(knownTools) && knownTools.length > 0 ? new Set(knownTools) : null;
188
+ const hooks = [];
189
+ for (const [i, entry] of raw.entries()) {
190
+ const at = `${label} hook #${i + 1}`;
191
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
192
+ return { ok: false, error: `${at} must be an object` };
193
+ }
194
+ const event = entry.event;
195
+ if (!HOOK_EVENTS.includes(event)) {
196
+ /**
197
+ * ⚠️ THE TYPO IS QUOTED BACK. `"PreToolCall"` silently registering nothing
198
+ * is the exact silent-gate failure this file exists to refuse, and a user
199
+ * reading "unknown event" without seeing their own string will look in the
200
+ * wrong place first.
201
+ */
202
+ return { ok: false, error: `${at}: "${String(event)}" is not a hook event — the events are ${HOOK_EVENTS.join(', ')}` };
203
+ }
204
+ const command = typeof entry.command === 'string' ? entry.command.trim() : '';
205
+ if (!command) return { ok: false, error: `${at} has no "command" — a hook with nothing to run is a control that protects nothing` };
206
+
207
+ let tools = null;
208
+ if (entry.tools !== undefined) {
209
+ if (event === 'Stop') {
210
+ // No tool is in flight at session stop, so a `tools` filter here would
211
+ // read as "only when the session ends after write_file" and mean nothing.
212
+ return { ok: false, error: `${at}: a Stop hook has no tool to match, so "tools" cannot apply to it` };
213
+ }
214
+ if (!Array.isArray(entry.tools) || entry.tools.some((t) => typeof t !== 'string' || !t.trim())) {
215
+ return { ok: false, error: `${at}: "tools" must be an array of tool names, or ["*"] for every tool` };
216
+ }
217
+ tools = entry.tools.map((t) => t.trim());
218
+ if (known) {
219
+ for (const t of tools) {
220
+ /**
221
+ * ⚠️ A NAMESPACED NAME IS AN MCP TOOL AND IS NOT IN OUR LIST. `turn.mjs`
222
+ * routes `server.tool` to the MCP connection BEFORE the local
223
+ * dispatcher, so those names are real, are hookable, and can never
224
+ * appear in `TOOL_NAMES`. Checking them against it would refuse
225
+ * correct configuration — worse than not checking at all.
226
+ */
227
+ if (t === '*' || t.includes('.')) continue;
228
+ if (!known.has(t)) {
229
+ return { ok: false, error: `${at}: "${t}" is not a tool this CLI has — a hook on a name that never fires protects nothing. Tools: ${[...known].join(', ')}` };
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ let timeoutMs = DEFAULT_HOOK_TIMEOUT_MS;
236
+ if (entry.timeoutMs !== undefined) {
237
+ if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0 || entry.timeoutMs > MAX_HOOK_TIMEOUT_MS) {
238
+ return { ok: false, error: `${at}: "timeoutMs" must be between 1 and ${MAX_HOOK_TIMEOUT_MS} — a hook runs on the critical path of a tool call` };
239
+ }
240
+ timeoutMs = Math.floor(entry.timeoutMs);
241
+ }
242
+
243
+ hooks.push({
244
+ event,
245
+ tools,
246
+ command,
247
+ timeoutMs,
248
+ /** A short, stable handle for events and error text. */
249
+ label: typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : `${event} #${i + 1}`,
250
+ });
251
+ }
252
+ return { ok: true, hooks };
253
+ }
254
+
255
+ /**
256
+ * Read `.acuvo/hooks.json` from a workspace.
257
+ *
258
+ * ⚠️ THE THREE OUTCOMES ARE DISTINCT AND MUST STAY THAT WAY: absent (fine, no
259
+ * hooks), present-and-valid (hooks), present-and-broken (an ERROR the caller
260
+ * must surface). Collapsing the third into the first is how a user ends up
261
+ * running unhooked while believing they are gated — the silent pass, one layer
262
+ * above the runner.
263
+ *
264
+ * @param {{ root: string, readFileImpl?: (p: string) => string, knownTools?: string[]|null }} opts
265
+ */
266
+ export function loadHooks({ root, readFileImpl = (p) => readFileSync(p, 'utf8'), knownTools = null }) {
267
+ const path = HOOKS_CONFIG_FILE;
268
+ let text;
269
+ try {
270
+ text = readFileImpl(join(String(root ?? ''), HOOKS_CONFIG_FILE));
271
+ } catch (err) {
272
+ // ENOENT is "no hooks configured". Anything else — a permission error, a
273
+ // directory where a file should be — is a fact the user needs, not a silent
274
+ // fallback to unhooked.
275
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return { ok: true, found: false, hooks: [], path };
276
+ return { ok: false, found: true, hooks: [], path, error: `${path} could not be read: ${err instanceof Error ? err.message : String(err)}` };
277
+ }
278
+ const parsed = parseHooksConfig(text, { label: path, knownTools });
279
+ if (!parsed.ok) return { ok: false, found: true, hooks: [], path, error: parsed.error };
280
+ return { ok: true, found: true, hooks: parsed.hooks, path };
281
+ }
282
+
283
+ /**
284
+ * Which hooks fire for this event and tool, in file order.
285
+ *
286
+ * ⭐ FILE ORDER IS THE CONTRACT. Hooks are a chain of gates and people write
287
+ * them assuming the cheap one runs first; re-ordering them (by specificity, by
288
+ * name) would be a surprise nobody can see in the file they wrote.
289
+ *
290
+ * @param {Array<object>} hooks
291
+ * @param {'PreToolUse'|'PostToolUse'|'Stop'} event
292
+ * @param {string|null} toolName
293
+ */
294
+ export function hooksFor(hooks, event, toolName) {
295
+ return (Array.isArray(hooks) ? hooks : []).filter((h) => {
296
+ if (h.event !== event) return false;
297
+ if (event === 'Stop') return true;
298
+ // A hook with no `tools` key is a hook on everything — the same meaning as
299
+ // ["*"], and the shape most people write first.
300
+ if (h.tools === null) return true;
301
+ return h.tools.includes('*') || h.tools.includes(toolName);
302
+ });
303
+ }
304
+
305
+ /* ── what the command can see ────────────────────────────────────────────── */
306
+
307
+ /** Cap a value and SAY SO. See MAX_HOOK_ENV_CHARS for the kernel limit. */
308
+ function capped(value) {
309
+ const s = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
310
+ if (s.length <= MAX_HOOK_ENV_CHARS) return s;
311
+ return `${s.slice(0, MAX_HOOK_ENV_CHARS)}… [truncated, ${s.length} characters total]`;
312
+ }
313
+
314
+ /**
315
+ * Turn an argument key into an environment variable name.
316
+ * `path` → `ACUVO_TOOL_ARG_PATH`, `pullRequestTitle` → `ACUVO_TOOL_ARG_PULLREQUESTTITLE`.
317
+ *
318
+ * ⚠️ NON-ALPHANUMERIC BECOMES `_`, and a key that reduces to nothing is DROPPED
319
+ * rather than exported as `ACUVO_TOOL_ARG_` — an empty-named variable is
320
+ * rejected by `execve` on POSIX and would fail the spawn of every hook on that
321
+ * tool, turning one odd argument name into "hooks are broken".
322
+ */
323
+ function argEnvName(key) {
324
+ const cleaned = String(key).replace(/[^A-Za-z0-9]/g, '_').replace(/^_+|_+$/g, '').toUpperCase();
325
+ return cleaned ? `ACUVO_TOOL_ARG_${cleaned}` : null;
326
+ }
327
+
328
+ /**
329
+ * The variables a hook command reads.
330
+ *
331
+ * ⭐ WHY THE ENVIRONMENT AND NOT STDIN. `spawnBounded` opens the child with
332
+ * `stdio: ['ignore', 'pipe', 'pipe']` — there is no stdin to write to, and
333
+ * changing that would mean a second settle path in the one spawner whose
334
+ * timeout/tree-kill logic everything else in this package depends on. The
335
+ * environment reaches a `cmd.exe` one-liner and a `sh -c` one-liner identically,
336
+ * which is what a cross-platform hook needs.
337
+ *
338
+ * ⭐ AND WHY THE SCALARS ARE BROKEN OUT SEPARATELY. `ACUVO_TOOL_ARGS` is the
339
+ * complete, honest record — but the hook people actually write is
340
+ * `npx prettier --write "$ACUVO_TOOL_ARG_PATH"`, and neither `sh` nor `cmd` has
341
+ * a JSON parser. Requiring `jq` would make the feature unusable on most of the
342
+ * machines it is for.
343
+ *
344
+ * ⚠️ OBJECT AND ARRAY ARGUMENTS GET NO SCALAR. `ACUVO_TOOL_ARG_HEADERS='[object
345
+ * Object]'` is a string that looks like data and is not, and a shell has no way
346
+ * to tell. They are in `ACUVO_TOOL_ARGS` in full, which is the honest place.
347
+ */
348
+ export function hookEnvironment({ event, toolName = null, args = null, result = null, session = null, root = '' } = {}) {
349
+ /** @type {Record<string,string>} */
350
+ const env = { ACUVO_HOOK_EVENT: String(event) };
351
+ if (root) env.ACUVO_WORKSPACE_ROOT = String(root);
352
+
353
+ if (toolName) env.ACUVO_TOOL_NAME = String(toolName);
354
+ if (args !== null && typeof args === 'object') {
355
+ env.ACUVO_TOOL_ARGS = capped(JSON.stringify(args));
356
+ for (const [k, v] of Object.entries(args)) {
357
+ if (v === null || v === undefined) continue;
358
+ if (typeof v === 'object') continue;
359
+ const name = argEnvName(k);
360
+ if (name) env[name] = capped(String(v));
361
+ }
362
+ }
363
+ if (result !== null && typeof result === 'object') {
364
+ env.ACUVO_TOOL_OK = result.ok === true ? '1' : '0';
365
+ if (typeof result.error === 'string' && result.error) env.ACUVO_TOOL_ERROR = capped(result.error);
366
+ }
367
+ if (session !== null && typeof session === 'object') {
368
+ env.ACUVO_SESSION_OK = session.ok === true ? '1' : '0';
369
+ if (session.stoppedBecause != null) env.ACUVO_SESSION_STOPPED_BECAUSE = String(session.stoppedBecause);
370
+ if (Number.isFinite(session.roundsUsed)) env.ACUVO_SESSION_ROUNDS = String(session.roundsUsed);
371
+ }
372
+ return env;
373
+ }
374
+
375
+ /* ── the runner ──────────────────────────────────────────────────────────── */
376
+
377
+ /** The tail of whatever the hook said, for the model and for the terminal. */
378
+ function outputOf(res) {
379
+ const text = `${res?.stderr ?? ''}${res?.stdout ?? ''}`.trim();
380
+ return text.length > 600 ? `…${text.slice(-600)}` : text;
381
+ }
382
+
383
+ /**
384
+ * Build the per-session hook runner.
385
+ *
386
+ * @param {{
387
+ * hooks?: Array<object>,
388
+ * root?: string,
389
+ * onEvent?: (e: any) => void,
390
+ * runImpl?: (spec: {file: string, args: string[], cwd: string, timeoutMs: number, env: Record<string,string>}) => Promise<any>,
391
+ * baseEnv?: Record<string, string|undefined>,
392
+ * platform?: string,
393
+ * }} opts
394
+ */
395
+ export function createHookRunner({
396
+ hooks = [],
397
+ root = process.cwd(),
398
+ onEvent = () => {},
399
+ /**
400
+ * ⚠️ `spawnBounded` BY DEFAULT AND NOT A SECOND SPAWNER. It is the function
401
+ * that owns the timeout, the process-TREE kill, the output caps and the
402
+ * settle grace — the four things that stand between "a hook" and "the agent
403
+ * is hung and nobody knows why". Injectable so the blocking behaviour can be
404
+ * proven without starting a process.
405
+ */
406
+ runImpl = null,
407
+ baseEnv = process.env,
408
+ platform = process.platform,
409
+ } = {}) {
410
+ const list = Array.isArray(hooks) ? hooks : [];
411
+ const run = runImpl ?? ((spec) => spawnBounded(spec));
412
+
413
+ /**
414
+ * Run one hook and classify the outcome into the only three things a caller
415
+ * can act on.
416
+ *
417
+ * ⚠️ `error` AND `failed` ARE DIFFERENT AND MUST NOT BE MERGED. `failed` is
418
+ * the hook DECIDING — a linter found problems, a guard refused a path — and
419
+ * for `PreToolUse` that decision is the product. `error` is the hook never
420
+ * getting to decide: not installed, could not start, killed at the timeout.
421
+ * Both stop a `PreToolUse` call, but only `error` is a configuration problem
422
+ * the USER has to fix, and rendering them identically would send someone
423
+ * hunting a policy bug when their `$PATH` is wrong.
424
+ *
425
+ * @returns {Promise<{ kind: 'ok'|'failed'|'error', hook: object, output: string, error?: string, exitCode?: number|null, durationMs?: number }>}
426
+ */
427
+ const runOne = async (hook, env) => {
428
+ const invocation = buildShellInvocation(hook.command, { platform, env: baseEnv });
429
+ if (!invocation.ok) return { kind: 'error', hook, output: '', error: invocation.error };
430
+
431
+ let res;
432
+ try {
433
+ res = await run({
434
+ file: invocation.file,
435
+ args: invocation.args,
436
+ cwd: root,
437
+ // ⚠️ ALWAYS A NUMBER. `spawnBounded` arms its timer from this value; an
438
+ // undefined here is an unbounded child, which is the hang this whole
439
+ // module promises cannot happen.
440
+ timeoutMs: hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
441
+ /**
442
+ * ⚠️ SCRUBBED, THEN OUR VARIABLES ON TOP. A hook is a user's command,
443
+ * but it is spawned by a process holding `OPENROUTER_API_KEY` — and
444
+ * `scrubEnvironment` is the one audited place that knows which names
445
+ * must never survive a spawn. Composing here rather than passing
446
+ * `process.env` keeps hooks on the same footing as `run_command`.
447
+ */
448
+ env: { ...scrubEnvironment(baseEnv), ...env },
449
+ });
450
+ } catch (err) {
451
+ // A spawner that THROWS is a broken spawner, and a hook that cannot be
452
+ // run must never be reported as a hook that passed.
453
+ return { kind: 'error', hook, output: '', error: err instanceof Error ? err.message : String(err) };
454
+ }
455
+
456
+ if (!res || res.ok !== true) {
457
+ return { kind: 'error', hook, output: '', error: res?.error ?? 'the hook could not be started' };
458
+ }
459
+ if (res.timedOut === true) {
460
+ return {
461
+ kind: 'error', hook, output: outputOf(res), durationMs: res.durationMs ?? null,
462
+ error: `the hook timed out after ${hook.timeoutMs}ms and was killed`,
463
+ };
464
+ }
465
+ if (res.exitCode === 0) {
466
+ return { kind: 'ok', hook, output: outputOf(res), exitCode: 0, durationMs: res.durationMs ?? null };
467
+ }
468
+ return { kind: 'failed', hook, output: outputOf(res), exitCode: res.exitCode ?? null, durationMs: res.durationMs ?? null };
469
+ };
470
+
471
+ /** One announcement shape, so a JSON consumer and the terminal see the same fact. */
472
+ const announce = (outcome, { event, tool = null, blocked = false }) => {
473
+ if (outcome.kind === 'error') {
474
+ onEvent({ type: 'hook-error', event, tool, hook: outcome.hook.label, command: outcome.hook.command, error: outcome.error, output: outcome.output, blocked });
475
+ return;
476
+ }
477
+ onEvent({
478
+ type: 'hook',
479
+ event,
480
+ tool,
481
+ hook: outcome.hook.label,
482
+ command: outcome.hook.command,
483
+ ok: outcome.kind === 'ok',
484
+ blocked,
485
+ exitCode: outcome.exitCode ?? null,
486
+ output: outcome.output,
487
+ durationMs: outcome.durationMs ?? null,
488
+ });
489
+ };
490
+
491
+ /** Read `{ id, function: { name, arguments } }` without ever throwing on it. */
492
+ const readCall = (call) => {
493
+ const name = call?.function?.name ?? call?.name ?? null;
494
+ let args = call?.args ?? null;
495
+ if (args === null) {
496
+ try { args = JSON.parse(call?.function?.arguments || '{}'); } catch { args = {}; }
497
+ }
498
+ return { id: call?.id ?? null, name, args };
499
+ };
500
+
501
+ return {
502
+ /** True when this workspace configured anything at all. */
503
+ enabled: list.length > 0,
504
+ count: list.length,
505
+ hooks: list,
506
+
507
+ /**
508
+ * ⭐⭐⭐ THE GATE. Runs every matching `PreToolUse` hook in order and, on the
509
+ * first refusal, hands back a ready-made failed tool record for the caller
510
+ * to push in place of the call.
511
+ *
512
+ * ⚠️ THE RECORD IS BUILT HERE ON PURPOSE. `turn.mjs` pushes tool records
513
+ * onto `executed`, feeds them to `toolResultText`, and counts `mutated` for
514
+ * the "N files written" line. A blocked call must look like every other
515
+ * refusal — `ok: false`, `mutated: false`, an error the model can read —
516
+ * or one of those three consumers grows a special case for hooks and
517
+ * eventually disagrees with the other two.
518
+ */
519
+ async before(call) {
520
+ const { id, name, args } = readCall(call);
521
+ const matching = hooksFor(list, 'PreToolUse', name);
522
+ if (matching.length === 0) return { ok: true };
523
+
524
+ const env = hookEnvironment({ event: 'PreToolUse', toolName: name, args, root });
525
+ for (const hook of matching) {
526
+ const outcome = await runOne(hook, env);
527
+ if (outcome.kind === 'ok') {
528
+ announce(outcome, { event: 'PreToolUse', tool: name });
529
+ continue;
530
+ }
531
+ /**
532
+ * ⚠️⚠️ AN ERROR BLOCKS TOO, AND THIS IS THE DECISION THE WHOLE MODULE
533
+ * TURNS ON. A `PreToolUse` hook exists to answer one question. If it
534
+ * could not be started or had to be killed, it did not answer — and
535
+ * treating "no answer" as "yes" is precisely the silent gate this file
536
+ * was written to make impossible. The cost is stated: a typo in the
537
+ * command halts the agent's tool calls. That is the loud failure, and it
538
+ * is the one a user can fix in ten seconds; the quiet one costs them the
539
+ * policy they thought they had.
540
+ */
541
+ announce(outcome, { event: 'PreToolUse', tool: name, blocked: true });
542
+ const why = outcome.kind === 'error'
543
+ ? `could not run (${outcome.error})`
544
+ : `exited ${outcome.exitCode}`;
545
+ const said = outcome.output ? `\n${outcome.output}` : '';
546
+ return {
547
+ ok: false,
548
+ hook,
549
+ kind: outcome.kind,
550
+ output: outcome.output,
551
+ record: {
552
+ id,
553
+ name,
554
+ args,
555
+ mutated: false,
556
+ /**
557
+ * ⭐ THE HOOK'S OWN WORDS GO TO THE MODEL. "Blocked" with no reason
558
+ * is a wall it walks into again next round at full token price; the
559
+ * stderr of a guard script is usually the exact instruction needed
560
+ * ("infra/ is protected — ask a human"). And it is told this is a
561
+ * POLICY, so it stops retrying and reports instead.
562
+ */
563
+ result: {
564
+ ok: false,
565
+ blockedByHook: hook.label,
566
+ error: `blocked by the PreToolUse hook "${hook.label}" — it ${why}. `
567
+ + 'This is a workspace policy, not a transient failure: do not retry the same call. '
568
+ + `Do the work another way, or say that the policy stopped you.${said}`,
569
+ },
570
+ },
571
+ };
572
+ }
573
+ return { ok: true };
574
+ },
575
+
576
+ /**
577
+ * `PostToolUse`. Cannot block — the tool has already run and the file is
578
+ * already on disk — so its whole job is to be LOUD and to hand the failures
579
+ * back to the caller.
580
+ */
581
+ async after(record) {
582
+ const name = record?.name ?? null;
583
+ const matching = hooksFor(list, 'PostToolUse', name);
584
+ if (matching.length === 0) return { ok: true, failures: [] };
585
+
586
+ const env = hookEnvironment({
587
+ event: 'PostToolUse', toolName: name, args: record?.args ?? null, result: record?.result ?? null, root,
588
+ });
589
+ const failures = [];
590
+ for (const hook of matching) {
591
+ const outcome = await runOne(hook, env);
592
+ // ⚠️ NEVER `blocked: true` HERE. Claiming to have blocked something that
593
+ // already happened would be a false statement in the audit stream.
594
+ announce(outcome, { event: 'PostToolUse', tool: name, blocked: false });
595
+ if (outcome.kind !== 'ok') {
596
+ failures.push({ hook: hook.label, kind: outcome.kind, error: outcome.error ?? null, output: outcome.output, exitCode: outcome.exitCode ?? null });
597
+ }
598
+ }
599
+ return { ok: failures.length === 0, failures };
600
+ },
601
+
602
+ /**
603
+ * `Stop`. Fires where the session ends.
604
+ *
605
+ * ⚠️ IT MUST NEVER THROW AND NEVER CHANGE THE OUTCOME. A notifier that is
606
+ * not installed is not a reason to lose a completed run's summary, its
607
+ * cost, or its saved session — the failure is reported and the return value
608
+ * is advisory.
609
+ */
610
+ async stop(session) {
611
+ const matching = hooksFor(list, 'Stop', null);
612
+ if (matching.length === 0) return { ok: true, failures: [] };
613
+
614
+ const env = hookEnvironment({ event: 'Stop', session: session ?? null, root });
615
+ const failures = [];
616
+ for (const hook of matching) {
617
+ const outcome = await runOne(hook, env);
618
+ announce(outcome, { event: 'Stop', tool: null, blocked: false });
619
+ if (outcome.kind !== 'ok') {
620
+ failures.push({ hook: hook.label, kind: outcome.kind, error: outcome.error ?? null, output: outcome.output, exitCode: outcome.exitCode ?? null });
621
+ }
622
+ }
623
+ return { ok: failures.length === 0, failures };
624
+ },
625
+ };
626
+ }