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,824 +1,824 @@
1
- 'use strict';
2
-
3
- /**
4
- * hook-loader.js - load and persist the hook manifest, resolve runnable specs.
5
- *
6
- * Contract: the manifest shape and the loader API are both specified below.
7
- *
8
- * Responsibilities:
9
- * - loadManifest(path): read hooks.manifest.json, expand ${HOOKS_DIR} (the absolute
10
- * src/hooks dir) inside every command string, return { version, hooksDir, hooks: Spec[] }.
11
- * - enabledHooksFor(event): the enabled specs for a lifecycle event.
12
- * - setEnabled(id, bool): flip a hook's enabled flag and persist ATOMICALLY (temp + rename).
13
- * - readScript(id): the hook's source text (for the manager's "open").
14
- * - writeScript(id, text): save to the COPY under src/hooks/scripts - and REFUSE any
15
- * resolved path outside src/hooks/scripts (path-traversal guard, isolation rule).
16
- *
17
- * Hook Manager v2 additions - all ADDITIVE; the API above
18
- * keeps its exact behaviour so the existing loader tests still pass:
19
- * - AUTO-DETECT: autodetect() scans <hooksDir>/scripts for hook scripts and reconciles
20
- * them with the manifest. The FOLDER is the source of truth for what EXISTS; the
21
- * manifest is CONFIG. Scripts on disk with no manifest entry surface as detected
22
- * entries (enabled:false, detected:true, event/matcher unknown). Manifest entries
23
- * whose script is missing on disk are flagged (present:false). Aliased as
24
- * detect/reconcile/scan/inventory/reconcileScripts.
25
- * - PERSIST STATE: enabled-state lives in <hooksDir>/hooks.state.json, an overlay keyed
26
- * by hook id ({ "<id>": bool }). On load it is applied OVER the manifest's enabled
27
- * flags (overlay WINS - documented precedence). setEnabled writes the overlay
28
- * atomically (temp+rename) IN ADDITION to mutating the manifest, so toggles survive
29
- * restarts and stay separate from the inventory.
30
- *
31
- * CommonJS only. Node built-ins only.
32
- */
33
-
34
- const fs = require('node:fs');
35
- const path = require('node:path');
36
-
37
- // Lifecycle event names (frozen) - used to validate addEntry specs against the
38
- // six supported events. events.js has zero host imports, so this cannot cycle.
39
- const { EVENT_NAMES } = require('./events');
40
-
41
- // The matcher-compile discriminator - addEntry refuses a matcher that can never be
42
- // evaluated (matcher.js has zero host imports, so this cannot cycle either).
43
- const { matcherError } = require('./matcher');
44
-
45
- /**
46
- * Extensions we treat as hook scripts during auto-detect. Everything else in the
47
- * scripts dir (e.g. *.conf files, READMEs) is config/data, not a hook.
48
- */
49
- const HOOK_SCRIPT_EXTS = /\.(sh|js|cjs|mjs|cmd|bat|ps1|py)$/i;
50
-
51
- /**
52
- * Resolve the absolute src/hooks directory from the manifest's own location and its
53
- * declared (possibly relative) hooksDir. The manifest lives at <hooks>/hooks.manifest.json,
54
- * so its directory IS the hooks dir; we honor an explicit absolute hooksDir if given but
55
- * otherwise anchor to the manifest file's location (robust against cwd differences).
56
- *
57
- * @param {string} manifestPath absolute path to hooks.manifest.json
58
- * @param {string|undefined} declaredHooksDir the manifest's "hooksDir" field
59
- * @returns {string} absolute path to the hooks directory
60
- */
61
- function resolveHooksDir(manifestPath, declaredHooksDir) {
62
- const manifestDir = path.dirname(path.resolve(manifestPath));
63
- if (!declaredHooksDir) return manifestDir;
64
- if (path.isAbsolute(declaredHooksDir)) return path.resolve(declaredHooksDir);
65
- // Relative hooksDir is interpreted relative to the manifest's directory's parent only
66
- // when it does not simply re-point at the manifest dir; in practice the manifest sits
67
- // inside the hooks dir, so the manifest directory is the source of truth.
68
- return manifestDir;
69
- }
70
-
71
- /**
72
- * Expand ${HOOKS_DIR} occurrences in a string with the absolute hooks dir.
73
- * Defensive: returns the input unchanged for non-strings.
74
- *
75
- * @param {string} str
76
- * @param {string} hooksDir absolute hooks directory
77
- * @returns {string}
78
- */
79
- function expandHooksDir(str, hooksDir) {
80
- if (typeof str !== 'string') return str;
81
- // Replace every literal occurrence of ${HOOKS_DIR}. We avoid regex special-char issues
82
- // by using split/join on the literal token.
83
- return str.split('${HOOKS_DIR}').join(hooksDir);
84
- }
85
-
86
- class HookLoader {
87
- /**
88
- * @param {string} manifestPath absolute path to hooks.manifest.json
89
- * @param {object} manifest the parsed + expanded manifest object
90
- * @param {string} hooksDir absolute hooks directory
91
- */
92
- constructor(manifestPath, manifest, hooksDir) {
93
- this.manifestPath = manifestPath;
94
- this.manifest = manifest;
95
- this.hooksDir = hooksDir;
96
- // The scripts copy directory - the ONLY place writeScript may touch.
97
- this.scriptsDir = path.join(hooksDir, 'scripts');
98
- // v2: the persisted enabled-state overlay (keyed by hook id), kept separate
99
- // from the manifest inventory. Lives next to the manifest in the hooks dir.
100
- this.statePath = path.join(hooksDir, 'hooks.state.json');
101
- }
102
-
103
- /** @returns {number} manifest schema version */
104
- get version() {
105
- return this.manifest && this.manifest.version;
106
- }
107
-
108
- /** @returns {Array<object>} all hook specs (expanded) */
109
- get hooks() {
110
- return (this.manifest && Array.isArray(this.manifest.hooks)) ? this.manifest.hooks : [];
111
- }
112
-
113
- /**
114
- * Enabled specs for a given lifecycle event, in manifest order.
115
- * @param {string} event one of EVENTS
116
- * @returns {Array<object>}
117
- */
118
- enabledHooksFor(event) {
119
- return this.hooks.filter((h) => h && h.event === event && h.enabled === true);
120
- }
121
-
122
- /**
123
- * Look up a single spec by id.
124
- * @param {string} id
125
- * @returns {object|undefined}
126
- */
127
- getSpec(id) {
128
- return this.hooks.find((h) => h && h.id === id);
129
- }
130
-
131
- // ── v2: persisted enabled-state overlay ──────────────────────────────────
132
-
133
- /**
134
- * Read the enabled-state overlay from hooks.state.json. The overlay is a flat
135
- * map of hook id -> boolean. Missing/unreadable/malformed file -> empty overlay
136
- * (the manifest's own enabled flags stand). Never throws.
137
- *
138
- * @returns {Object<string, boolean>}
139
- */
140
- readState() {
141
- let raw;
142
- try {
143
- raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
144
- } catch (_) {
145
- return {};
146
- }
147
- return normalizeState(raw);
148
- }
149
-
150
- /**
151
- * Persist the enabled-state overlay atomically (temp + rename). Confined to the
152
- * hooks dir. The overlay carries ONLY booleans keyed by hook id.
153
- *
154
- * @param {Object<string, boolean>} overlay
155
- * @returns {void}
156
- */
157
- writeState(overlay) {
158
- const clean = normalizeState(overlay);
159
- atomicWriteJson(this.statePath, clean);
160
- }
161
-
162
- /**
163
- * Apply the on-disk state overlay over the in-memory manifest specs. The overlay
164
- * WINS over the manifest's own enabled flag (documented precedence). Called on
165
- * load so enabledHooksFor reflects the persisted toggles. Idempotent.
166
- *
167
- * @returns {Object<string, boolean>} the overlay that was applied
168
- */
169
- applyStateOverlay() {
170
- const overlay = this.readState();
171
- for (const h of this.hooks) {
172
- if (!h || typeof h.id !== 'string') continue;
173
- if (Object.prototype.hasOwnProperty.call(overlay, h.id)) {
174
- h.enabled = overlay[h.id] === true;
175
- }
176
- }
177
- return overlay;
178
- }
179
-
180
- // ── v2: auto-detect / reconcile inventory ────────────────────────────────
181
-
182
- /**
183
- * Scan <hooksDir>/scripts for hook scripts and reconcile with the manifest.
184
- *
185
- * The FOLDER is the source of truth for what EXISTS; the manifest is CONFIG.
186
- * - Manifest entries are returned with `present` reflecting whether their
187
- * `script` file exists on disk (manifest entries with no script - inline
188
- * commands - are treated as present:true, since there is no file to miss).
189
- * - Scripts present on disk but absent from the manifest are surfaced as
190
- * `detected` entries: enabled:false, detected:true, event/matcher unknown.
191
- *
192
- * Returns a normalised inventory. `all` is the union (configured first, in
193
- * manifest order, then detected). Pure read - does not mutate the manifest or
194
- * touch disk beyond reading the scripts dir.
195
- *
196
- * @returns {{ all: object[], configured: object[], detected: object[], orphans: object[] }}
197
- */
198
- autodetect() {
199
- const scriptFiles = this._scanScripts(); // Set of basenames present on disk
200
- const referenced = new Set();
201
-
202
- const configured = [];
203
- const orphans = [];
204
-
205
- for (const h of this.hooks) {
206
- if (!h || typeof h !== 'object') continue;
207
- const base = scriptBasename(h.script);
208
- if (base) referenced.add(base);
209
-
210
- // Inline-command hooks (script:null) have no file to verify -> present.
211
- const present = base ? scriptFiles.has(base) : true;
212
- const entry = Object.assign({}, h, {
213
- detected: false,
214
- configured: true,
215
- present,
216
- });
217
- if (!present) {
218
- // Orphan: manifest references a script that does not exist on disk.
219
- entry.missing = true;
220
- entry.scriptMissing = true;
221
- entry.exists = false;
222
- entry.status = 'missing';
223
- orphans.push(entry);
224
- }
225
- configured.push(entry);
226
- }
227
-
228
- // Scripts on disk with no manifest entry -> detected, unconfigured.
229
- const detected = [];
230
- for (const base of scriptFiles) {
231
- if (referenced.has(base)) continue;
232
- detected.push({
233
- id: `detected/${base.replace(/\.[^.]+$/, '')}`,
234
- event: null, // unknown - not yet configured
235
- matcher: null,
236
- type: 'command',
237
- script: path.posix.join('scripts', base),
238
- command: null,
239
- timeout: null,
240
- enabled: false, // detected entries are never auto-enabled
241
- detected: true,
242
- configured: false,
243
- unconfigured: true,
244
- present: true,
245
- source: 'disk-scan',
246
- description: `Detected on disk; not yet configured in the manifest (${base}).`,
247
- });
248
- }
249
-
250
- return {
251
- all: configured.concat(detected),
252
- configured,
253
- detected,
254
- orphans,
255
- };
256
- }
257
-
258
- /** @returns {object} alias of {@link HookLoader#autodetect} (compatibility naming). */
259
- reconcile() { return this.autodetect(); }
260
- /** @returns {object} alias of {@link HookLoader#autodetect}. */
261
- reconcileScripts() { return this.autodetect(); }
262
- /** @returns {object} alias of {@link HookLoader#autodetect}. */
263
- inventory() { return this.autodetect(); }
264
- /** @returns {object} alias of {@link HookLoader#autodetect}. */
265
- detect() { return this.autodetect(); }
266
- /** @returns {object} alias of {@link HookLoader#autodetect}. */
267
- scan() { return this.autodetect(); }
268
-
269
- /**
270
- * Scan the scripts dir for candidate hook script files. Hook scripts are
271
- * executable shell/node scripts; we treat .sh/.js/.cmd/.bat/.ps1/.py as hook
272
- * scripts and ignore everything else (e.g. a *.conf file is config, not a
273
- * hook). Missing dir -> empty set. Never throws.
274
- *
275
- * @returns {Set<string>} basenames of script files present on disk
276
- * @private
277
- */
278
- _scanScripts() {
279
- const out = new Set();
280
- let entries;
281
- try {
282
- entries = fs.readdirSync(this.scriptsDir, { withFileTypes: true });
283
- } catch (_) {
284
- return out; // no scripts dir yet
285
- }
286
- for (const ent of entries) {
287
- if (!ent.isFile || !ent.isFile()) continue;
288
- if (HOOK_SCRIPT_EXTS.test(ent.name)) out.add(ent.name);
289
- }
290
- return out;
291
- }
292
-
293
- /**
294
- * Toggle a hook's enabled flag and persist the manifest atomically.
295
- *
296
- * v2 precedence: the authoritative persisted toggle lives in the hooks.state.json
297
- * OVERLAY (keyed by hook id), which WINS over the manifest's own enabled flag at
298
- * load time. setEnabled writes that overlay atomically so the toggle survives a
299
- * restart and stays separate from the auto-detected inventory. For backward
300
- * compatibility (and so the manifest stays a faithful default), it ALSO mirrors
301
- * the flag into the manifest - writing the ORIGINAL (unexpanded) command strings
302
- * so the absolute ${HOOKS_DIR} expansion never leaks into the portable on-disk
303
- * manifest. We re-read the raw manifest from disk to do this.
304
- *
305
- * If the hook is a v2 DETECTED entry (on disk, not in the manifest), there is no
306
- * manifest row to mirror into - the overlay alone carries its state.
307
- *
308
- * @param {string} id hook id
309
- * @param {boolean} bool desired enabled state
310
- * @returns {boolean} true if the toggle was applied (manifest hook OR detected hook)
311
- */
312
- setEnabled(id, bool) {
313
- const desired = !!bool;
314
-
315
- // Update the in-memory (expanded) spec so the live engine sees the change
316
- // immediately - if this id corresponds to a configured manifest hook.
317
- const liveSpec = this.getSpec(id);
318
- if (liveSpec) liveSpec.enabled = desired;
319
-
320
- // (1) Mirror into the manifest when a matching row exists (keeps the manifest a
321
- // faithful default and preserves the v1 setEnabled contract). Skipped for
322
- // detected-only ids that have no manifest row.
323
- let raw;
324
- try {
325
- raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
326
- } catch (err) {
327
- // If the on-disk file is unreadable, fall back to persisting a re-collapsed copy
328
- // of the in-memory manifest (best effort - still atomic).
329
- raw = collapseManifest(this.manifest, this.hooksDir);
330
- }
331
-
332
- let manifestRow = false;
333
- if (raw && Array.isArray(raw.hooks)) {
334
- const rawSpec = raw.hooks.find((h) => h && h.id === id);
335
- if (rawSpec) {
336
- manifestRow = true;
337
- rawSpec.enabled = desired;
338
- atomicWriteJson(this.manifestPath, raw);
339
- }
340
- }
341
-
342
- // (2) Persist the overlay - the authoritative, precedence-winning store - ONLY for an id
343
- // that matched something. The first correction here fixed the constant-true return
344
- // (`!!liveSpec || true`) but left this write unconditional, so a typo'd id still left a
345
- // stray key in hooks.state.json forever. Strays are not inert: applyState() lays the
346
- // overlay over the manifest at every load, so a future hook adopting the id would boot
347
- // silently in the stale recorded state - a pre-disabled gate.
348
- if (liveSpec || manifestRow) {
349
- const overlay = this.readState();
350
- overlay[id] = desired;
351
- this.writeState(overlay);
352
- }
353
-
354
- // True if we toggled a known manifest hook; also true for a detected (manifest-less) hook
355
- // so the manager can persist its state. An id matching NEITHER is a miss: report false,
356
- // write nothing.
357
- return !!liveSpec || manifestRow;
358
- }
359
-
360
- /**
361
- * Add a new hook entry to the manifest AND the live in-memory engine view.
362
- *
363
- * Mirrors setEnabled's persistence pattern: the RAW manifest JSON is re-read from
364
- * disk (so the portable ${HOOKS_DIR} token inside command strings stays UNCOLLAPSED
365
- * on disk), the validated spec is appended VERBATIM, and the file is rewritten
366
- * atomically (temp + rename). In ADDITION an EXPANDED copy - ${HOOKS_DIR} resolved in
367
- * the command - is pushed onto this.manifest.hooks so enabledHooksFor / the live
368
- * engine observe the new hook WITHOUT a reload.
369
- *
370
- * @param {object} spec
371
- * - id: non-empty string; must not already exist in the manifest (rejected if it does)
372
- * - event: one of the lifecycle EVENTS (EVENT_NAMES)
373
- * - command: non-empty string
374
- * - matcher: optional string; must COMPILE under matcher.js's anchoring (rejected
375
- * otherwise - an uncompilable matcher can never be evaluated)
376
- * - script/timeout/description: optional, stored verbatim
377
- * - enabled: defaults to (spec.enabled === true)
378
- * @returns {object} the stored spec (exactly as written to the manifest)
379
- * @throws TypeError/Error if validation fails or the id already exists
380
- */
381
- addEntry(spec) {
382
- if (!spec || typeof spec !== 'object') {
383
- throw new TypeError('addEntry: spec must be an object');
384
- }
385
- if (typeof spec.id !== 'string' || spec.id.length === 0) {
386
- throw new Error('addEntry: spec.id must be a non-empty string');
387
- }
388
- if (!EVENT_NAMES.includes(spec.event)) {
389
- throw new Error(
390
- `addEntry: spec.event must be one of ${EVENT_NAMES.join(', ')} (got ${JSON.stringify(spec.event)})`
391
- );
392
- }
393
- if (typeof spec.command !== 'string' || spec.command.length === 0) {
394
- throw new Error('addEntry: spec.command must be a non-empty string');
395
- }
396
- // A matcher that does not compile can never be evaluated - storing it would author a
397
- // hook whose gate the engine must refuse on every tool-bearing call (hook-engine's
398
- // unreadable-gate rule). Refuse HERE so the UI's POST /api/hooks/add and tf_hook_add
399
- // both answer with the problem instead of 200/ok. (0.7.0; matcher-compile.test.js.)
400
- if (spec.matcher !== undefined && spec.matcher !== null && typeof spec.matcher !== 'string') {
401
- throw new Error('addEntry: spec.matcher must be a string when provided');
402
- }
403
- {
404
- const compileProblem = matcherError(spec.matcher);
405
- if (compileProblem) {
406
- throw new Error(
407
- `addEntry: spec.matcher ${JSON.stringify(spec.matcher)} does not compile as a ` +
408
- `regular expression (${compileProblem}) - matchers are anchored regexes ` +
409
- '(e.g. "Bash|Write"), not globs'
410
- );
411
- }
412
- }
413
-
414
- // The stored spec: every supplied field VERBATIM, with enabled normalized to a
415
- // strict boolean. The command is left untouched so a portable ${HOOKS_DIR} token
416
- // (if the caller used one) stays UNCOLLAPSED on disk.
417
- const stored = Object.assign({}, spec, { enabled: spec.enabled === true });
418
-
419
- // Re-read the RAW manifest from disk (commands uncollapsed). Fall back to a
420
- // re-collapsed copy of the in-memory manifest if the file is unreadable - same
421
- // resilience as setEnabled, and the collapse keeps ${HOOKS_DIR} uncollapsed too.
422
- let raw;
423
- try {
424
- raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
425
- } catch (_) {
426
- raw = collapseManifest(this.manifest, this.hooksDir);
427
- }
428
- if (!raw || typeof raw !== 'object') raw = { version: this.version, hooks: [] };
429
- if (!Array.isArray(raw.hooks)) raw.hooks = [];
430
-
431
- if (raw.hooks.some((h) => h && h.id === spec.id)) {
432
- throw new Error(`addEntry: a hook with id "${spec.id}" already exists`);
433
- }
434
-
435
- // Persist the spec VERBATIM, atomically.
436
- raw.hooks.push(stored);
437
- atomicWriteJson(this.manifestPath, raw);
438
-
439
- // Mirror an EXPANDED copy into the live manifest so the engine sees it NOW.
440
- if (!Array.isArray(this.manifest.hooks)) this.manifest.hooks = [];
441
- const expanded = Object.assign({}, stored, {
442
- command: expandHooksDir(stored.command, this.hooksDir),
443
- });
444
- this.manifest.hooks.push(expanded);
445
-
446
- return stored;
447
- }
448
-
449
- /**
450
- * Remove a hook entry by id from the manifest, the live engine view, and the
451
- * persisted enabled-state overlay. Does NOT delete the script file on disk -
452
- * scripts are inventory (auto-detect's source of truth); unconfiguring a hook
453
- * only drops its manifest row, leaving any file for re-detection.
454
- *
455
- * Mirrors setEnabled's persistence pattern: re-read the RAW manifest (commands
456
- * stay UNCOLLAPSED), filter out the id, and rewrite atomically ONLY if something
457
- * changed. The in-memory expanded spec is spliced out so the live engine stops
458
- * firing it WITHOUT a reload, and the overlay key (when present) is purged.
459
- *
460
- * @param {string} id hook id to remove
461
- * @returns {boolean} true if a manifest row OR a live spec was removed
462
- * @throws Error if id is not a non-empty string
463
- */
464
- removeEntry(id) {
465
- if (typeof id !== 'string' || id.length === 0) {
466
- throw new Error('removeEntry: id must be a non-empty string');
467
- }
468
-
469
- // (1) Re-read the RAW manifest and drop the matching row (commands uncollapsed).
470
- let raw;
471
- try {
472
- raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
473
- } catch (_) {
474
- raw = collapseManifest(this.manifest, this.hooksDir);
475
- }
476
- let removedFromManifest = false;
477
- if (raw && Array.isArray(raw.hooks)) {
478
- const before = raw.hooks.length;
479
- raw.hooks = raw.hooks.filter((h) => !(h && h.id === id));
480
- removedFromManifest = raw.hooks.length !== before;
481
- if (removedFromManifest) atomicWriteJson(this.manifestPath, raw); // write only if changed
482
- }
483
-
484
- // (2) Splice the live expanded spec(s) so the engine stops seeing it immediately.
485
- let removedFromMemory = false;
486
- if (Array.isArray(this.manifest.hooks)) {
487
- for (let i = this.manifest.hooks.length - 1; i >= 0; i--) {
488
- const h = this.manifest.hooks[i];
489
- if (h && h.id === id) {
490
- this.manifest.hooks.splice(i, 1);
491
- removedFromMemory = true;
492
- }
493
- }
494
- }
495
-
496
- // (3) Purge the overlay key. Only rewrite the overlay when it actually carried
497
- // this id, so we never create a stray state file for a never-toggled hook.
498
- const overlay = this.readState();
499
- if (Object.prototype.hasOwnProperty.call(overlay, id)) {
500
- delete overlay[id];
501
- this.writeState(overlay);
502
- }
503
-
504
- return removedFromManifest || removedFromMemory;
505
- }
506
-
507
- /**
508
- * Read a hook's script source. Resolves the spec's "script" (relative to hooksDir) and
509
- * reads it. Falls back to deriving the path from the script basename under scripts/.
510
- *
511
- * @param {string} id hook id
512
- * @returns {string} file contents
513
- * @throws if the hook id is unknown or the file cannot be read
514
- */
515
- readScript(id) {
516
- const spec = this.getSpec(id);
517
- if (!spec) throw new Error(`readScript: unknown hook id "${id}"`);
518
- const scriptPath = this._scriptPathForSpec(spec);
519
- return fs.readFileSync(scriptPath, 'utf8');
520
- }
521
-
522
- /**
523
- * Write a hook's script source to the COPY under src/hooks/scripts. Refuses any path that
524
- * resolves outside src/hooks/scripts (defense-in-depth for the isolation rule).
525
- *
526
- * @param {string} id hook id
527
- * @param {string} text new source
528
- * @returns {void}
529
- * @throws if the id is unknown, or the resolved path escapes scripts/
530
- */
531
- writeScript(id, text) {
532
- const spec = this.getSpec(id);
533
- if (!spec) throw new Error(`writeScript: unknown hook id "${id}"`);
534
- if (typeof text !== 'string') {
535
- throw new TypeError('writeScript: text must be a string');
536
- }
537
-
538
- const scriptPath = this._scriptPathForSpec(spec);
539
- this._assertInsideScriptsDir(scriptPath);
540
-
541
- // Atomic write within the scripts dir (temp + rename), preserving any line endings
542
- // the caller supplied (do not normalize - the shell scripts may be CRLF-sensitive).
543
- atomicWriteText(scriptPath, text, this.scriptsDir);
544
- }
545
-
546
- /**
547
- * Resolve the absolute filesystem path for a spec's script, anchored to hooksDir, and
548
- * guard it against escaping scripts/.
549
- *
550
- * @param {object} spec
551
- * @returns {string} absolute path inside scripts/
552
- * @private
553
- */
554
- _scriptPathForSpec(spec) {
555
- let rel = spec.script;
556
- if (typeof rel !== 'string' || rel.length === 0) {
557
- // Fall back to deriving from the id's basename if "script" is missing.
558
- rel = path.join('scripts', `${path.basename(spec.id)}`);
559
- }
560
- // spec.script is documented relative to hooksDir (e.g. "scripts/on-prompt.sh").
561
- const resolved = path.resolve(this.hooksDir, rel);
562
- this._assertInsideScriptsDir(resolved);
563
- return resolved;
564
- }
565
-
566
- /**
567
- * Throw unless `target` resolves strictly inside this.scriptsDir.
568
- * @param {string} target absolute candidate path
569
- * @private
570
- */
571
- _assertInsideScriptsDir(target) {
572
- const resolved = path.resolve(target);
573
- const base = path.resolve(this.scriptsDir);
574
- // Compare with a trailing separator so "<base>extra" cannot pass as "<base>".
575
- const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
576
- const isInside = resolved === base || resolved.startsWith(baseWithSep);
577
- if (!isInside) {
578
- throw new Error(
579
- `path-traversal blocked: "${resolved}" is outside the scripts dir "${base}"`
580
- );
581
- }
582
- }
583
- }
584
-
585
- /**
586
- * Coerce an arbitrary parsed value into a clean { id: boolean } overlay map. Accepts
587
- * either a flat map ({ "<id>": bool }) or a wrapped form ({ enabled: { ... } }) for
588
- * forward-tolerance; non-boolean values are coerced to strict booleans; non-object
589
- * input yields an empty overlay. Never throws.
590
- *
591
- * @param {*} raw
592
- * @returns {Object<string, boolean>}
593
- */
594
- function normalizeState(raw) {
595
- const src =
596
- raw && typeof raw === 'object' && raw.enabled && typeof raw.enabled === 'object'
597
- ? raw.enabled
598
- : raw;
599
- const out = {};
600
- if (!src || typeof src !== 'object' || Array.isArray(src)) return out;
601
- for (const key of Object.keys(src)) {
602
- out[key] = src[key] === true;
603
- }
604
- return out;
605
- }
606
-
607
- /**
608
- * The basename of a spec's script field (e.g. "scripts/on-prompt.sh" -> "on-prompt.sh").
609
- * Returns null for inline-command hooks (script null/empty). Tolerates either path
610
- * separator since manifests use forward slashes.
611
- *
612
- * @param {*} script the spec.script field
613
- * @returns {string|null}
614
- */
615
- function scriptBasename(script) {
616
- if (typeof script !== 'string' || script.length === 0) return null;
617
- const norm = script.split('\\').join('/');
618
- const base = norm.slice(norm.lastIndexOf('/') + 1);
619
- return base.length ? base : null;
620
- }
621
-
622
- /**
623
- * Produce a manifest object with command strings collapsed back to ${HOOKS_DIR} so the
624
- * on-disk form stays machine-portable. Used only as a fallback when the raw file cannot
625
- * be re-read during persistence.
626
- *
627
- * @param {object} manifest expanded manifest
628
- * @param {string} hooksDir absolute hooks dir to collapse out of command strings
629
- * @returns {object} a deep-enough copy with collapsed commands
630
- */
631
- function collapseManifest(manifest, hooksDir) {
632
- const copy = JSON.parse(JSON.stringify(manifest));
633
- if (Array.isArray(copy.hooks)) {
634
- for (const h of copy.hooks) {
635
- if (h && typeof h.command === 'string' && hooksDir) {
636
- h.command = h.command.split(hooksDir).join('${HOOKS_DIR}');
637
- }
638
- }
639
- }
640
- return copy;
641
- }
642
-
643
- /**
644
- * Atomically write a JS object as pretty JSON: write to a temp file in the SAME directory
645
- * as the target (so rename is atomic on the same filesystem), then rename over the target.
646
- *
647
- * @param {string} targetPath absolute destination
648
- * @param {object} obj
649
- */
650
- function atomicWriteJson(targetPath, obj) {
651
- const data = JSON.stringify(obj, null, 2) + '\n';
652
- atomicWriteText(targetPath, data, path.dirname(targetPath));
653
- }
654
-
655
- /**
656
- * Atomically write text: temp file in `tmpDir` (must be on the same volume as the target),
657
- * fsync, then rename over the target. Cleans up the temp file on failure.
658
- *
659
- * @param {string} targetPath absolute destination
660
- * @param {string} data
661
- * @param {string} tmpDir directory to place the temp file (same FS as target)
662
- */
663
- function atomicWriteText(targetPath, data, tmpDir) {
664
- const dir = tmpDir || path.dirname(targetPath);
665
- const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${Math.random()
666
- .toString(36)
667
- .slice(2)}.tmp`;
668
- const tmpPath = path.join(dir, tmpName);
669
-
670
- let fd;
671
- try {
672
- fd = fs.openSync(tmpPath, 'w');
673
- fs.writeFileSync(fd, data);
674
- try {
675
- fs.fsyncSync(fd);
676
- } catch (_) {
677
- // fsync may fail on some platforms/filesystems; the rename is still ordered.
678
- }
679
- } finally {
680
- if (fd !== undefined) {
681
- try {
682
- fs.closeSync(fd);
683
- } catch (_) {
684
- /* ignore */
685
- }
686
- }
687
- }
688
-
689
- try {
690
- // rename is atomic on the same filesystem and overwrites the destination on POSIX;
691
- // on Windows it overwrites too as long as the target isn't locked.
692
- fs.renameSync(tmpPath, targetPath);
693
- } catch (err) {
694
- // Best-effort cleanup so we don't leave temp files behind.
695
- try {
696
- fs.unlinkSync(tmpPath);
697
- } catch (_) {
698
- /* ignore */
699
- }
700
- throw err;
701
- }
702
- }
703
-
704
- /**
705
- * Load a manifest from disk, expand ${HOOKS_DIR} in every command string, and return a
706
- * HookLoader holding it.
707
- *
708
- * @param {string} manifestPath absolute path to hooks.manifest.json
709
- * @returns {HookLoader}
710
- */
711
- function loadManifest(manifestPath) {
712
- if (typeof manifestPath !== 'string' || manifestPath.length === 0) {
713
- throw new TypeError('loadManifest: manifestPath must be a non-empty string');
714
- }
715
- const absManifest = path.resolve(manifestPath);
716
- const raw = JSON.parse(fs.readFileSync(absManifest, 'utf8'));
717
-
718
- const hooksDir = resolveHooksDir(absManifest, raw && raw.hooksDir);
719
-
720
- // Build the expanded manifest (do not mutate the raw parse beyond this copy).
721
- const expanded = {
722
- version: raw && raw.version,
723
- hooksDir, // store the resolved ABSOLUTE hooks dir for downstream consumers
724
- hooks: [],
725
- };
726
-
727
- const srcHooks = (raw && Array.isArray(raw.hooks)) ? raw.hooks : [];
728
- for (const h of srcHooks) {
729
- if (!h || typeof h !== 'object') continue;
730
- const spec = Object.assign({}, h);
731
- // Expand ${HOOKS_DIR} inside the command (and, defensively, any string field that
732
- // might reference it - but command is the documented carrier).
733
- if (typeof spec.command === 'string') {
734
- spec.command = expandHooksDir(spec.command, hooksDir);
735
- }
736
- // Normalize enabled to a strict boolean (manifest authors may omit it).
737
- spec.enabled = spec.enabled === true;
738
- expanded.hooks.push(spec);
739
- }
740
-
741
- const loader = new HookLoader(absManifest, expanded, hooksDir);
742
- // v2: apply the persisted enabled-state overlay OVER the manifest flags so the
743
- // returned loader (and enabledHooksFor) reflects the durable toggles. The overlay
744
- // wins; absent/unreadable overlay leaves the manifest defaults intact.
745
- loader.applyStateOverlay();
746
- return loader;
747
- }
748
-
749
- /**
750
- * Tiny inline self-check: add then remove a temp entry and confirm it round-trips
751
- * with the manifest byte-for-byte RESTORED. Runs only under `node hook-loader.js`
752
- * (never on import) and uses a THROWAWAY manifest in the OS temp dir, so the live
753
- * project manifest is never touched. Exits non-zero on any failure.
754
- */
755
- function _selfCheck() {
756
- const os = require('node:os');
757
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hookloader-selfcheck-'));
758
- const manifestPath = path.join(tmpRoot, 'hooks.manifest.json');
759
- const assert = (cond, msg) => { if (!cond) throw new Error('self-check FAILED: ' + msg); };
760
- try {
761
- // A manifest whose command uses the ${HOOKS_DIR} token, so we can prove it stays
762
- // UNCOLLAPSED on disk while being EXPANDED in the live view.
763
- const original =
764
- JSON.stringify(
765
- { version: 1, hooks: [{ id: 'existing', event: 'PreToolUse', command: '${HOOKS_DIR}/scripts/x.sh', enabled: true }] },
766
- null,
767
- 2
768
- ) + '\n';
769
- fs.writeFileSync(manifestPath, original);
770
-
771
- const loader = loadManifest(manifestPath);
772
- const tmpSpec = { id: 'selfcheck/tmp', event: 'PostToolUse', command: '${HOOKS_DIR}/scripts/tmp.sh' };
773
-
774
- const stored = loader.addEntry(tmpSpec);
775
- assert(stored.enabled === false, 'enabled should default to false');
776
- // On disk: VERBATIM, token uncollapsed.
777
- const afterAdd = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
778
- const diskRow = afterAdd.hooks.find((h) => h.id === 'selfcheck/tmp');
779
- assert(!!diskRow, 'temp entry must be persisted to the manifest');
780
- assert(diskRow.command === '${HOOKS_DIR}/scripts/tmp.sh', 'on-disk command must keep ${HOOKS_DIR} uncollapsed');
781
- // Live: EXPANDED so the engine sees it without reload.
782
- const liveRow = loader.getSpec('selfcheck/tmp');
783
- assert(!!liveRow, 'temp entry must be live in this.manifest.hooks');
784
- assert(liveRow.command === path.join(loader.hooksDir, 'scripts/tmp.sh') ||
785
- liveRow.command === loader.hooksDir + '/scripts/tmp.sh',
786
- 'live command must be expanded: ' + liveRow.command);
787
-
788
- // Duplicate id must be rejected.
789
- let threw = false;
790
- try { loader.addEntry(tmpSpec); } catch (_) { threw = true; }
791
- assert(threw, 'adding a duplicate id must throw');
792
-
793
- const removed = loader.removeEntry('selfcheck/tmp');
794
- assert(removed === true, 'removeEntry should report a removal');
795
- assert(!loader.getSpec('selfcheck/tmp'), 'temp entry must be gone from the live view');
796
-
797
- // Manifest must be RESTORED byte-for-byte (the only other row is "existing").
798
- const afterRemove = fs.readFileSync(manifestPath, 'utf8');
799
- assert(afterRemove === original, 'manifest must be restored byte-for-byte after add+remove');
800
-
801
- process.stdout.write('hook-loader self-check OK (add+remove round-trip; manifest restored)\n');
802
- } finally {
803
- try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
804
- }
805
- }
806
-
807
- if (require.main === module) {
808
- try {
809
- _selfCheck();
810
- } catch (err) {
811
- process.stderr.write(((err && err.stack) || String(err)) + '\n');
812
- process.exit(1);
813
- }
814
- }
815
-
816
- module.exports = {
817
- loadManifest,
818
- HookLoader,
819
- // Exported for unit tests / reuse:
820
- expandHooksDir,
821
- resolveHooksDir,
822
- normalizeState,
823
- scriptBasename,
824
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * hook-loader.js - load and persist the hook manifest, resolve runnable specs.
5
+ *
6
+ * Contract: the manifest shape and the loader API are both specified below.
7
+ *
8
+ * Responsibilities:
9
+ * - loadManifest(path): read hooks.manifest.json, expand ${HOOKS_DIR} (the absolute
10
+ * src/hooks dir) inside every command string, return { version, hooksDir, hooks: Spec[] }.
11
+ * - enabledHooksFor(event): the enabled specs for a lifecycle event.
12
+ * - setEnabled(id, bool): flip a hook's enabled flag and persist ATOMICALLY (temp + rename).
13
+ * - readScript(id): the hook's source text (for the manager's "open").
14
+ * - writeScript(id, text): save to the COPY under src/hooks/scripts - and REFUSE any
15
+ * resolved path outside src/hooks/scripts (path-traversal guard, isolation rule).
16
+ *
17
+ * Hook Manager v2 additions - all ADDITIVE; the API above
18
+ * keeps its exact behaviour so the existing loader tests still pass:
19
+ * - AUTO-DETECT: autodetect() scans <hooksDir>/scripts for hook scripts and reconciles
20
+ * them with the manifest. The FOLDER is the source of truth for what EXISTS; the
21
+ * manifest is CONFIG. Scripts on disk with no manifest entry surface as detected
22
+ * entries (enabled:false, detected:true, event/matcher unknown). Manifest entries
23
+ * whose script is missing on disk are flagged (present:false). Aliased as
24
+ * detect/reconcile/scan/inventory/reconcileScripts.
25
+ * - PERSIST STATE: enabled-state lives in <hooksDir>/hooks.state.json, an overlay keyed
26
+ * by hook id ({ "<id>": bool }). On load it is applied OVER the manifest's enabled
27
+ * flags (overlay WINS - documented precedence). setEnabled writes the overlay
28
+ * atomically (temp+rename) IN ADDITION to mutating the manifest, so toggles survive
29
+ * restarts and stay separate from the inventory.
30
+ *
31
+ * CommonJS only. Node built-ins only.
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ // Lifecycle event names (frozen) - used to validate addEntry specs against the
38
+ // six supported events. events.js has zero host imports, so this cannot cycle.
39
+ const { EVENT_NAMES } = require('./events');
40
+
41
+ // The matcher-compile discriminator - addEntry refuses a matcher that can never be
42
+ // evaluated (matcher.js has zero host imports, so this cannot cycle either).
43
+ const { matcherError } = require('./matcher');
44
+
45
+ /**
46
+ * Extensions we treat as hook scripts during auto-detect. Everything else in the
47
+ * scripts dir (e.g. *.conf files, READMEs) is config/data, not a hook.
48
+ */
49
+ const HOOK_SCRIPT_EXTS = /\.(sh|js|cjs|mjs|cmd|bat|ps1|py)$/i;
50
+
51
+ /**
52
+ * Resolve the absolute src/hooks directory from the manifest's own location and its
53
+ * declared (possibly relative) hooksDir. The manifest lives at <hooks>/hooks.manifest.json,
54
+ * so its directory IS the hooks dir; we honor an explicit absolute hooksDir if given but
55
+ * otherwise anchor to the manifest file's location (robust against cwd differences).
56
+ *
57
+ * @param {string} manifestPath absolute path to hooks.manifest.json
58
+ * @param {string|undefined} declaredHooksDir the manifest's "hooksDir" field
59
+ * @returns {string} absolute path to the hooks directory
60
+ */
61
+ function resolveHooksDir(manifestPath, declaredHooksDir) {
62
+ const manifestDir = path.dirname(path.resolve(manifestPath));
63
+ if (!declaredHooksDir) return manifestDir;
64
+ if (path.isAbsolute(declaredHooksDir)) return path.resolve(declaredHooksDir);
65
+ // Relative hooksDir is interpreted relative to the manifest's directory's parent only
66
+ // when it does not simply re-point at the manifest dir; in practice the manifest sits
67
+ // inside the hooks dir, so the manifest directory is the source of truth.
68
+ return manifestDir;
69
+ }
70
+
71
+ /**
72
+ * Expand ${HOOKS_DIR} occurrences in a string with the absolute hooks dir.
73
+ * Defensive: returns the input unchanged for non-strings.
74
+ *
75
+ * @param {string} str
76
+ * @param {string} hooksDir absolute hooks directory
77
+ * @returns {string}
78
+ */
79
+ function expandHooksDir(str, hooksDir) {
80
+ if (typeof str !== 'string') return str;
81
+ // Replace every literal occurrence of ${HOOKS_DIR}. We avoid regex special-char issues
82
+ // by using split/join on the literal token.
83
+ return str.split('${HOOKS_DIR}').join(hooksDir);
84
+ }
85
+
86
+ class HookLoader {
87
+ /**
88
+ * @param {string} manifestPath absolute path to hooks.manifest.json
89
+ * @param {object} manifest the parsed + expanded manifest object
90
+ * @param {string} hooksDir absolute hooks directory
91
+ */
92
+ constructor(manifestPath, manifest, hooksDir) {
93
+ this.manifestPath = manifestPath;
94
+ this.manifest = manifest;
95
+ this.hooksDir = hooksDir;
96
+ // The scripts copy directory - the ONLY place writeScript may touch.
97
+ this.scriptsDir = path.join(hooksDir, 'scripts');
98
+ // v2: the persisted enabled-state overlay (keyed by hook id), kept separate
99
+ // from the manifest inventory. Lives next to the manifest in the hooks dir.
100
+ this.statePath = path.join(hooksDir, 'hooks.state.json');
101
+ }
102
+
103
+ /** @returns {number} manifest schema version */
104
+ get version() {
105
+ return this.manifest && this.manifest.version;
106
+ }
107
+
108
+ /** @returns {Array<object>} all hook specs (expanded) */
109
+ get hooks() {
110
+ return (this.manifest && Array.isArray(this.manifest.hooks)) ? this.manifest.hooks : [];
111
+ }
112
+
113
+ /**
114
+ * Enabled specs for a given lifecycle event, in manifest order.
115
+ * @param {string} event one of EVENTS
116
+ * @returns {Array<object>}
117
+ */
118
+ enabledHooksFor(event) {
119
+ return this.hooks.filter((h) => h && h.event === event && h.enabled === true);
120
+ }
121
+
122
+ /**
123
+ * Look up a single spec by id.
124
+ * @param {string} id
125
+ * @returns {object|undefined}
126
+ */
127
+ getSpec(id) {
128
+ return this.hooks.find((h) => h && h.id === id);
129
+ }
130
+
131
+ // ── v2: persisted enabled-state overlay ──────────────────────────────────
132
+
133
+ /**
134
+ * Read the enabled-state overlay from hooks.state.json. The overlay is a flat
135
+ * map of hook id -> boolean. Missing/unreadable/malformed file -> empty overlay
136
+ * (the manifest's own enabled flags stand). Never throws.
137
+ *
138
+ * @returns {Object<string, boolean>}
139
+ */
140
+ readState() {
141
+ let raw;
142
+ try {
143
+ raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
144
+ } catch (_) {
145
+ return {};
146
+ }
147
+ return normalizeState(raw);
148
+ }
149
+
150
+ /**
151
+ * Persist the enabled-state overlay atomically (temp + rename). Confined to the
152
+ * hooks dir. The overlay carries ONLY booleans keyed by hook id.
153
+ *
154
+ * @param {Object<string, boolean>} overlay
155
+ * @returns {void}
156
+ */
157
+ writeState(overlay) {
158
+ const clean = normalizeState(overlay);
159
+ atomicWriteJson(this.statePath, clean);
160
+ }
161
+
162
+ /**
163
+ * Apply the on-disk state overlay over the in-memory manifest specs. The overlay
164
+ * WINS over the manifest's own enabled flag (documented precedence). Called on
165
+ * load so enabledHooksFor reflects the persisted toggles. Idempotent.
166
+ *
167
+ * @returns {Object<string, boolean>} the overlay that was applied
168
+ */
169
+ applyStateOverlay() {
170
+ const overlay = this.readState();
171
+ for (const h of this.hooks) {
172
+ if (!h || typeof h.id !== 'string') continue;
173
+ if (Object.prototype.hasOwnProperty.call(overlay, h.id)) {
174
+ h.enabled = overlay[h.id] === true;
175
+ }
176
+ }
177
+ return overlay;
178
+ }
179
+
180
+ // ── v2: auto-detect / reconcile inventory ────────────────────────────────
181
+
182
+ /**
183
+ * Scan <hooksDir>/scripts for hook scripts and reconcile with the manifest.
184
+ *
185
+ * The FOLDER is the source of truth for what EXISTS; the manifest is CONFIG.
186
+ * - Manifest entries are returned with `present` reflecting whether their
187
+ * `script` file exists on disk (manifest entries with no script - inline
188
+ * commands - are treated as present:true, since there is no file to miss).
189
+ * - Scripts present on disk but absent from the manifest are surfaced as
190
+ * `detected` entries: enabled:false, detected:true, event/matcher unknown.
191
+ *
192
+ * Returns a normalised inventory. `all` is the union (configured first, in
193
+ * manifest order, then detected). Pure read - does not mutate the manifest or
194
+ * touch disk beyond reading the scripts dir.
195
+ *
196
+ * @returns {{ all: object[], configured: object[], detected: object[], orphans: object[] }}
197
+ */
198
+ autodetect() {
199
+ const scriptFiles = this._scanScripts(); // Set of basenames present on disk
200
+ const referenced = new Set();
201
+
202
+ const configured = [];
203
+ const orphans = [];
204
+
205
+ for (const h of this.hooks) {
206
+ if (!h || typeof h !== 'object') continue;
207
+ const base = scriptBasename(h.script);
208
+ if (base) referenced.add(base);
209
+
210
+ // Inline-command hooks (script:null) have no file to verify -> present.
211
+ const present = base ? scriptFiles.has(base) : true;
212
+ const entry = Object.assign({}, h, {
213
+ detected: false,
214
+ configured: true,
215
+ present,
216
+ });
217
+ if (!present) {
218
+ // Orphan: manifest references a script that does not exist on disk.
219
+ entry.missing = true;
220
+ entry.scriptMissing = true;
221
+ entry.exists = false;
222
+ entry.status = 'missing';
223
+ orphans.push(entry);
224
+ }
225
+ configured.push(entry);
226
+ }
227
+
228
+ // Scripts on disk with no manifest entry -> detected, unconfigured.
229
+ const detected = [];
230
+ for (const base of scriptFiles) {
231
+ if (referenced.has(base)) continue;
232
+ detected.push({
233
+ id: `detected/${base.replace(/\.[^.]+$/, '')}`,
234
+ event: null, // unknown - not yet configured
235
+ matcher: null,
236
+ type: 'command',
237
+ script: path.posix.join('scripts', base),
238
+ command: null,
239
+ timeout: null,
240
+ enabled: false, // detected entries are never auto-enabled
241
+ detected: true,
242
+ configured: false,
243
+ unconfigured: true,
244
+ present: true,
245
+ source: 'disk-scan',
246
+ description: `Detected on disk; not yet configured in the manifest (${base}).`,
247
+ });
248
+ }
249
+
250
+ return {
251
+ all: configured.concat(detected),
252
+ configured,
253
+ detected,
254
+ orphans,
255
+ };
256
+ }
257
+
258
+ /** @returns {object} alias of {@link HookLoader#autodetect} (compatibility naming). */
259
+ reconcile() { return this.autodetect(); }
260
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
261
+ reconcileScripts() { return this.autodetect(); }
262
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
263
+ inventory() { return this.autodetect(); }
264
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
265
+ detect() { return this.autodetect(); }
266
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
267
+ scan() { return this.autodetect(); }
268
+
269
+ /**
270
+ * Scan the scripts dir for candidate hook script files. Hook scripts are
271
+ * executable shell/node scripts; we treat .sh/.js/.cmd/.bat/.ps1/.py as hook
272
+ * scripts and ignore everything else (e.g. a *.conf file is config, not a
273
+ * hook). Missing dir -> empty set. Never throws.
274
+ *
275
+ * @returns {Set<string>} basenames of script files present on disk
276
+ * @private
277
+ */
278
+ _scanScripts() {
279
+ const out = new Set();
280
+ let entries;
281
+ try {
282
+ entries = fs.readdirSync(this.scriptsDir, { withFileTypes: true });
283
+ } catch (_) {
284
+ return out; // no scripts dir yet
285
+ }
286
+ for (const ent of entries) {
287
+ if (!ent.isFile || !ent.isFile()) continue;
288
+ if (HOOK_SCRIPT_EXTS.test(ent.name)) out.add(ent.name);
289
+ }
290
+ return out;
291
+ }
292
+
293
+ /**
294
+ * Toggle a hook's enabled flag and persist the manifest atomically.
295
+ *
296
+ * v2 precedence: the authoritative persisted toggle lives in the hooks.state.json
297
+ * OVERLAY (keyed by hook id), which WINS over the manifest's own enabled flag at
298
+ * load time. setEnabled writes that overlay atomically so the toggle survives a
299
+ * restart and stays separate from the auto-detected inventory. For backward
300
+ * compatibility (and so the manifest stays a faithful default), it ALSO mirrors
301
+ * the flag into the manifest - writing the ORIGINAL (unexpanded) command strings
302
+ * so the absolute ${HOOKS_DIR} expansion never leaks into the portable on-disk
303
+ * manifest. We re-read the raw manifest from disk to do this.
304
+ *
305
+ * If the hook is a v2 DETECTED entry (on disk, not in the manifest), there is no
306
+ * manifest row to mirror into - the overlay alone carries its state.
307
+ *
308
+ * @param {string} id hook id
309
+ * @param {boolean} bool desired enabled state
310
+ * @returns {boolean} true if the toggle was applied (manifest hook OR detected hook)
311
+ */
312
+ setEnabled(id, bool) {
313
+ const desired = !!bool;
314
+
315
+ // Update the in-memory (expanded) spec so the live engine sees the change
316
+ // immediately - if this id corresponds to a configured manifest hook.
317
+ const liveSpec = this.getSpec(id);
318
+ if (liveSpec) liveSpec.enabled = desired;
319
+
320
+ // (1) Mirror into the manifest when a matching row exists (keeps the manifest a
321
+ // faithful default and preserves the v1 setEnabled contract). Skipped for
322
+ // detected-only ids that have no manifest row.
323
+ let raw;
324
+ try {
325
+ raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
326
+ } catch (err) {
327
+ // If the on-disk file is unreadable, fall back to persisting a re-collapsed copy
328
+ // of the in-memory manifest (best effort - still atomic).
329
+ raw = collapseManifest(this.manifest, this.hooksDir);
330
+ }
331
+
332
+ let manifestRow = false;
333
+ if (raw && Array.isArray(raw.hooks)) {
334
+ const rawSpec = raw.hooks.find((h) => h && h.id === id);
335
+ if (rawSpec) {
336
+ manifestRow = true;
337
+ rawSpec.enabled = desired;
338
+ atomicWriteJson(this.manifestPath, raw);
339
+ }
340
+ }
341
+
342
+ // (2) Persist the overlay - the authoritative, precedence-winning store - ONLY for an id
343
+ // that matched something. The first correction here fixed the constant-true return
344
+ // (`!!liveSpec || true`) but left this write unconditional, so a typo'd id still left a
345
+ // stray key in hooks.state.json forever. Strays are not inert: applyState() lays the
346
+ // overlay over the manifest at every load, so a future hook adopting the id would boot
347
+ // silently in the stale recorded state - a pre-disabled gate.
348
+ if (liveSpec || manifestRow) {
349
+ const overlay = this.readState();
350
+ overlay[id] = desired;
351
+ this.writeState(overlay);
352
+ }
353
+
354
+ // True if we toggled a known manifest hook; also true for a detected (manifest-less) hook
355
+ // so the manager can persist its state. An id matching NEITHER is a miss: report false,
356
+ // write nothing.
357
+ return !!liveSpec || manifestRow;
358
+ }
359
+
360
+ /**
361
+ * Add a new hook entry to the manifest AND the live in-memory engine view.
362
+ *
363
+ * Mirrors setEnabled's persistence pattern: the RAW manifest JSON is re-read from
364
+ * disk (so the portable ${HOOKS_DIR} token inside command strings stays UNCOLLAPSED
365
+ * on disk), the validated spec is appended VERBATIM, and the file is rewritten
366
+ * atomically (temp + rename). In ADDITION an EXPANDED copy - ${HOOKS_DIR} resolved in
367
+ * the command - is pushed onto this.manifest.hooks so enabledHooksFor / the live
368
+ * engine observe the new hook WITHOUT a reload.
369
+ *
370
+ * @param {object} spec
371
+ * - id: non-empty string; must not already exist in the manifest (rejected if it does)
372
+ * - event: one of the lifecycle EVENTS (EVENT_NAMES)
373
+ * - command: non-empty string
374
+ * - matcher: optional string; must COMPILE under matcher.js's anchoring (rejected
375
+ * otherwise - an uncompilable matcher can never be evaluated)
376
+ * - script/timeout/description: optional, stored verbatim
377
+ * - enabled: defaults to (spec.enabled === true)
378
+ * @returns {object} the stored spec (exactly as written to the manifest)
379
+ * @throws TypeError/Error if validation fails or the id already exists
380
+ */
381
+ addEntry(spec) {
382
+ if (!spec || typeof spec !== 'object') {
383
+ throw new TypeError('addEntry: spec must be an object');
384
+ }
385
+ if (typeof spec.id !== 'string' || spec.id.length === 0) {
386
+ throw new Error('addEntry: spec.id must be a non-empty string');
387
+ }
388
+ if (!EVENT_NAMES.includes(spec.event)) {
389
+ throw new Error(
390
+ `addEntry: spec.event must be one of ${EVENT_NAMES.join(', ')} (got ${JSON.stringify(spec.event)})`
391
+ );
392
+ }
393
+ if (typeof spec.command !== 'string' || spec.command.length === 0) {
394
+ throw new Error('addEntry: spec.command must be a non-empty string');
395
+ }
396
+ // A matcher that does not compile can never be evaluated - storing it would author a
397
+ // hook whose gate the engine must refuse on every tool-bearing call (hook-engine's
398
+ // unreadable-gate rule). Refuse HERE so the UI's POST /api/hooks/add and tf_hook_add
399
+ // both answer with the problem instead of 200/ok. (0.7.0; matcher-compile.test.js.)
400
+ if (spec.matcher !== undefined && spec.matcher !== null && typeof spec.matcher !== 'string') {
401
+ throw new Error('addEntry: spec.matcher must be a string when provided');
402
+ }
403
+ {
404
+ const compileProblem = matcherError(spec.matcher);
405
+ if (compileProblem) {
406
+ throw new Error(
407
+ `addEntry: spec.matcher ${JSON.stringify(spec.matcher)} does not compile as a ` +
408
+ `regular expression (${compileProblem}) - matchers are anchored regexes ` +
409
+ '(e.g. "Bash|Write"), not globs'
410
+ );
411
+ }
412
+ }
413
+
414
+ // The stored spec: every supplied field VERBATIM, with enabled normalized to a
415
+ // strict boolean. The command is left untouched so a portable ${HOOKS_DIR} token
416
+ // (if the caller used one) stays UNCOLLAPSED on disk.
417
+ const stored = Object.assign({}, spec, { enabled: spec.enabled === true });
418
+
419
+ // Re-read the RAW manifest from disk (commands uncollapsed). Fall back to a
420
+ // re-collapsed copy of the in-memory manifest if the file is unreadable - same
421
+ // resilience as setEnabled, and the collapse keeps ${HOOKS_DIR} uncollapsed too.
422
+ let raw;
423
+ try {
424
+ raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
425
+ } catch (_) {
426
+ raw = collapseManifest(this.manifest, this.hooksDir);
427
+ }
428
+ if (!raw || typeof raw !== 'object') raw = { version: this.version, hooks: [] };
429
+ if (!Array.isArray(raw.hooks)) raw.hooks = [];
430
+
431
+ if (raw.hooks.some((h) => h && h.id === spec.id)) {
432
+ throw new Error(`addEntry: a hook with id "${spec.id}" already exists`);
433
+ }
434
+
435
+ // Persist the spec VERBATIM, atomically.
436
+ raw.hooks.push(stored);
437
+ atomicWriteJson(this.manifestPath, raw);
438
+
439
+ // Mirror an EXPANDED copy into the live manifest so the engine sees it NOW.
440
+ if (!Array.isArray(this.manifest.hooks)) this.manifest.hooks = [];
441
+ const expanded = Object.assign({}, stored, {
442
+ command: expandHooksDir(stored.command, this.hooksDir),
443
+ });
444
+ this.manifest.hooks.push(expanded);
445
+
446
+ return stored;
447
+ }
448
+
449
+ /**
450
+ * Remove a hook entry by id from the manifest, the live engine view, and the
451
+ * persisted enabled-state overlay. Does NOT delete the script file on disk -
452
+ * scripts are inventory (auto-detect's source of truth); unconfiguring a hook
453
+ * only drops its manifest row, leaving any file for re-detection.
454
+ *
455
+ * Mirrors setEnabled's persistence pattern: re-read the RAW manifest (commands
456
+ * stay UNCOLLAPSED), filter out the id, and rewrite atomically ONLY if something
457
+ * changed. The in-memory expanded spec is spliced out so the live engine stops
458
+ * firing it WITHOUT a reload, and the overlay key (when present) is purged.
459
+ *
460
+ * @param {string} id hook id to remove
461
+ * @returns {boolean} true if a manifest row OR a live spec was removed
462
+ * @throws Error if id is not a non-empty string
463
+ */
464
+ removeEntry(id) {
465
+ if (typeof id !== 'string' || id.length === 0) {
466
+ throw new Error('removeEntry: id must be a non-empty string');
467
+ }
468
+
469
+ // (1) Re-read the RAW manifest and drop the matching row (commands uncollapsed).
470
+ let raw;
471
+ try {
472
+ raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
473
+ } catch (_) {
474
+ raw = collapseManifest(this.manifest, this.hooksDir);
475
+ }
476
+ let removedFromManifest = false;
477
+ if (raw && Array.isArray(raw.hooks)) {
478
+ const before = raw.hooks.length;
479
+ raw.hooks = raw.hooks.filter((h) => !(h && h.id === id));
480
+ removedFromManifest = raw.hooks.length !== before;
481
+ if (removedFromManifest) atomicWriteJson(this.manifestPath, raw); // write only if changed
482
+ }
483
+
484
+ // (2) Splice the live expanded spec(s) so the engine stops seeing it immediately.
485
+ let removedFromMemory = false;
486
+ if (Array.isArray(this.manifest.hooks)) {
487
+ for (let i = this.manifest.hooks.length - 1; i >= 0; i--) {
488
+ const h = this.manifest.hooks[i];
489
+ if (h && h.id === id) {
490
+ this.manifest.hooks.splice(i, 1);
491
+ removedFromMemory = true;
492
+ }
493
+ }
494
+ }
495
+
496
+ // (3) Purge the overlay key. Only rewrite the overlay when it actually carried
497
+ // this id, so we never create a stray state file for a never-toggled hook.
498
+ const overlay = this.readState();
499
+ if (Object.prototype.hasOwnProperty.call(overlay, id)) {
500
+ delete overlay[id];
501
+ this.writeState(overlay);
502
+ }
503
+
504
+ return removedFromManifest || removedFromMemory;
505
+ }
506
+
507
+ /**
508
+ * Read a hook's script source. Resolves the spec's "script" (relative to hooksDir) and
509
+ * reads it. Falls back to deriving the path from the script basename under scripts/.
510
+ *
511
+ * @param {string} id hook id
512
+ * @returns {string} file contents
513
+ * @throws if the hook id is unknown or the file cannot be read
514
+ */
515
+ readScript(id) {
516
+ const spec = this.getSpec(id);
517
+ if (!spec) throw new Error(`readScript: unknown hook id "${id}"`);
518
+ const scriptPath = this._scriptPathForSpec(spec);
519
+ return fs.readFileSync(scriptPath, 'utf8');
520
+ }
521
+
522
+ /**
523
+ * Write a hook's script source to the COPY under src/hooks/scripts. Refuses any path that
524
+ * resolves outside src/hooks/scripts (defense-in-depth for the isolation rule).
525
+ *
526
+ * @param {string} id hook id
527
+ * @param {string} text new source
528
+ * @returns {void}
529
+ * @throws if the id is unknown, or the resolved path escapes scripts/
530
+ */
531
+ writeScript(id, text) {
532
+ const spec = this.getSpec(id);
533
+ if (!spec) throw new Error(`writeScript: unknown hook id "${id}"`);
534
+ if (typeof text !== 'string') {
535
+ throw new TypeError('writeScript: text must be a string');
536
+ }
537
+
538
+ const scriptPath = this._scriptPathForSpec(spec);
539
+ this._assertInsideScriptsDir(scriptPath);
540
+
541
+ // Atomic write within the scripts dir (temp + rename), preserving any line endings
542
+ // the caller supplied (do not normalize - the shell scripts may be CRLF-sensitive).
543
+ atomicWriteText(scriptPath, text, this.scriptsDir);
544
+ }
545
+
546
+ /**
547
+ * Resolve the absolute filesystem path for a spec's script, anchored to hooksDir, and
548
+ * guard it against escaping scripts/.
549
+ *
550
+ * @param {object} spec
551
+ * @returns {string} absolute path inside scripts/
552
+ * @private
553
+ */
554
+ _scriptPathForSpec(spec) {
555
+ let rel = spec.script;
556
+ if (typeof rel !== 'string' || rel.length === 0) {
557
+ // Fall back to deriving from the id's basename if "script" is missing.
558
+ rel = path.join('scripts', `${path.basename(spec.id)}`);
559
+ }
560
+ // spec.script is documented relative to hooksDir (e.g. "scripts/on-prompt.sh").
561
+ const resolved = path.resolve(this.hooksDir, rel);
562
+ this._assertInsideScriptsDir(resolved);
563
+ return resolved;
564
+ }
565
+
566
+ /**
567
+ * Throw unless `target` resolves strictly inside this.scriptsDir.
568
+ * @param {string} target absolute candidate path
569
+ * @private
570
+ */
571
+ _assertInsideScriptsDir(target) {
572
+ const resolved = path.resolve(target);
573
+ const base = path.resolve(this.scriptsDir);
574
+ // Compare with a trailing separator so "<base>extra" cannot pass as "<base>".
575
+ const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
576
+ const isInside = resolved === base || resolved.startsWith(baseWithSep);
577
+ if (!isInside) {
578
+ throw new Error(
579
+ `path-traversal blocked: "${resolved}" is outside the scripts dir "${base}"`
580
+ );
581
+ }
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Coerce an arbitrary parsed value into a clean { id: boolean } overlay map. Accepts
587
+ * either a flat map ({ "<id>": bool }) or a wrapped form ({ enabled: { ... } }) for
588
+ * forward-tolerance; non-boolean values are coerced to strict booleans; non-object
589
+ * input yields an empty overlay. Never throws.
590
+ *
591
+ * @param {*} raw
592
+ * @returns {Object<string, boolean>}
593
+ */
594
+ function normalizeState(raw) {
595
+ const src =
596
+ raw && typeof raw === 'object' && raw.enabled && typeof raw.enabled === 'object'
597
+ ? raw.enabled
598
+ : raw;
599
+ const out = {};
600
+ if (!src || typeof src !== 'object' || Array.isArray(src)) return out;
601
+ for (const key of Object.keys(src)) {
602
+ out[key] = src[key] === true;
603
+ }
604
+ return out;
605
+ }
606
+
607
+ /**
608
+ * The basename of a spec's script field (e.g. "scripts/on-prompt.sh" -> "on-prompt.sh").
609
+ * Returns null for inline-command hooks (script null/empty). Tolerates either path
610
+ * separator since manifests use forward slashes.
611
+ *
612
+ * @param {*} script the spec.script field
613
+ * @returns {string|null}
614
+ */
615
+ function scriptBasename(script) {
616
+ if (typeof script !== 'string' || script.length === 0) return null;
617
+ const norm = script.split('\\').join('/');
618
+ const base = norm.slice(norm.lastIndexOf('/') + 1);
619
+ return base.length ? base : null;
620
+ }
621
+
622
+ /**
623
+ * Produce a manifest object with command strings collapsed back to ${HOOKS_DIR} so the
624
+ * on-disk form stays machine-portable. Used only as a fallback when the raw file cannot
625
+ * be re-read during persistence.
626
+ *
627
+ * @param {object} manifest expanded manifest
628
+ * @param {string} hooksDir absolute hooks dir to collapse out of command strings
629
+ * @returns {object} a deep-enough copy with collapsed commands
630
+ */
631
+ function collapseManifest(manifest, hooksDir) {
632
+ const copy = JSON.parse(JSON.stringify(manifest));
633
+ if (Array.isArray(copy.hooks)) {
634
+ for (const h of copy.hooks) {
635
+ if (h && typeof h.command === 'string' && hooksDir) {
636
+ h.command = h.command.split(hooksDir).join('${HOOKS_DIR}');
637
+ }
638
+ }
639
+ }
640
+ return copy;
641
+ }
642
+
643
+ /**
644
+ * Atomically write a JS object as pretty JSON: write to a temp file in the SAME directory
645
+ * as the target (so rename is atomic on the same filesystem), then rename over the target.
646
+ *
647
+ * @param {string} targetPath absolute destination
648
+ * @param {object} obj
649
+ */
650
+ function atomicWriteJson(targetPath, obj) {
651
+ const data = JSON.stringify(obj, null, 2) + '\n';
652
+ atomicWriteText(targetPath, data, path.dirname(targetPath));
653
+ }
654
+
655
+ /**
656
+ * Atomically write text: temp file in `tmpDir` (must be on the same volume as the target),
657
+ * fsync, then rename over the target. Cleans up the temp file on failure.
658
+ *
659
+ * @param {string} targetPath absolute destination
660
+ * @param {string} data
661
+ * @param {string} tmpDir directory to place the temp file (same FS as target)
662
+ */
663
+ function atomicWriteText(targetPath, data, tmpDir) {
664
+ const dir = tmpDir || path.dirname(targetPath);
665
+ const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${Math.random()
666
+ .toString(36)
667
+ .slice(2)}.tmp`;
668
+ const tmpPath = path.join(dir, tmpName);
669
+
670
+ let fd;
671
+ try {
672
+ fd = fs.openSync(tmpPath, 'w');
673
+ fs.writeFileSync(fd, data);
674
+ try {
675
+ fs.fsyncSync(fd);
676
+ } catch (_) {
677
+ // fsync may fail on some platforms/filesystems; the rename is still ordered.
678
+ }
679
+ } finally {
680
+ if (fd !== undefined) {
681
+ try {
682
+ fs.closeSync(fd);
683
+ } catch (_) {
684
+ /* ignore */
685
+ }
686
+ }
687
+ }
688
+
689
+ try {
690
+ // rename is atomic on the same filesystem and overwrites the destination on POSIX;
691
+ // on Windows it overwrites too as long as the target isn't locked.
692
+ fs.renameSync(tmpPath, targetPath);
693
+ } catch (err) {
694
+ // Best-effort cleanup so we don't leave temp files behind.
695
+ try {
696
+ fs.unlinkSync(tmpPath);
697
+ } catch (_) {
698
+ /* ignore */
699
+ }
700
+ throw err;
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Load a manifest from disk, expand ${HOOKS_DIR} in every command string, and return a
706
+ * HookLoader holding it.
707
+ *
708
+ * @param {string} manifestPath absolute path to hooks.manifest.json
709
+ * @returns {HookLoader}
710
+ */
711
+ function loadManifest(manifestPath) {
712
+ if (typeof manifestPath !== 'string' || manifestPath.length === 0) {
713
+ throw new TypeError('loadManifest: manifestPath must be a non-empty string');
714
+ }
715
+ const absManifest = path.resolve(manifestPath);
716
+ const raw = JSON.parse(fs.readFileSync(absManifest, 'utf8'));
717
+
718
+ const hooksDir = resolveHooksDir(absManifest, raw && raw.hooksDir);
719
+
720
+ // Build the expanded manifest (do not mutate the raw parse beyond this copy).
721
+ const expanded = {
722
+ version: raw && raw.version,
723
+ hooksDir, // store the resolved ABSOLUTE hooks dir for downstream consumers
724
+ hooks: [],
725
+ };
726
+
727
+ const srcHooks = (raw && Array.isArray(raw.hooks)) ? raw.hooks : [];
728
+ for (const h of srcHooks) {
729
+ if (!h || typeof h !== 'object') continue;
730
+ const spec = Object.assign({}, h);
731
+ // Expand ${HOOKS_DIR} inside the command (and, defensively, any string field that
732
+ // might reference it - but command is the documented carrier).
733
+ if (typeof spec.command === 'string') {
734
+ spec.command = expandHooksDir(spec.command, hooksDir);
735
+ }
736
+ // Normalize enabled to a strict boolean (manifest authors may omit it).
737
+ spec.enabled = spec.enabled === true;
738
+ expanded.hooks.push(spec);
739
+ }
740
+
741
+ const loader = new HookLoader(absManifest, expanded, hooksDir);
742
+ // v2: apply the persisted enabled-state overlay OVER the manifest flags so the
743
+ // returned loader (and enabledHooksFor) reflects the durable toggles. The overlay
744
+ // wins; absent/unreadable overlay leaves the manifest defaults intact.
745
+ loader.applyStateOverlay();
746
+ return loader;
747
+ }
748
+
749
+ /**
750
+ * Tiny inline self-check: add then remove a temp entry and confirm it round-trips
751
+ * with the manifest byte-for-byte RESTORED. Runs only under `node hook-loader.js`
752
+ * (never on import) and uses a THROWAWAY manifest in the OS temp dir, so the live
753
+ * project manifest is never touched. Exits non-zero on any failure.
754
+ */
755
+ function _selfCheck() {
756
+ const os = require('node:os');
757
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hookloader-selfcheck-'));
758
+ const manifestPath = path.join(tmpRoot, 'hooks.manifest.json');
759
+ const assert = (cond, msg) => { if (!cond) throw new Error('self-check FAILED: ' + msg); };
760
+ try {
761
+ // A manifest whose command uses the ${HOOKS_DIR} token, so we can prove it stays
762
+ // UNCOLLAPSED on disk while being EXPANDED in the live view.
763
+ const original =
764
+ JSON.stringify(
765
+ { version: 1, hooks: [{ id: 'existing', event: 'PreToolUse', command: '${HOOKS_DIR}/scripts/x.sh', enabled: true }] },
766
+ null,
767
+ 2
768
+ ) + '\n';
769
+ fs.writeFileSync(manifestPath, original);
770
+
771
+ const loader = loadManifest(manifestPath);
772
+ const tmpSpec = { id: 'selfcheck/tmp', event: 'PostToolUse', command: '${HOOKS_DIR}/scripts/tmp.sh' };
773
+
774
+ const stored = loader.addEntry(tmpSpec);
775
+ assert(stored.enabled === false, 'enabled should default to false');
776
+ // On disk: VERBATIM, token uncollapsed.
777
+ const afterAdd = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
778
+ const diskRow = afterAdd.hooks.find((h) => h.id === 'selfcheck/tmp');
779
+ assert(!!diskRow, 'temp entry must be persisted to the manifest');
780
+ assert(diskRow.command === '${HOOKS_DIR}/scripts/tmp.sh', 'on-disk command must keep ${HOOKS_DIR} uncollapsed');
781
+ // Live: EXPANDED so the engine sees it without reload.
782
+ const liveRow = loader.getSpec('selfcheck/tmp');
783
+ assert(!!liveRow, 'temp entry must be live in this.manifest.hooks');
784
+ assert(liveRow.command === path.join(loader.hooksDir, 'scripts/tmp.sh') ||
785
+ liveRow.command === loader.hooksDir + '/scripts/tmp.sh',
786
+ 'live command must be expanded: ' + liveRow.command);
787
+
788
+ // Duplicate id must be rejected.
789
+ let threw = false;
790
+ try { loader.addEntry(tmpSpec); } catch (_) { threw = true; }
791
+ assert(threw, 'adding a duplicate id must throw');
792
+
793
+ const removed = loader.removeEntry('selfcheck/tmp');
794
+ assert(removed === true, 'removeEntry should report a removal');
795
+ assert(!loader.getSpec('selfcheck/tmp'), 'temp entry must be gone from the live view');
796
+
797
+ // Manifest must be RESTORED byte-for-byte (the only other row is "existing").
798
+ const afterRemove = fs.readFileSync(manifestPath, 'utf8');
799
+ assert(afterRemove === original, 'manifest must be restored byte-for-byte after add+remove');
800
+
801
+ process.stdout.write('hook-loader self-check OK (add+remove round-trip; manifest restored)\n');
802
+ } finally {
803
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
804
+ }
805
+ }
806
+
807
+ if (require.main === module) {
808
+ try {
809
+ _selfCheck();
810
+ } catch (err) {
811
+ process.stderr.write(((err && err.stack) || String(err)) + '\n');
812
+ process.exit(1);
813
+ }
814
+ }
815
+
816
+ module.exports = {
817
+ loadManifest,
818
+ HookLoader,
819
+ // Exported for unit tests / reuse:
820
+ expandHooksDir,
821
+ resolveHooksDir,
822
+ normalizeState,
823
+ scriptBasename,
824
+ };