toolfunnel 0.5.0 → 0.6.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 (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  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 +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
@@ -1,275 +1,275 @@
1
- 'use strict';
2
-
3
- /**
4
- * hook-engine.js — fire a lifecycle event through its matching, enabled hooks.
5
- *
6
- * Contract: HOOK_ENGINE.md §6.
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[] // per §4, 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
- * - Build the stdin payload once via events.buildPayload(event, ctx, extra).
25
- * - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
26
- * (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
27
- * - injected = every non-null inject joined by "\n", in order.
28
- * - blocked/reason = the FIRST blocker in order.
29
- * - stopLoop = true if ANY hook returned continue:false.
30
- *
31
- * The runner never throws (§4), and this engine defends against a misbehaving runner too:
32
- * any rejection is converted into a synthetic error result so fire() never rejects.
33
- *
34
- * CommonJS only. Zero host imports (must run headless under `node --test`).
35
- */
36
-
37
- const path = require('node:path');
38
-
39
- // Sibling core modules (authored to the same contract in this build pass).
40
- const matcher = require('./matcher');
41
- const events = require('./events');
42
- const hookRunner = require('./hook-runner');
43
-
44
- const DEFAULT_CONCURRENCY = 8;
45
-
46
- /**
47
- * Resolve the matcher function from matcher.js regardless of whether it is the default or
48
- * a named export, so we interlock with the contract's `matches(matcher, toolName)`.
49
- * @returns {(m: string|undefined, toolName: string) => boolean}
50
- */
51
- function getMatchesFn() {
52
- if (typeof matcher === 'function') return matcher;
53
- if (matcher && typeof matcher.matches === 'function') return matcher.matches;
54
- // Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
55
- return () => true;
56
- }
57
-
58
- /**
59
- * Resolve buildPayload from events.js (named export per contract).
60
- * @returns {(event: string, ctx: object, extra: object) => object}
61
- */
62
- function getBuildPayloadFn() {
63
- if (events && typeof events.buildPayload === 'function') return events.buildPayload;
64
- // Defensive fallback: assemble a minimal common payload.
65
- return (event, ctx, extra) =>
66
- Object.assign(
67
- {
68
- session_id: (ctx && ctx.session_id) || '',
69
- transcript_path: (ctx && ctx.transcript_path) || '',
70
- cwd: (ctx && ctx.cwd) || process.cwd(),
71
- hook_event_name: event,
72
- },
73
- extra || {}
74
- );
75
- }
76
-
77
- /**
78
- * Resolve runHook from hook-runner.js (named export per contract).
79
- * @returns {(spec: object, payload: object, opts: object) => Promise<object>}
80
- */
81
- function getRunHookFn() {
82
- if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
83
- if (typeof hookRunner === 'function') return hookRunner;
84
- throw new Error('hook-engine: hook-runner.runHook is unavailable');
85
- }
86
-
87
- /**
88
- * Build a synthetic "failed" result matching the §4 shape, used when the runner rejects
89
- * unexpectedly (it is contractually not supposed to, but we never let the loop throw).
90
- * @param {object} spec
91
- * @param {string} event
92
- * @param {Error} err
93
- * @param {number} durationMs
94
- * @returns {object}
95
- */
96
- function syntheticFailure(spec, event, err, durationMs) {
97
- return {
98
- id: spec && spec.id,
99
- event,
100
- exitCode: -1,
101
- timedOut: false,
102
- stdout: '',
103
- stderr: (err && err.message) || String(err),
104
- blocked: false,
105
- stopLoop: false,
106
- reason: null,
107
- inject: null,
108
- durationMs: durationMs || 0,
109
- };
110
- }
111
-
112
- /**
113
- * Run an array of async task factories with a concurrency cap, preserving input order in
114
- * the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
115
- * the resolved value (tasks here are written to never reject).
116
- *
117
- * @template T
118
- * @param {Array<() => Promise<T>>} taskFactories
119
- * @param {number} cap maximum simultaneous tasks
120
- * @returns {Promise<T[]>} results in the same order as taskFactories
121
- */
122
- async function runWithConcurrency(taskFactories, cap) {
123
- const total = taskFactories.length;
124
- const results = new Array(total);
125
- if (total === 0) return results;
126
-
127
- const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
128
- let nextIndex = 0;
129
-
130
- // Each worker pulls the next index until the queue drains.
131
- async function worker() {
132
- // eslint-disable-next-line no-constant-condition
133
- while (true) {
134
- const i = nextIndex++;
135
- if (i >= total) return;
136
- results[i] = await taskFactories[i]();
137
- }
138
- }
139
-
140
- const workers = [];
141
- for (let w = 0; w < limit; w++) workers.push(worker());
142
- await Promise.all(workers);
143
- return results;
144
- }
145
-
146
- class HookEngine {
147
- /**
148
- * @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
149
- * @param {object} [options]
150
- * @param {number} [options.concurrency=8] global concurrency cap per fire().
151
- * @param {string} [options.cwd] working dir passed to the runner.
152
- * @param {object} [options.env] base env merged into the runner's child env.
153
- */
154
- constructor(loader, options) {
155
- if (!loader || typeof loader.enabledHooksFor !== 'function') {
156
- throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
157
- }
158
- this.loader = loader;
159
- const opts = options || {};
160
- this.concurrency =
161
- Number.isInteger(opts.concurrency) && opts.concurrency > 0
162
- ? opts.concurrency
163
- : DEFAULT_CONCURRENCY;
164
- this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
165
- this.env = opts.env || null;
166
-
167
- // Resolve contract functions once.
168
- this._matches = getMatchesFn();
169
- this._buildPayload = getBuildPayloadFn();
170
- this._runHook = getRunHookFn();
171
- }
172
-
173
- /**
174
- * Fire a lifecycle event through its matching, enabled hooks.
175
- *
176
- * @param {string} event one of EVENTS (e.g. "PreToolUse")
177
- * @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
178
- * @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, …)
179
- * @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
180
- * stopLoop:boolean, results:object[]}>}
181
- */
182
- async fire(event, ctx, extra) {
183
- const context = ctx || {};
184
- const extraFields = extra || {};
185
-
186
- // Build the stdin payload once (frozen field names per §2).
187
- let payload;
188
- try {
189
- payload = this._buildPayload(event, context, extraFields);
190
- } catch (err) {
191
- // If payload assembly fails, fall back to a minimal valid payload so hooks still run.
192
- payload = Object.assign(
193
- {
194
- session_id: context.session_id || '',
195
- transcript_path: context.transcript_path || '',
196
- cwd: context.cwd || this.cwd,
197
- hook_event_name: event,
198
- },
199
- extraFields
200
- );
201
- }
202
-
203
- // The toolName for matcher scoping comes from the payload (tool-bearing events only).
204
- // For tool-less events this is undefined and matcher.js treats it as always-fire.
205
- const toolName = payload && payload.tool_name;
206
-
207
- // Select enabled hooks for this event whose matcher matches, in MANIFEST ORDER.
208
- const candidates = this.loader.enabledHooksFor(event) || [];
209
- const selected = candidates.filter((spec) => {
210
- try {
211
- return this._matches(spec && spec.matcher, toolName);
212
- } catch (_) {
213
- // A broken matcher string should not crash the engine; skip that hook.
214
- return false;
215
- }
216
- });
217
-
218
- // Resolve the child cwd/env once for the runner.
219
- const runCwd = (payload && payload.cwd) || this.cwd;
220
- const runOpts = { cwd: runCwd };
221
- if (this.env) runOpts.env = this.env;
222
-
223
- // Build task factories (one per selected hook) — never reject.
224
- const factories = selected.map((spec) => async () => {
225
- const started = Date.now();
226
- try {
227
- const res = await this._runHook(spec, payload, runOpts);
228
- // Defensive: ensure the result carries id/event even if the runner omitted them.
229
- if (res && typeof res === 'object') {
230
- if (res.id === undefined) res.id = spec.id;
231
- if (res.event === undefined) res.event = event;
232
- return res;
233
- }
234
- return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
235
- } catch (err) {
236
- return syntheticFailure(spec, event, err, Date.now() - started);
237
- }
238
- });
239
-
240
- // Run concurrently with the cap; results come back in manifest order.
241
- const results = await runWithConcurrency(factories, this.concurrency);
242
-
243
- // Aggregate per §6.
244
- const injectFragments = [];
245
- let blocked = false;
246
- let reason = null;
247
- let stopLoop = false;
248
-
249
- for (const r of results) {
250
- if (!r) continue;
251
- if (r.inject != null) injectFragments.push(r.inject);
252
- if (r.blocked && !blocked) {
253
- // First blocker in order owns the reason.
254
- blocked = true;
255
- reason = r.reason != null ? r.reason : null;
256
- }
257
- if (r.stopLoop) stopLoop = true;
258
- }
259
-
260
- return {
261
- injected: injectFragments.join('\n'),
262
- blocked,
263
- reason,
264
- stopLoop,
265
- results,
266
- };
267
- }
268
- }
269
-
270
- module.exports = {
271
- HookEngine,
272
- DEFAULT_CONCURRENCY,
273
- // Exported for unit tests / reuse:
274
- runWithConcurrency,
275
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
5
+ *
6
+ * Contract: HOOK_ENGINE.md §6.
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[] // per §4, 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
+ * - Build the stdin payload once via events.buildPayload(event, ctx, extra).
25
+ * - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
26
+ * (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
27
+ * - injected = every non-null inject joined by "\n", in order.
28
+ * - blocked/reason = the FIRST blocker in order.
29
+ * - stopLoop = true if ANY hook returned continue:false.
30
+ *
31
+ * The runner never throws (§4), and this engine defends against a misbehaving runner too:
32
+ * any rejection is converted into a synthetic error result so fire() never rejects.
33
+ *
34
+ * CommonJS only. Zero host imports (must run headless under `node --test`).
35
+ */
36
+
37
+ const path = require('node:path');
38
+
39
+ // Sibling core modules (authored to the same contract in this build pass).
40
+ const matcher = require('./matcher');
41
+ const events = require('./events');
42
+ const hookRunner = require('./hook-runner');
43
+
44
+ const DEFAULT_CONCURRENCY = 8;
45
+
46
+ /**
47
+ * Resolve the matcher function from matcher.js regardless of whether it is the default or
48
+ * a named export, so we interlock with the contract's `matches(matcher, toolName)`.
49
+ * @returns {(m: string|undefined, toolName: string) => boolean}
50
+ */
51
+ function getMatchesFn() {
52
+ if (typeof matcher === 'function') return matcher;
53
+ if (matcher && typeof matcher.matches === 'function') return matcher.matches;
54
+ // Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
55
+ return () => true;
56
+ }
57
+
58
+ /**
59
+ * Resolve buildPayload from events.js (named export per contract).
60
+ * @returns {(event: string, ctx: object, extra: object) => object}
61
+ */
62
+ function getBuildPayloadFn() {
63
+ if (events && typeof events.buildPayload === 'function') return events.buildPayload;
64
+ // Defensive fallback: assemble a minimal common payload.
65
+ return (event, ctx, extra) =>
66
+ Object.assign(
67
+ {
68
+ session_id: (ctx && ctx.session_id) || '',
69
+ transcript_path: (ctx && ctx.transcript_path) || '',
70
+ cwd: (ctx && ctx.cwd) || process.cwd(),
71
+ hook_event_name: event,
72
+ },
73
+ extra || {}
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Resolve runHook from hook-runner.js (named export per contract).
79
+ * @returns {(spec: object, payload: object, opts: object) => Promise<object>}
80
+ */
81
+ function getRunHookFn() {
82
+ if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
83
+ if (typeof hookRunner === 'function') return hookRunner;
84
+ throw new Error('hook-engine: hook-runner.runHook is unavailable');
85
+ }
86
+
87
+ /**
88
+ * Build a synthetic "failed" result matching the §4 shape, used when the runner rejects
89
+ * unexpectedly (it is contractually not supposed to, but we never let the loop throw).
90
+ * @param {object} spec
91
+ * @param {string} event
92
+ * @param {Error} err
93
+ * @param {number} durationMs
94
+ * @returns {object}
95
+ */
96
+ function syntheticFailure(spec, event, err, durationMs) {
97
+ return {
98
+ id: spec && spec.id,
99
+ event,
100
+ exitCode: -1,
101
+ timedOut: false,
102
+ stdout: '',
103
+ stderr: (err && err.message) || String(err),
104
+ blocked: false,
105
+ stopLoop: false,
106
+ reason: null,
107
+ inject: null,
108
+ durationMs: durationMs || 0,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Run an array of async task factories with a concurrency cap, preserving input order in
114
+ * the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
115
+ * the resolved value (tasks here are written to never reject).
116
+ *
117
+ * @template T
118
+ * @param {Array<() => Promise<T>>} taskFactories
119
+ * @param {number} cap maximum simultaneous tasks
120
+ * @returns {Promise<T[]>} results in the same order as taskFactories
121
+ */
122
+ async function runWithConcurrency(taskFactories, cap) {
123
+ const total = taskFactories.length;
124
+ const results = new Array(total);
125
+ if (total === 0) return results;
126
+
127
+ const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
128
+ let nextIndex = 0;
129
+
130
+ // Each worker pulls the next index until the queue drains.
131
+ async function worker() {
132
+ // eslint-disable-next-line no-constant-condition
133
+ while (true) {
134
+ const i = nextIndex++;
135
+ if (i >= total) return;
136
+ results[i] = await taskFactories[i]();
137
+ }
138
+ }
139
+
140
+ const workers = [];
141
+ for (let w = 0; w < limit; w++) workers.push(worker());
142
+ await Promise.all(workers);
143
+ return results;
144
+ }
145
+
146
+ class HookEngine {
147
+ /**
148
+ * @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
149
+ * @param {object} [options]
150
+ * @param {number} [options.concurrency=8] global concurrency cap per fire().
151
+ * @param {string} [options.cwd] working dir passed to the runner.
152
+ * @param {object} [options.env] base env merged into the runner's child env.
153
+ */
154
+ constructor(loader, options) {
155
+ if (!loader || typeof loader.enabledHooksFor !== 'function') {
156
+ throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
157
+ }
158
+ this.loader = loader;
159
+ const opts = options || {};
160
+ this.concurrency =
161
+ Number.isInteger(opts.concurrency) && opts.concurrency > 0
162
+ ? opts.concurrency
163
+ : DEFAULT_CONCURRENCY;
164
+ this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
165
+ this.env = opts.env || null;
166
+
167
+ // Resolve contract functions once.
168
+ this._matches = getMatchesFn();
169
+ this._buildPayload = getBuildPayloadFn();
170
+ this._runHook = getRunHookFn();
171
+ }
172
+
173
+ /**
174
+ * Fire a lifecycle event through its matching, enabled hooks.
175
+ *
176
+ * @param {string} event one of EVENTS (e.g. "PreToolUse")
177
+ * @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
178
+ * @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, ...)
179
+ * @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
180
+ * stopLoop:boolean, results:object[]}>}
181
+ */
182
+ async fire(event, ctx, extra) {
183
+ const context = ctx || {};
184
+ const extraFields = extra || {};
185
+
186
+ // Build the stdin payload once (frozen field names per §2).
187
+ let payload;
188
+ try {
189
+ payload = this._buildPayload(event, context, extraFields);
190
+ } catch (err) {
191
+ // If payload assembly fails, fall back to a minimal valid payload so hooks still run.
192
+ payload = Object.assign(
193
+ {
194
+ session_id: context.session_id || '',
195
+ transcript_path: context.transcript_path || '',
196
+ cwd: context.cwd || this.cwd,
197
+ hook_event_name: event,
198
+ },
199
+ extraFields
200
+ );
201
+ }
202
+
203
+ // The toolName for matcher scoping comes from the payload (tool-bearing events only).
204
+ // For tool-less events this is undefined and matcher.js treats it as always-fire.
205
+ const toolName = payload && payload.tool_name;
206
+
207
+ // Select enabled hooks for this event whose matcher matches, in MANIFEST ORDER.
208
+ const candidates = this.loader.enabledHooksFor(event) || [];
209
+ const selected = candidates.filter((spec) => {
210
+ try {
211
+ return this._matches(spec && spec.matcher, toolName);
212
+ } catch (_) {
213
+ // A broken matcher string should not crash the engine; skip that hook.
214
+ return false;
215
+ }
216
+ });
217
+
218
+ // Resolve the child cwd/env once for the runner.
219
+ const runCwd = (payload && payload.cwd) || this.cwd;
220
+ const runOpts = { cwd: runCwd };
221
+ if (this.env) runOpts.env = this.env;
222
+
223
+ // Build task factories (one per selected hook) - never reject.
224
+ const factories = selected.map((spec) => async () => {
225
+ const started = Date.now();
226
+ try {
227
+ const res = await this._runHook(spec, payload, runOpts);
228
+ // Defensive: ensure the result carries id/event even if the runner omitted them.
229
+ if (res && typeof res === 'object') {
230
+ if (res.id === undefined) res.id = spec.id;
231
+ if (res.event === undefined) res.event = event;
232
+ return res;
233
+ }
234
+ return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
235
+ } catch (err) {
236
+ return syntheticFailure(spec, event, err, Date.now() - started);
237
+ }
238
+ });
239
+
240
+ // Run concurrently with the cap; results come back in manifest order.
241
+ const results = await runWithConcurrency(factories, this.concurrency);
242
+
243
+ // Aggregate per §6.
244
+ const injectFragments = [];
245
+ let blocked = false;
246
+ let reason = null;
247
+ let stopLoop = false;
248
+
249
+ for (const r of results) {
250
+ if (!r) continue;
251
+ if (r.inject != null) injectFragments.push(r.inject);
252
+ if (r.blocked && !blocked) {
253
+ // First blocker in order owns the reason.
254
+ blocked = true;
255
+ reason = r.reason != null ? r.reason : null;
256
+ }
257
+ if (r.stopLoop) stopLoop = true;
258
+ }
259
+
260
+ return {
261
+ injected: injectFragments.join('\n'),
262
+ blocked,
263
+ reason,
264
+ stopLoop,
265
+ results,
266
+ };
267
+ }
268
+ }
269
+
270
+ module.exports = {
271
+ HookEngine,
272
+ DEFAULT_CONCURRENCY,
273
+ // Exported for unit tests / reuse:
274
+ runWithConcurrency,
275
+ };