toolfunnel 0.7.0 → 0.7.1

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 (67) hide show
  1. package/README.md +228 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +451 -451
  4. package/docs/KNOWN_BUGS.md +80 -80
  5. package/docs/design.md +84 -84
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +39 -39
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -147
  12. package/mcp/servers/mock-upstream/server.js +380 -380
  13. package/package.json +67 -67
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +353 -353
  17. package/src/core/child-reaper.js +61 -61
  18. package/src/core/config-home.js +134 -134
  19. package/src/core/events.js +105 -105
  20. package/src/core/hook-engine.js +329 -329
  21. package/src/core/hook-loader.js +824 -824
  22. package/src/core/hook-runner.js +566 -566
  23. package/src/core/logger.js +297 -297
  24. package/src/core/matcher.js +100 -100
  25. package/src/core/metrics.js +75 -75
  26. package/src/core/rename-migration.js +163 -163
  27. package/src/core/requires.js +138 -138
  28. package/src/core/server-config.js +98 -98
  29. package/src/extend/add-hook.md +152 -152
  30. package/src/extend/add-mcp.md +119 -119
  31. package/src/extend/configure.md +88 -88
  32. package/src/extend/create-tool.md +168 -168
  33. package/src/extend/howto.js +102 -102
  34. package/src/extend/package.md +190 -190
  35. package/src/extend/wrap.md +99 -99
  36. package/src/mcp/aggregator.js +1719 -1674
  37. package/src/mcp/expose-store.js +577 -577
  38. package/src/mcp/gated-run.js +210 -210
  39. package/src/mcp/http-transport.js +1563 -1563
  40. package/src/mcp/mcp-client.js +1070 -1070
  41. package/src/mcp/modern.js +406 -406
  42. package/src/mcp/protocol.js +468 -468
  43. package/src/mcp/server.js +3112 -3112
  44. package/src/tools/registry.js +661 -661
  45. package/src/tools/tool-state.js +170 -170
  46. package/src/ui/public/app.js +1187 -1187
  47. package/src/ui/public/index.html +286 -286
  48. package/src/ui/public/styles.css +535 -535
  49. package/src/ui/server.js +1616 -1616
  50. package/tools/scripts/base64.js +139 -139
  51. package/tools/scripts/danger.js +61 -61
  52. package/tools/scripts/echo.js +39 -39
  53. package/tools/scripts/hash.js +101 -101
  54. package/tools/scripts/json.js +109 -109
  55. package/tools/scripts/text-stats.js +131 -131
  56. package/tools/scripts/tf-env.js +43 -43
  57. package/tools/scripts/tf-hook-add.js +151 -151
  58. package/tools/scripts/tf-hook-set.js +120 -120
  59. package/tools/scripts/tf-list.js +192 -192
  60. package/tools/scripts/tf-log.js +86 -86
  61. package/tools/scripts/tf-mcp-add.js +117 -117
  62. package/tools/scripts/tf-mcp-set.js +121 -121
  63. package/tools/scripts/tf-pack.js +237 -237
  64. package/tools/scripts/tf-tool-add.js +134 -134
  65. package/tools/scripts/tf-tool-set.js +142 -142
  66. package/tools/scripts/tf-wrap.js +103 -103
  67. package/tools/scripts/uuid.js +102 -102
@@ -1,329 +1,329 @@
1
- 'use strict';
2
-
3
- /**
4
- * hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
5
- *
6
- * Contract: fire() never rejects - see the class doc below.
7
- *
8
- * class HookEngine {
9
- * constructor(loader) // loader provides enabled hook specs
10
- * async fire(event, ctx, extra) -> {
11
- * injected: string, // all non-null inject fragments joined by "\n"
12
- * blocked: boolean, // any hook blocked
13
- * reason: string|null, // first block reason (in manifest order)
14
- * stopLoop: boolean, // any hook said continue:false
15
- * results: HookResult[] // runner-result shapes, in stable (manifest) order
16
- * }
17
- * }
18
- *
19
- * Behaviour:
20
- * - Select hooks where spec.event === event AND spec.enabled AND matches(spec.matcher, toolName).
21
- * Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore the matcher -
22
- * matcher.js handles that, but we also derive toolName only from the built payload so the
23
- * matcher gets exactly what the contract says it should see.
24
- * - A matcher that does not COMPILE is an unreadable gate: on a tool-bearing event the hook
25
- * contributes a synthetic BLOCKING result (its command never runs) - an unreadable gate is
26
- * never permission, mirroring the runner's unreadable-decision rule. Authoring surfaces
27
- * refuse such a matcher up front (hook-loader.addEntry), so this only fires for a
28
- * hand-edited manifest. (0.7.0; matcher-compile.test.js is the acceptance.)
29
- * - Build the stdin payload once via events.buildPayload(event, ctx, extra).
30
- * - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
31
- * (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
32
- * - injected = every non-null inject joined by "\n", in order.
33
- * - blocked/reason = the FIRST blocker in order.
34
- * - stopLoop = true if ANY hook returned continue:false.
35
- *
36
- * The runner never throws (its hard guarantee), and this engine defends against a misbehaving runner too:
37
- * any rejection is converted into a synthetic error result so fire() never rejects.
38
- *
39
- * CommonJS only. Zero host imports (must run headless under `node --test`).
40
- */
41
-
42
- const path = require('node:path');
43
-
44
- // Sibling core modules (authored to the same contract in this build pass).
45
- const matcher = require('./matcher');
46
- const events = require('./events');
47
- const hookRunner = require('./hook-runner');
48
-
49
- const DEFAULT_CONCURRENCY = 8;
50
-
51
- /**
52
- * Resolve the matcher function from matcher.js regardless of whether it is the default or
53
- * a named export, so we interlock with the contract's `matches(matcher, toolName)`.
54
- * @returns {(m: string|undefined, toolName: string) => boolean}
55
- */
56
- function getMatchesFn() {
57
- if (typeof matcher === 'function') return matcher;
58
- if (matcher && typeof matcher.matches === 'function') return matcher.matches;
59
- // Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
60
- return () => true;
61
- }
62
-
63
- /**
64
- * Resolve matcherError from matcher.js (named export). Falls back to "no problem" so an older
65
- * matcher module degrades to the previous behaviour rather than crashing the engine.
66
- * @returns {(m: string|undefined) => (string|null)}
67
- */
68
- function getMatcherErrorFn() {
69
- if (matcher && typeof matcher.matcherError === 'function') return matcher.matcherError;
70
- return () => null;
71
- }
72
-
73
- /**
74
- * Resolve buildPayload from events.js (named export per contract).
75
- * @returns {(event: string, ctx: object, extra: object) => object}
76
- */
77
- function getBuildPayloadFn() {
78
- if (events && typeof events.buildPayload === 'function') return events.buildPayload;
79
- // Defensive fallback: assemble a minimal common payload.
80
- return (event, ctx, extra) =>
81
- Object.assign(
82
- {
83
- session_id: (ctx && ctx.session_id) || '',
84
- transcript_path: (ctx && ctx.transcript_path) || '',
85
- cwd: (ctx && ctx.cwd) || process.cwd(),
86
- hook_event_name: event,
87
- },
88
- extra || {}
89
- );
90
- }
91
-
92
- /**
93
- * Resolve runHook from hook-runner.js (named export per contract).
94
- * @returns {(spec: object, payload: object, opts: object) => Promise<object>}
95
- */
96
- function getRunHookFn() {
97
- if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
98
- if (typeof hookRunner === 'function') return hookRunner;
99
- throw new Error('hook-engine: hook-runner.runHook is unavailable');
100
- }
101
-
102
- /**
103
- * Build a synthetic "failed" result matching the runner's result shape, used when the runner rejects
104
- * unexpectedly (it is contractually not supposed to, but we never let the loop throw).
105
- * @param {object} spec
106
- * @param {string} event
107
- * @param {Error} err
108
- * @param {number} durationMs
109
- * @returns {object}
110
- */
111
- function syntheticFailure(spec, event, err, durationMs) {
112
- return {
113
- id: spec && spec.id,
114
- event,
115
- exitCode: -1,
116
- timedOut: false,
117
- stdout: '',
118
- stderr: (err && err.message) || String(err),
119
- blocked: false,
120
- stopLoop: false,
121
- reason: null,
122
- inject: null,
123
- durationMs: durationMs || 0,
124
- };
125
- }
126
-
127
- /**
128
- * Run an array of async task factories with a concurrency cap, preserving input order in
129
- * the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
130
- * the resolved value (tasks here are written to never reject).
131
- *
132
- * @template T
133
- * @param {Array<() => Promise<T>>} taskFactories
134
- * @param {number} cap maximum simultaneous tasks
135
- * @returns {Promise<T[]>} results in the same order as taskFactories
136
- */
137
- async function runWithConcurrency(taskFactories, cap) {
138
- const total = taskFactories.length;
139
- const results = new Array(total);
140
- if (total === 0) return results;
141
-
142
- const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
143
- let nextIndex = 0;
144
-
145
- // Each worker pulls the next index until the queue drains.
146
- async function worker() {
147
- // eslint-disable-next-line no-constant-condition
148
- while (true) {
149
- const i = nextIndex++;
150
- if (i >= total) return;
151
- results[i] = await taskFactories[i]();
152
- }
153
- }
154
-
155
- const workers = [];
156
- for (let w = 0; w < limit; w++) workers.push(worker());
157
- await Promise.all(workers);
158
- return results;
159
- }
160
-
161
- class HookEngine {
162
- /**
163
- * @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
164
- * @param {object} [options]
165
- * @param {number} [options.concurrency=8] global concurrency cap per fire().
166
- * @param {string} [options.cwd] working dir passed to the runner.
167
- * @param {object} [options.env] base env merged into the runner's child env.
168
- */
169
- constructor(loader, options) {
170
- if (!loader || typeof loader.enabledHooksFor !== 'function') {
171
- throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
172
- }
173
- this.loader = loader;
174
- const opts = options || {};
175
- this.concurrency =
176
- Number.isInteger(opts.concurrency) && opts.concurrency > 0
177
- ? opts.concurrency
178
- : DEFAULT_CONCURRENCY;
179
- this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
180
- this.env = opts.env || null;
181
-
182
- // Resolve contract functions once.
183
- this._matches = getMatchesFn();
184
- this._matcherError = getMatcherErrorFn();
185
- this._buildPayload = getBuildPayloadFn();
186
- this._runHook = getRunHookFn();
187
- }
188
-
189
- /**
190
- * Fire a lifecycle event through its matching, enabled hooks.
191
- *
192
- * @param {string} event one of EVENTS (e.g. "PreToolUse")
193
- * @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
194
- * @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, ...)
195
- * @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
196
- * stopLoop:boolean, results:object[]}>}
197
- */
198
- async fire(event, ctx, extra) {
199
- const context = ctx || {};
200
- const extraFields = extra || {};
201
-
202
- // Build the stdin payload once (the contract's frozen field names).
203
- let payload;
204
- try {
205
- payload = this._buildPayload(event, context, extraFields);
206
- } catch (err) {
207
- // If payload assembly fails, fall back to a minimal valid payload so hooks still run.
208
- payload = Object.assign(
209
- {
210
- session_id: context.session_id || '',
211
- transcript_path: context.transcript_path || '',
212
- cwd: context.cwd || this.cwd,
213
- hook_event_name: event,
214
- },
215
- extraFields
216
- );
217
- }
218
-
219
- // The toolName for matcher scoping comes from the payload (tool-bearing events only).
220
- // For tool-less events this is undefined and matcher.js treats it as always-fire.
221
- const toolName = payload && payload.tool_name;
222
-
223
- // Resolve the child cwd/env once for the runner.
224
- const runCwd = (payload && payload.cwd) || this.cwd;
225
- const runOpts = { cwd: runCwd };
226
- if (this.env) runOpts.env = this.env;
227
-
228
- // Select enabled hooks for this event and build task factories, in MANIFEST ORDER.
229
- //
230
- // A matcher that does not COMPILE cannot be evaluated, and an unreadable gate is never
231
- // permission - the same rule the runner applies to an unreadable decision (gate-semantics /
232
- // matcher-compile tests). On a TOOL-BEARING event such a hook contributes a synthetic
233
- // BLOCKING result in its manifest position (its command is never run); authoring surfaces
234
- // refuse the pattern up front (hook-loader.addEntry), so this branch only fires for a
235
- // hand-edited manifest. Tool-less events ignore the matcher entirely (existing contract).
236
- const candidates = this.loader.enabledHooksFor(event) || [];
237
- const factories = [];
238
- for (const spec of candidates) {
239
- if (toolName !== undefined && toolName !== null) {
240
- let compileProblem = null;
241
- try {
242
- compileProblem = this._matcherError(spec && spec.matcher);
243
- } catch (_) {
244
- compileProblem = null; // discriminator itself failing must not take the engine down
245
- }
246
- if (compileProblem) {
247
- const unreadable = {
248
- id: spec && spec.id,
249
- event,
250
- exitCode: -1,
251
- timedOut: false,
252
- stdout: '',
253
- stderr: '',
254
- blocked: true,
255
- stopLoop: false,
256
- reason:
257
- `hook-engine: hook "${(spec && spec.id) || '(unnamed)'}" has a matcher that does ` +
258
- `not compile (${compileProblem}); its gate could not be read, so the call is ` +
259
- 'denied (fail closed). Fix or disable the hook.',
260
- inject: null,
261
- durationMs: 0,
262
- };
263
- factories.push(async () => unreadable);
264
- continue;
265
- }
266
- }
267
-
268
- let fires = false;
269
- try {
270
- fires = this._matches(spec && spec.matcher, toolName);
271
- } catch (_) {
272
- // A matcher evaluation crash should not crash the engine; skip that hook.
273
- fires = false;
274
- }
275
- if (!fires) continue;
276
-
277
- factories.push(async () => {
278
- const started = Date.now();
279
- try {
280
- const res = await this._runHook(spec, payload, runOpts);
281
- // Defensive: ensure the result carries id/event even if the runner omitted them.
282
- if (res && typeof res === 'object') {
283
- if (res.id === undefined) res.id = spec.id;
284
- if (res.event === undefined) res.event = event;
285
- return res;
286
- }
287
- return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
288
- } catch (err) {
289
- return syntheticFailure(spec, event, err, Date.now() - started);
290
- }
291
- });
292
- }
293
-
294
- // Run concurrently with the cap; results come back in manifest order.
295
- const results = await runWithConcurrency(factories, this.concurrency);
296
-
297
- // Aggregate the per-hook results into the engine outcome.
298
- const injectFragments = [];
299
- let blocked = false;
300
- let reason = null;
301
- let stopLoop = false;
302
-
303
- for (const r of results) {
304
- if (!r) continue;
305
- if (r.inject != null) injectFragments.push(r.inject);
306
- if (r.blocked && !blocked) {
307
- // First blocker in order owns the reason.
308
- blocked = true;
309
- reason = r.reason != null ? r.reason : null;
310
- }
311
- if (r.stopLoop) stopLoop = true;
312
- }
313
-
314
- return {
315
- injected: injectFragments.join('\n'),
316
- blocked,
317
- reason,
318
- stopLoop,
319
- results,
320
- };
321
- }
322
- }
323
-
324
- module.exports = {
325
- HookEngine,
326
- DEFAULT_CONCURRENCY,
327
- // Exported for unit tests / reuse:
328
- runWithConcurrency,
329
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
5
+ *
6
+ * Contract: fire() never rejects - see the class doc below.
7
+ *
8
+ * class HookEngine {
9
+ * constructor(loader) // loader provides enabled hook specs
10
+ * async fire(event, ctx, extra) -> {
11
+ * injected: string, // all non-null inject fragments joined by "\n"
12
+ * blocked: boolean, // any hook blocked
13
+ * reason: string|null, // first block reason (in manifest order)
14
+ * stopLoop: boolean, // any hook said continue:false
15
+ * results: HookResult[] // runner-result shapes, in stable (manifest) order
16
+ * }
17
+ * }
18
+ *
19
+ * Behaviour:
20
+ * - Select hooks where spec.event === event AND spec.enabled AND matches(spec.matcher, toolName).
21
+ * Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore the matcher -
22
+ * matcher.js handles that, but we also derive toolName only from the built payload so the
23
+ * matcher gets exactly what the contract says it should see.
24
+ * - A matcher that does not COMPILE is an unreadable gate: on a tool-bearing event the hook
25
+ * contributes a synthetic BLOCKING result (its command never runs) - an unreadable gate is
26
+ * never permission, mirroring the runner's unreadable-decision rule. Authoring surfaces
27
+ * refuse such a matcher up front (hook-loader.addEntry), so this only fires for a
28
+ * hand-edited manifest. (0.7.0; matcher-compile.test.js is the acceptance.)
29
+ * - Build the stdin payload once via events.buildPayload(event, ctx, extra).
30
+ * - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
31
+ * (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
32
+ * - injected = every non-null inject joined by "\n", in order.
33
+ * - blocked/reason = the FIRST blocker in order.
34
+ * - stopLoop = true if ANY hook returned continue:false.
35
+ *
36
+ * The runner never throws (its hard guarantee), and this engine defends against a misbehaving runner too:
37
+ * any rejection is converted into a synthetic error result so fire() never rejects.
38
+ *
39
+ * CommonJS only. Zero host imports (must run headless under `node --test`).
40
+ */
41
+
42
+ const path = require('node:path');
43
+
44
+ // Sibling core modules (authored to the same contract in this build pass).
45
+ const matcher = require('./matcher');
46
+ const events = require('./events');
47
+ const hookRunner = require('./hook-runner');
48
+
49
+ const DEFAULT_CONCURRENCY = 8;
50
+
51
+ /**
52
+ * Resolve the matcher function from matcher.js regardless of whether it is the default or
53
+ * a named export, so we interlock with the contract's `matches(matcher, toolName)`.
54
+ * @returns {(m: string|undefined, toolName: string) => boolean}
55
+ */
56
+ function getMatchesFn() {
57
+ if (typeof matcher === 'function') return matcher;
58
+ if (matcher && typeof matcher.matches === 'function') return matcher.matches;
59
+ // Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
60
+ return () => true;
61
+ }
62
+
63
+ /**
64
+ * Resolve matcherError from matcher.js (named export). Falls back to "no problem" so an older
65
+ * matcher module degrades to the previous behaviour rather than crashing the engine.
66
+ * @returns {(m: string|undefined) => (string|null)}
67
+ */
68
+ function getMatcherErrorFn() {
69
+ if (matcher && typeof matcher.matcherError === 'function') return matcher.matcherError;
70
+ return () => null;
71
+ }
72
+
73
+ /**
74
+ * Resolve buildPayload from events.js (named export per contract).
75
+ * @returns {(event: string, ctx: object, extra: object) => object}
76
+ */
77
+ function getBuildPayloadFn() {
78
+ if (events && typeof events.buildPayload === 'function') return events.buildPayload;
79
+ // Defensive fallback: assemble a minimal common payload.
80
+ return (event, ctx, extra) =>
81
+ Object.assign(
82
+ {
83
+ session_id: (ctx && ctx.session_id) || '',
84
+ transcript_path: (ctx && ctx.transcript_path) || '',
85
+ cwd: (ctx && ctx.cwd) || process.cwd(),
86
+ hook_event_name: event,
87
+ },
88
+ extra || {}
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Resolve runHook from hook-runner.js (named export per contract).
94
+ * @returns {(spec: object, payload: object, opts: object) => Promise<object>}
95
+ */
96
+ function getRunHookFn() {
97
+ if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
98
+ if (typeof hookRunner === 'function') return hookRunner;
99
+ throw new Error('hook-engine: hook-runner.runHook is unavailable');
100
+ }
101
+
102
+ /**
103
+ * Build a synthetic "failed" result matching the runner's result shape, used when the runner rejects
104
+ * unexpectedly (it is contractually not supposed to, but we never let the loop throw).
105
+ * @param {object} spec
106
+ * @param {string} event
107
+ * @param {Error} err
108
+ * @param {number} durationMs
109
+ * @returns {object}
110
+ */
111
+ function syntheticFailure(spec, event, err, durationMs) {
112
+ return {
113
+ id: spec && spec.id,
114
+ event,
115
+ exitCode: -1,
116
+ timedOut: false,
117
+ stdout: '',
118
+ stderr: (err && err.message) || String(err),
119
+ blocked: false,
120
+ stopLoop: false,
121
+ reason: null,
122
+ inject: null,
123
+ durationMs: durationMs || 0,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Run an array of async task factories with a concurrency cap, preserving input order in
129
+ * the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
130
+ * the resolved value (tasks here are written to never reject).
131
+ *
132
+ * @template T
133
+ * @param {Array<() => Promise<T>>} taskFactories
134
+ * @param {number} cap maximum simultaneous tasks
135
+ * @returns {Promise<T[]>} results in the same order as taskFactories
136
+ */
137
+ async function runWithConcurrency(taskFactories, cap) {
138
+ const total = taskFactories.length;
139
+ const results = new Array(total);
140
+ if (total === 0) return results;
141
+
142
+ const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
143
+ let nextIndex = 0;
144
+
145
+ // Each worker pulls the next index until the queue drains.
146
+ async function worker() {
147
+ // eslint-disable-next-line no-constant-condition
148
+ while (true) {
149
+ const i = nextIndex++;
150
+ if (i >= total) return;
151
+ results[i] = await taskFactories[i]();
152
+ }
153
+ }
154
+
155
+ const workers = [];
156
+ for (let w = 0; w < limit; w++) workers.push(worker());
157
+ await Promise.all(workers);
158
+ return results;
159
+ }
160
+
161
+ class HookEngine {
162
+ /**
163
+ * @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
164
+ * @param {object} [options]
165
+ * @param {number} [options.concurrency=8] global concurrency cap per fire().
166
+ * @param {string} [options.cwd] working dir passed to the runner.
167
+ * @param {object} [options.env] base env merged into the runner's child env.
168
+ */
169
+ constructor(loader, options) {
170
+ if (!loader || typeof loader.enabledHooksFor !== 'function') {
171
+ throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
172
+ }
173
+ this.loader = loader;
174
+ const opts = options || {};
175
+ this.concurrency =
176
+ Number.isInteger(opts.concurrency) && opts.concurrency > 0
177
+ ? opts.concurrency
178
+ : DEFAULT_CONCURRENCY;
179
+ this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
180
+ this.env = opts.env || null;
181
+
182
+ // Resolve contract functions once.
183
+ this._matches = getMatchesFn();
184
+ this._matcherError = getMatcherErrorFn();
185
+ this._buildPayload = getBuildPayloadFn();
186
+ this._runHook = getRunHookFn();
187
+ }
188
+
189
+ /**
190
+ * Fire a lifecycle event through its matching, enabled hooks.
191
+ *
192
+ * @param {string} event one of EVENTS (e.g. "PreToolUse")
193
+ * @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
194
+ * @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, ...)
195
+ * @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
196
+ * stopLoop:boolean, results:object[]}>}
197
+ */
198
+ async fire(event, ctx, extra) {
199
+ const context = ctx || {};
200
+ const extraFields = extra || {};
201
+
202
+ // Build the stdin payload once (the contract's frozen field names).
203
+ let payload;
204
+ try {
205
+ payload = this._buildPayload(event, context, extraFields);
206
+ } catch (err) {
207
+ // If payload assembly fails, fall back to a minimal valid payload so hooks still run.
208
+ payload = Object.assign(
209
+ {
210
+ session_id: context.session_id || '',
211
+ transcript_path: context.transcript_path || '',
212
+ cwd: context.cwd || this.cwd,
213
+ hook_event_name: event,
214
+ },
215
+ extraFields
216
+ );
217
+ }
218
+
219
+ // The toolName for matcher scoping comes from the payload (tool-bearing events only).
220
+ // For tool-less events this is undefined and matcher.js treats it as always-fire.
221
+ const toolName = payload && payload.tool_name;
222
+
223
+ // Resolve the child cwd/env once for the runner.
224
+ const runCwd = (payload && payload.cwd) || this.cwd;
225
+ const runOpts = { cwd: runCwd };
226
+ if (this.env) runOpts.env = this.env;
227
+
228
+ // Select enabled hooks for this event and build task factories, in MANIFEST ORDER.
229
+ //
230
+ // A matcher that does not COMPILE cannot be evaluated, and an unreadable gate is never
231
+ // permission - the same rule the runner applies to an unreadable decision (gate-semantics /
232
+ // matcher-compile tests). On a TOOL-BEARING event such a hook contributes a synthetic
233
+ // BLOCKING result in its manifest position (its command is never run); authoring surfaces
234
+ // refuse the pattern up front (hook-loader.addEntry), so this branch only fires for a
235
+ // hand-edited manifest. Tool-less events ignore the matcher entirely (existing contract).
236
+ const candidates = this.loader.enabledHooksFor(event) || [];
237
+ const factories = [];
238
+ for (const spec of candidates) {
239
+ if (toolName !== undefined && toolName !== null) {
240
+ let compileProblem = null;
241
+ try {
242
+ compileProblem = this._matcherError(spec && spec.matcher);
243
+ } catch (_) {
244
+ compileProblem = null; // discriminator itself failing must not take the engine down
245
+ }
246
+ if (compileProblem) {
247
+ const unreadable = {
248
+ id: spec && spec.id,
249
+ event,
250
+ exitCode: -1,
251
+ timedOut: false,
252
+ stdout: '',
253
+ stderr: '',
254
+ blocked: true,
255
+ stopLoop: false,
256
+ reason:
257
+ `hook-engine: hook "${(spec && spec.id) || '(unnamed)'}" has a matcher that does ` +
258
+ `not compile (${compileProblem}); its gate could not be read, so the call is ` +
259
+ 'denied (fail closed). Fix or disable the hook.',
260
+ inject: null,
261
+ durationMs: 0,
262
+ };
263
+ factories.push(async () => unreadable);
264
+ continue;
265
+ }
266
+ }
267
+
268
+ let fires = false;
269
+ try {
270
+ fires = this._matches(spec && spec.matcher, toolName);
271
+ } catch (_) {
272
+ // A matcher evaluation crash should not crash the engine; skip that hook.
273
+ fires = false;
274
+ }
275
+ if (!fires) continue;
276
+
277
+ factories.push(async () => {
278
+ const started = Date.now();
279
+ try {
280
+ const res = await this._runHook(spec, payload, runOpts);
281
+ // Defensive: ensure the result carries id/event even if the runner omitted them.
282
+ if (res && typeof res === 'object') {
283
+ if (res.id === undefined) res.id = spec.id;
284
+ if (res.event === undefined) res.event = event;
285
+ return res;
286
+ }
287
+ return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
288
+ } catch (err) {
289
+ return syntheticFailure(spec, event, err, Date.now() - started);
290
+ }
291
+ });
292
+ }
293
+
294
+ // Run concurrently with the cap; results come back in manifest order.
295
+ const results = await runWithConcurrency(factories, this.concurrency);
296
+
297
+ // Aggregate the per-hook results into the engine outcome.
298
+ const injectFragments = [];
299
+ let blocked = false;
300
+ let reason = null;
301
+ let stopLoop = false;
302
+
303
+ for (const r of results) {
304
+ if (!r) continue;
305
+ if (r.inject != null) injectFragments.push(r.inject);
306
+ if (r.blocked && !blocked) {
307
+ // First blocker in order owns the reason.
308
+ blocked = true;
309
+ reason = r.reason != null ? r.reason : null;
310
+ }
311
+ if (r.stopLoop) stopLoop = true;
312
+ }
313
+
314
+ return {
315
+ injected: injectFragments.join('\n'),
316
+ blocked,
317
+ reason,
318
+ stopLoop,
319
+ results,
320
+ };
321
+ }
322
+ }
323
+
324
+ module.exports = {
325
+ HookEngine,
326
+ DEFAULT_CONCURRENCY,
327
+ // Exported for unit tests / reuse:
328
+ runWithConcurrency,
329
+ };