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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- 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
|
+
}
|