ruvnet-brain 3.9.85-dev → 3.9.130-dev

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.
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hook-registry.mjs — the MERGED hook registry census (ADR-055 §7 M1, build item #1).
4
+ *
5
+ * WHY THIS EXISTS. Every hook test in this repo before today read exactly ONE file:
6
+ * `plugin/hooks/hooks.json` (hook-contract.test.mjs:43). That is not the mesh. On this machine a
7
+ * live session loads **six** registries, and the five it could not see are where the defects were:
8
+ * an untimed blocking `Task|Agent` wall in the user layer, a stale project-level Stop override whose
9
+ * own `_note` says delete me, thirteen third-party handlers with no timeout at all, and a
10
+ * third-party `SessionStart` carrying `timeout: 180`. ADR-055 F16 names this precisely — "the test
11
+ * suite cannot see the merged registry" — and a suite that cannot see a layer cannot go red on it.
12
+ *
13
+ * WHAT IT DOES. Enumerates EVERY hook registration a session actually loads and normalizes each one
14
+ * to a single flat record, so an invariant can be stated once and evaluated over all layers:
15
+ *
16
+ * { layer, file, locator, event, matcher, command, timeout, mode, offBehavior, reachesStrangers }
17
+ *
18
+ * plus the derived fields the lint needs (`handler`, `shimId`, `codeRoot`, `hasFailsafe`,
19
+ * `declaredMode`, `tools`, `anchored`, `asyncRewake`, `contractSource`).
20
+ *
21
+ * THE SIX REGISTRIES (ADR-055 appendix A), and one deliberate split:
22
+ *
23
+ * layer | file | inMesh
24
+ * ---------------------|---------------------------------------------------------|-------
25
+ * plugin | <repo>/plugin/hooks/hooks.json | yes
26
+ * user | ~/.claude/settings.json | yes
27
+ * project | <repo>/.claude/settings.json | yes
28
+ * third-party:<name> | <plugin install>/hooks/hooks.json (enabled plugins only)| yes
29
+ * plugin-installed | ~/.claude/plugins/cache/ruvnet-brain/<v>/hooks/hooks.json | NO (mirror)
30
+ * marketplace-clone | ~/.claude/plugins/marketplaces/ruvnet-brain/…/hooks.json | NO (mirror)
31
+ *
32
+ * The last two are the SAME registrations as `plugin`, delivered as different code copies — the
33
+ * repo copy is the preimage, the cache copy is what Claude Code booted, the marketplace clone is
34
+ * what the user layer's own commands execute from. Counting all three in the mesh would invent 30
35
+ * phantom duplicates and make M1 fire on itself. They are enumerated (you cannot reason about
36
+ * "which code copy" without seeing them), reported, and DRIFT-checked — but excluded from the
37
+ * duplicate analysis, which asks a different question: is one HANDLER registered twice, from two
38
+ * different code roots, on an overlapping (event, tool) pair? That is F3 (route-dispatch: plugin
39
+ * shim + user layer's marketplace-clone copy) and F6 (continuation-gate: plugin + project).
40
+ *
41
+ * CI vs THIS MACHINE. `plugin` and `project` live in the repo and exist everywhere. The other four
42
+ * are machine-local and simply absent in CI — `discoverSources()` reports them as `present: false`
43
+ * rather than throwing, and the caller decides what that means. `--machine=0` (or CI=true) drops
44
+ * them explicitly, which is how the lint keeps CI green while still biting on this laptop.
45
+ *
46
+ * MODE IS DERIVED FROM CONTRACTS, NOT FROM VIBES. `declaredMode`/`offBehavior` come from exactly two
47
+ * authorities: `plugin/scripts/hook-shim.mjs`'s dispatch TABLE (parsed — it IS the authority, same
48
+ * approach wired-check.mjs already takes) and the checked-in `plugin/hooks/hook-contracts.json` for
49
+ * everything outside the shim. When neither declares one, `declaredMode` is null — that is M6's
50
+ * finding, not something to paper over with a guess. `effectiveMode` is separate and honest about
51
+ * being an inference: a command with no `|| true` tail CAN return a non-zero status to the harness,
52
+ * so it is blocking-CAPABLE whatever anyone intended. M1 uses that, because the harness does.
53
+ *
54
+ * node scripts/hook-registry.mjs # census table
55
+ * node scripts/hook-registry.mjs --json # every normalized record
56
+ * node scripts/hook-registry.mjs --machine=0 # repo-owned layers only (what CI sees)
57
+ */
58
+ import fs from 'node:fs';
59
+ import path from 'node:path';
60
+ import os from 'node:os';
61
+ import { fileURLToPath } from 'node:url';
62
+
63
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
64
+ export const REPO = path.resolve(HERE, '..');
65
+
66
+ /** Events whose matcher selects a TOOL. Everything else matches a lifecycle source, not a tool. */
67
+ export const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
68
+
69
+ /**
70
+ * The tool names a matcher can select. Not exhaustive of Claude Code's surface and not meant to be:
71
+ * it is the set this repo's walls actually reason about, plus the two names that caused real
72
+ * accidents — `NotebookEdit` (caught by the unanchored `Write|Edit|MultiEdit` substring, F4) and
73
+ * `TaskStop` (caught by the unanchored `Task`, F3). A tool missing from this list can only make the
74
+ * duplicate analysis MISS a pair, never invent one, so the list is safe to extend.
75
+ */
76
+ export const TOOLS = Object.freeze([
77
+ 'Task', 'TaskStop', 'Agent', 'Bash', 'BashOutput', 'Read', 'Write', 'Edit', 'MultiEdit',
78
+ 'NotebookEdit', 'NotebookRead', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'TodoWrite', 'Skill',
79
+ ]);
80
+
81
+ /** `*` and `.*` and `` are Claude Code's "everything" spellings; `*` is not a legal regex. */
82
+ const WILDCARDS = new Set(['', '*', '.*']);
83
+
84
+ /**
85
+ * Which tools a matcher selects. Claude Code SEARCHES the tool name with the matcher as a regex
86
+ * (not a full match) — which is exactly why `Task` also hits `TaskStop` and `Edit` also hits
87
+ * `MultiEdit`/`NotebookEdit`. Modelling it as `.test()` reproduces the real semantics, including
88
+ * the accidents. An unparseable matcher returns `['?']` so it lands in its own bucket instead of
89
+ * silently colliding with everything.
90
+ */
91
+ export function matchedTools(matcher, event) {
92
+ if (!TOOL_EVENTS.has(event)) return ['*'];
93
+ const m = (matcher ?? '').trim();
94
+ if (WILDCARDS.has(m)) return ['*'];
95
+ let re;
96
+ try { re = new RegExp(m); } catch { return ['?']; }
97
+ const hits = TOOLS.filter((t) => re.test(t));
98
+ return hits.length ? hits : ['?'];
99
+ }
100
+
101
+ /** Anchored = pinned at both ends. `^(Write|Edit)$` yes; `Write|Edit` no; `Task` no. */
102
+ export function isAnchored(matcher) {
103
+ const m = (matcher ?? '').trim();
104
+ return m.startsWith('^') && m.endsWith('$');
105
+ }
106
+
107
+ /** A trailing `|| true` (or `; true`) forces exit 0 — the harness can never see a refusal. */
108
+ export function hasFailsafe(command) {
109
+ return /(\|\||;)\s*true\s*$/.test((command ?? '').trim());
110
+ }
111
+
112
+ /** Script basenames named literally in a command string, in order. */
113
+ export function basenamesIn(command) {
114
+ return (command ?? '').match(/[\w.-]+\.(?:mjs|sh|py|cjs|js|cmd)\b/g) || [];
115
+ }
116
+
117
+ /** The hook-shim dispatch id, when this command routes through the shim. */
118
+ export function shimIdIn(command) {
119
+ const m = (command ?? '').match(/hook-shim\.mjs["'`]?\s+([a-zA-Z][\w-]*)/);
120
+ return m ? m[1] : null;
121
+ }
122
+
123
+ /**
124
+ * hook-shim.mjs's dispatch TABLE, parsed rather than re-implemented — it is the authority for
125
+ * `mode` and `offBehavior` on every shim-routed registration (ADR-054 §3: the OFF contract lives in
126
+ * that table as DATA). Each entry is a single-line object literal by convention, which
127
+ * brain-off.test.mjs already relies on.
128
+ */
129
+ export function shimTable(repo = REPO) {
130
+ let src = '';
131
+ // TWO LAYOUTS, ONE PARSER. In the checkout the payload sits under `plugin/`; in a PACKED install
132
+ // (~/.claude/plugins/cache/ruvnet-brain/ruvnet-brain/<v>/) the payload IS the root — `scripts/` and
133
+ // `hooks/` hang directly off it. The self-check reads the INSTALLED tree on a stranger's machine,
134
+ // so the authority-parser has to resolve both or it silently returns {} there and every mode/
135
+ // offBehavior assertion degrades to "undeclared" — a hand-copied list by omission.
136
+ for (const rel of ['plugin/scripts/hook-shim.mjs', 'scripts/hook-shim.mjs']) {
137
+ try { src = fs.readFileSync(path.join(repo, rel), 'utf8'); break; } catch { /* try next layout */ }
138
+ }
139
+ if (!src) return {};
140
+ const table = {};
141
+ const re = /'([\w-]+)':\s*\{([^}]*)\}/g;
142
+ let m;
143
+ while ((m = re.exec(src))) {
144
+ const [, id, body] = m;
145
+ const field = (name) => body.match(new RegExp(`${name}:\\s*'([\\w.-]+)'`))?.[1] ?? null;
146
+ if (!field('file')) continue; // not a dispatch entry
147
+ table[id] = {
148
+ file: field('file'),
149
+ interpreter: field('interpreter'),
150
+ mode: field('mode'),
151
+ offBehavior: field('offBehavior'),
152
+ };
153
+ }
154
+ return table;
155
+ }
156
+
157
+ /** The checked-in out-of-shim contract file (ADR-055 §6). Missing file → no contracts, not a throw. */
158
+ export function loadContracts(repo = REPO) {
159
+ // Same two-layout rule as shimTable() above — checkout (`plugin/hooks/`) or packed install
160
+ // (`hooks/`). Absence stays a non-throw empty result: on a packed install that predates this file
161
+ // the honest answer is "no out-of-shim contracts shipped here", not a crash and not an invention.
162
+ const candidates = ['plugin/hooks/hook-contracts.json', 'hooks/hook-contracts.json']
163
+ .map((rel) => path.join(repo, rel));
164
+ const file = candidates.find((f) => fs.existsSync(f)) ?? candidates[0];
165
+ try {
166
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
167
+ return {
168
+ file,
169
+ contracts: Array.isArray(doc.contracts) ? doc.contracts : [],
170
+ matcherAllowlist: Array.isArray(doc.matcherAllowlist) ? doc.matcherAllowlist : [],
171
+ };
172
+ } catch {
173
+ return { file, contracts: [], matcherAllowlist: [] };
174
+ }
175
+ }
176
+
177
+ /** Does a contract entry describe this registration? All declared fields must match. */
178
+ export function contractMatches(contract, rec) {
179
+ if (contract.layer && contract.layer !== rec.layer) return false;
180
+ if (contract.event && contract.event !== rec.event) return false;
181
+ if (contract.matcher !== undefined && contract.matcher !== (rec.matcher ?? '')) return false;
182
+ if (contract.commandIncludes && !rec.command.includes(contract.commandIncludes)) return false;
183
+ return Boolean(contract.commandIncludes || contract.event);
184
+ }
185
+
186
+ /** 1-based line of the `occurrence`-th literal appearance of a JSON string value. 0 if not found. */
187
+ function lineOfValue(raw, value, occurrence) {
188
+ const needle = JSON.stringify(value);
189
+ let idx = -1;
190
+ for (let i = 0; i <= occurrence; i += 1) {
191
+ idx = raw.indexOf(needle, idx + 1);
192
+ if (idx === -1) return 0;
193
+ }
194
+ return raw.slice(0, idx).split('\n').length;
195
+ }
196
+
197
+ /**
198
+ * Every `{ hooks: { <Event>: [ { matcher, hooks: [ {command, timeout, ...} ] } ] } }` document —
199
+ * the shape plugin hooks.json and every settings.json share. Returns raw registration tuples.
200
+ */
201
+ function readRegistrations(file) {
202
+ const raw = fs.readFileSync(file, 'utf8');
203
+ const doc = JSON.parse(raw);
204
+ const node = doc.hooks ?? doc; // settings.json nests under .hooks; a bare hooks map is accepted too
205
+ const out = [];
206
+ const seen = new Map(); // command → how many times already located, so repeats get distinct lines
207
+ for (const [event, entries] of Object.entries(node)) {
208
+ if (!Array.isArray(entries)) continue;
209
+ for (const group of entries) {
210
+ for (const h of group?.hooks ?? []) {
211
+ if (typeof h?.command !== 'string') continue;
212
+ const n = seen.get(h.command) ?? 0;
213
+ seen.set(h.command, n + 1);
214
+ out.push({
215
+ event,
216
+ matcher: group.matcher ?? '',
217
+ command: h.command,
218
+ timeout: typeof h.timeout === 'number' ? h.timeout : null,
219
+ asyncRewake: h.asyncRewake === true,
220
+ async: h.async === true,
221
+ if: typeof h.if === 'string' ? h.if : null,
222
+ line: lineOfValue(raw, h.command, n),
223
+ });
224
+ }
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * The ruvnet-brain plugin copy Claude Code actually booted, if this machine has one installed.
232
+ * Exported because the post-install self-check (scripts/selfcheck.mjs) must read the INSTALLED
233
+ * hooks.json rather than the repo's — a stranger's machine has no checkout, and a self-check that
234
+ * reads the preimage instead of the booted copy is the adjacent-door defect ADR-055 F16 names.
235
+ */
236
+ export function installedPluginHooks(home = os.homedir()) {
237
+ const base = path.join(home, '.claude', 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
238
+ let versions = [];
239
+ try { versions = fs.readdirSync(base); } catch { return null; }
240
+ const hits = versions
241
+ .map((v) => path.join(base, v, 'hooks', 'hooks.json'))
242
+ .filter((p) => fs.existsSync(p));
243
+ if (!hits.length) return null;
244
+ // Newest mtime wins — several generations can sit in the cache at once.
245
+ hits.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
246
+ return hits[0];
247
+ }
248
+
249
+ /** Enabled third-party plugins that register hooks, read from the machine's own plugin state. */
250
+ function thirdPartySources(home) {
251
+ const settings = readJsonSafe(path.join(home, '.claude', 'settings.json')) ?? {};
252
+ const enabled = settings.enabledPlugins ?? {};
253
+ const installed = readJsonSafe(path.join(home, '.claude', 'plugins', 'installed_plugins.json'));
254
+ if (!installed?.plugins) return [];
255
+ const out = [];
256
+ for (const [key, entries] of Object.entries(installed.plugins)) {
257
+ if (enabled[key] !== true) continue; // CC only loads enabled plugins
258
+ if (key.startsWith('ruvnet-brain@')) continue; // ours — enumerated as `plugin` + mirrors
259
+ for (const e of entries) {
260
+ if (e.scope !== 'user') continue; // project-scoped installs belong to that project
261
+ const file = path.join(e.installPath ?? '', 'hooks', 'hooks.json');
262
+ if (!fs.existsSync(file)) continue;
263
+ out.push({ layer: `third-party:${key.split('@')[0]}`, file, role: 'active', inMesh: true, reachesStrangers: false, machineLocal: true });
264
+ break;
265
+ }
266
+ }
267
+ return out.sort((a, b) => a.layer.localeCompare(b.layer));
268
+ }
269
+
270
+ function readJsonSafe(file) {
271
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
272
+ }
273
+
274
+ /**
275
+ * Every registry a session on this machine loads, plus the two code-copy mirrors of our own plugin.
276
+ * `includeMachine: false` returns only the two the repo owns — exactly what a CI runner has.
277
+ */
278
+ export function discoverSources({ repo = REPO, home = os.homedir(), includeMachine = true } = {}) {
279
+ const sources = [
280
+ { layer: 'plugin', file: path.join(repo, 'plugin/hooks/hooks.json'), role: 'shipped', inMesh: true, reachesStrangers: true, machineLocal: false },
281
+ { layer: 'project', file: path.join(repo, '.claude/settings.json'), role: 'active', inMesh: true, reachesStrangers: false, machineLocal: false },
282
+ ];
283
+ if (includeMachine) {
284
+ sources.push({ layer: 'user', file: path.join(home, '.claude/settings.json'), role: 'active', inMesh: true, reachesStrangers: false, machineLocal: true });
285
+ sources.push(...thirdPartySources(home));
286
+ const installed = installedPluginHooks(home);
287
+ if (installed) sources.push({ layer: 'plugin-installed', file: installed, role: 'mirror', inMesh: false, reachesStrangers: false, machineLocal: true });
288
+ sources.push({ layer: 'marketplace-clone', file: path.join(home, '.claude/plugins/marketplaces/ruvnet-brain/plugin/hooks/hooks.json'), role: 'mirror', inMesh: false, reachesStrangers: false, machineLocal: true });
289
+ }
290
+ return sources.map((s) => ({ ...s, present: fs.existsSync(s.file) }));
291
+ }
292
+
293
+ /**
294
+ * The census. One normalized record per registration, across every present source.
295
+ * Returns { records, sources, errors } — a malformed registry is an `errors` row, never a throw:
296
+ * a census that dies on one bad file tells you nothing about the other five.
297
+ */
298
+ export function buildRegistry({ repo = REPO, home = os.homedir(), includeMachine = true } = {}) {
299
+ const sources = discoverSources({ repo, home, includeMachine });
300
+ const table = shimTable(repo);
301
+ const { contracts, matcherAllowlist, file: contractsFile } = loadContracts(repo);
302
+ const records = [];
303
+ const errors = [];
304
+
305
+ for (const src of sources) {
306
+ if (!src.present) continue;
307
+ let regs;
308
+ try { regs = readRegistrations(src.file); } catch (e) { errors.push({ file: src.file, layer: src.layer, error: String(e.message ?? e) }); continue; }
309
+ for (const r of regs) {
310
+ const shimId = shimIdIn(r.command);
311
+ const shim = shimId ? table[shimId] : null;
312
+ const base = {
313
+ layer: src.layer,
314
+ file: src.file,
315
+ locator: `${path.basename(src.file)}:${r.line}`,
316
+ event: r.event,
317
+ matcher: r.matcher,
318
+ command: r.command,
319
+ timeout: r.timeout,
320
+ reachesStrangers: src.reachesStrangers,
321
+ };
322
+ const rec = {
323
+ ...base,
324
+ // ── derived, in the order the lint reads them ──
325
+ role: src.role,
326
+ inMesh: src.inMesh,
327
+ machineLocal: src.machineLocal,
328
+ shimId,
329
+ handler: shim?.file ?? basenamesIn(r.command).filter((b) => b !== 'hook-shim.mjs').pop() ?? null,
330
+ hasFailsafe: hasFailsafe(r.command),
331
+ anchored: isAnchored(r.matcher),
332
+ tools: matchedTools(r.matcher, r.event),
333
+ asyncRewake: r.asyncRewake,
334
+ if: r.if,
335
+ mode: null,
336
+ offBehavior: null,
337
+ contractSource: null,
338
+ };
339
+ if (shim) {
340
+ rec.mode = shim.mode;
341
+ rec.offBehavior = shim.offBehavior;
342
+ rec.contractSource = 'shim-table';
343
+ } else {
344
+ const c = contracts.find((x) => contractMatches(x, rec));
345
+ if (c) {
346
+ rec.mode = c.mode ?? null;
347
+ rec.offBehavior = c.offBehavior ?? null;
348
+ rec.contractSource = 'hook-contracts.json';
349
+ rec.contract = c;
350
+ }
351
+ }
352
+ // The harness's own view, independent of what anyone declared: no failsafe → the exit code
353
+ // reaches Claude Code, so this registration CAN block whatever its author meant.
354
+ rec.effectiveMode = rec.mode ?? (rec.hasFailsafe ? 'advisory' : 'blocking-capable');
355
+ // Which code copy the body comes from — the axis F3/F6 are about.
356
+ rec.codeRoot = codeRootOf(rec, repo, home);
357
+ records.push(rec);
358
+ }
359
+ }
360
+ return { records, sources, errors, contractsFile, matcherAllowlist, shimTable: table };
361
+ }
362
+
363
+ /**
364
+ * The code copy a registration executes from. This is the axis that makes a duplicate a DEFECT
365
+ * rather than a harmless repeat: two registrations of the same handler from ONE root are one
366
+ * behavior; from TWO roots they are two behaviors that can disagree the moment one copy updates.
367
+ */
368
+ export function codeRootOf(rec, repo = REPO, home = os.homedir()) {
369
+ const c = rec.command;
370
+ if (c.includes('${CLAUDE_PLUGIN_ROOT}')) {
371
+ // Shim-routed commands resolve their BODY from the active spine generation, not from the
372
+ // plugin root — that indirection is the whole point of ADR-023.
373
+ return rec.shimId ? 'spine' : 'plugin-root';
374
+ }
375
+ // The SCRIPT path, not the interpreter's: `/bin/bash "/Users/…/route-dispatch.sh"` names two
376
+ // absolute paths and only the second one says which copy of the code runs. Taking the first
377
+ // reported every user-layer hook as living in /bin, which would have hidden F3's whole point.
378
+ const paths = c.match(/\/[^\s"']+/g) ?? [];
379
+ const p = paths.find((x) => /\.(?:mjs|sh|py|cjs|js|cmd)$/.test(x)) ?? paths[0];
380
+ if (!p) return 'unknown';
381
+ if (p.startsWith(path.join(home, '.claude/plugins/marketplaces'))) return 'marketplace-clone';
382
+ if (p.startsWith(path.join(home, '.claude/plugins/cache'))) return 'plugin-cache';
383
+ if (p.startsWith(path.join(home, '.claude/hooks'))) return 'user-hooks';
384
+ if (p.startsWith(path.join(home, '.claude/skills'))) return 'user-skills';
385
+ if (p.startsWith(repo)) return 'repo-checkout';
386
+ // No guessing beyond this point: the owning directory IS the identity of the code copy, and
387
+ // naming it verbatim keeps "two roots" an exact comparison instead of a bucketing heuristic.
388
+ return `dir:${path.dirname(p)}`;
389
+ }
390
+
391
+ /** Per-layer totals, in the shape ADR-055 appendix A states them. */
392
+ export function census(reg) {
393
+ const byLayer = new Map();
394
+ for (const s of reg.sources) byLayer.set(s.layer, { layer: s.layer, file: s.file, present: s.present, inMesh: s.inMesh, count: 0 });
395
+ for (const r of reg.records) byLayer.get(r.layer).count += 1;
396
+ const rows = [...byLayer.values()];
397
+ return {
398
+ rows,
399
+ mesh: rows.filter((r) => r.inMesh).reduce((n, r) => n + r.count, 0),
400
+ mirrors: rows.filter((r) => !r.inMesh).reduce((n, r) => n + r.count, 0),
401
+ total: reg.records.length,
402
+ };
403
+ }
404
+
405
+ // ── THE MESH INVARIANTS (ADR-055 §7) ────────────────────────────────────────────────────────────
406
+ //
407
+ // Each is a PURE function from records → findings, for three reasons that all matter:
408
+ // 1. one implementation, shared by the vitest lint and by any future pre-push/CI gate — the
409
+ // "adjacent door" defect (F16) is exactly what happens when a gate and its test are two
410
+ // different code paths;
411
+ // 2. a finding carries its own evidence (layer, locator, what was expected) so a refusal is
412
+ // actionable rather than a boolean;
413
+ // 3. they can be fed SYNTHETIC records, which is how §7.15 falsifiability is proven — break the
414
+ // input, watch the invariant go red. An invariant that has never been shown to fail is a
415
+ // claim, not a check.
416
+ // `mesh(records)` is the shared filter: the mirrors are the same registrations delivered twice, and
417
+ // counting them would make every invariant fire on itself.
418
+
419
+ export const mesh = (records) => records.filter((r) => r.inMesh);
420
+
421
+ /**
422
+ * M1 — no HANDLER is registered twice, on an overlapping (event, tool), FROM TWO DIFFERENT CODE
423
+ * ROOTS. Two registrations of one handler from one root are one behavior. From two roots they are
424
+ * two behaviors that diverge the instant one copy updates — and one of them is invisible to the
425
+ * other's tests. Live instances (ADR-055 F3, F6): route-dispatch.sh registered by the plugin
426
+ * (body resolved from the spine) AND by the user layer (body from the marketplace clone);
427
+ * continuation-gate.mjs registered by the plugin AND by this repo's own project settings.
428
+ * `blocking: true` marks the severe class — two walls that can both refuse the same call.
429
+ */
430
+ export function lintM1(records) {
431
+ const groups = new Map();
432
+ for (const r of mesh(records)) {
433
+ if (!r.handler) continue;
434
+ for (const tool of r.tools) {
435
+ const key = `${r.event}::${tool}::${r.handler}`;
436
+ if (!groups.has(key)) groups.set(key, []);
437
+ groups.get(key).push(r);
438
+ }
439
+ }
440
+ const findings = [];
441
+ for (const [key, rs] of groups) {
442
+ const roots = [...new Set(rs.map((r) => r.codeRoot))];
443
+ if (roots.length < 2) continue;
444
+ findings.push({
445
+ invariant: 'M1',
446
+ key,
447
+ roots,
448
+ blocking: rs.some((r) => r.effectiveMode !== 'advisory'),
449
+ where: rs.map((r) => `${r.layer} ${r.locator} [${r.codeRoot}] ${r.effectiveMode}`),
450
+ });
451
+ }
452
+ return findings.sort((a, b) => a.key.localeCompare(b.key));
453
+ }
454
+
455
+ /**
456
+ * M3 — TIMEOUT TOTALITY. Two failures, one invariant, because they are the same defect seen from
457
+ * either side: a MISSING timeout is Claude Code's default (600s outside the prompt path) silently
458
+ * applied to a stranger's session, and a value >60 is a milliseconds-intent number in a seconds
459
+ * field — "any timeout over 60 is a wrong-unit bug by fiat" (ADR-055 §7.5, Fable's rule). The user
460
+ * layer's whole 2000/3000/5000/30000 schism (F1) and its untimed blocking `Task|Agent` wall (F2)
461
+ * were both this, and both are now regression fixtures rather than open findings.
462
+ */
463
+ export function lintM3(records) {
464
+ const findings = [];
465
+ for (const r of mesh(records)) {
466
+ if (typeof r.timeout !== 'number') findings.push({ invariant: 'M3', kind: 'missing', layer: r.layer, locator: r.locator, event: r.event, handler: r.handler, detail: 'no explicit timeout — the host default applies' });
467
+ else if (r.timeout > 60) findings.push({ invariant: 'M3', kind: 'wrong-unit', layer: r.layer, locator: r.locator, event: r.event, handler: r.handler, detail: `timeout ${r.timeout} > 60 — a seconds field holding a milliseconds-intent value` });
468
+ }
469
+ return findings;
470
+ }
471
+
472
+ /**
473
+ * M5 — ANCHORED MATCHERS on the two events whose matcher names a TOOL. Claude Code SEARCHES the
474
+ * tool name, so `Task` also selects TaskStop and `Write|Edit|MultiEdit` also selects NotebookEdit
475
+ * (F3, F4) — a wall silently guarding a tool nobody chose to guard. Anchoring is meaningless for
476
+ * SessionStart/Stop/SessionEnd/UserPromptSubmit, whose matcher selects a lifecycle source, so they
477
+ * are out of scope rather than allowlisted en masse.
478
+ *
479
+ * The allowlist is a RATCHET, not an amnesty: each entry names one exact registration and the ADR
480
+ * item that retires it, stale entries are themselves a failure (see `lintAllowlistStale`), and a
481
+ * NEW unanchored matcher is red on arrival. ADR-055's own build order puts registration changes at
482
+ * item 3 behind battery v2 at item 2 — so today's anchoring debt is recorded, not hidden, and not
483
+ * fixed out of order.
484
+ */
485
+ export function lintM5(records, allowlist = []) {
486
+ const findings = [];
487
+ for (const r of mesh(records)) {
488
+ if (!TOOL_EVENTS.has(r.event)) continue;
489
+ if (r.anchored) continue;
490
+ if (allowlist.some((a) => allowlistMatches(a, r))) continue;
491
+ findings.push({ invariant: 'M5', layer: r.layer, locator: r.locator, event: r.event, matcher: r.matcher, handler: r.handler, tools: r.tools });
492
+ }
493
+ return findings;
494
+ }
495
+
496
+ const allowlistMatches = (a, r) => a.layer === r.layer && a.event === r.event && a.matcher === (r.matcher ?? '')
497
+ && (!a.handler || a.handler === r.handler);
498
+
499
+ /** An allowlist entry matching no live registration is fiction. Fiction rots into permission. */
500
+ export function lintAllowlistStale(records, allowlist = []) {
501
+ const active = mesh(records);
502
+ return allowlist
503
+ .filter((a) => !active.some((r) => allowlistMatches(a, r)))
504
+ .map((a) => ({ invariant: 'M5-stale', entry: `${a.layer} ${a.event} ${JSON.stringify(a.matcher)} ${a.handler ?? ''}`, reason: a.reason }));
505
+ }
506
+
507
+ /**
508
+ * M6 — OFF-BEHAVIOR TOTALITY (ADR-055 §7.4, closing F14/F5). ADR-054 made brain-OFF a per-hook
509
+ * contract carried as DATA — but only for the shim's eleven table entries. Every other
510
+ * registration on this machine, INCLUDING the plugin's own Stop hook, had no declared answer at
511
+ * all: nobody could say whether it runs, goes silent, or splits when the user switches the brain
512
+ * off, which means "off" was never a testable state for two thirds of the mesh. A registration
513
+ * must resolve to `silence | run | partial` through the shim table or through the checked-in
514
+ * hook-contracts.json; absent from both is the finding.
515
+ */
516
+ export const OFF_BEHAVIORS = Object.freeze(['silence', 'run', 'partial']);
517
+
518
+ export function lintM6(records) {
519
+ return mesh(records)
520
+ .filter((r) => !OFF_BEHAVIORS.includes(r.offBehavior))
521
+ .map((r) => ({ invariant: 'M6', layer: r.layer, locator: r.locator, event: r.event, matcher: r.matcher, handler: r.handler, declared: r.offBehavior, contractSource: r.contractSource }));
522
+ }
523
+
524
+ /** Every invariant at once, keyed by name — the shape a report or a gate wants. */
525
+ export function lintAll(reg) {
526
+ return {
527
+ M1: lintM1(reg.records),
528
+ M3: lintM3(reg.records),
529
+ M5: lintM5(reg.records, reg.matcherAllowlist),
530
+ 'M5-stale': lintAllowlistStale(reg.records, reg.matcherAllowlist),
531
+ M6: lintM6(reg.records),
532
+ };
533
+ }
534
+
535
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────────────
536
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
537
+ const includeMachine = !process.argv.includes('--machine=0') && process.env.CI !== 'true';
538
+ const reg = buildRegistry({ includeMachine });
539
+ if (process.argv.includes('--json')) {
540
+ process.stdout.write(`${JSON.stringify(reg.records, null, 2)}\n`);
541
+ } else if (process.argv.includes('--lint')) {
542
+ const found = lintAll(reg);
543
+ let n = 0;
544
+ for (const [name, findings] of Object.entries(found)) {
545
+ process.stdout.write(`\n${name}: ${findings.length ? `${findings.length} FINDING(S)` : 'clean'}\n`);
546
+ for (const f of findings) { n += 1; process.stdout.write(` ${JSON.stringify(f)}\n`); }
547
+ }
548
+ process.stdout.write(`\n${n} finding(s) over ${mesh(reg.records).length} mesh registrations${includeMachine ? '' : ' (repo-owned layers only)'}\n`);
549
+ process.exit(n ? 1 : 0);
550
+ } else {
551
+ const c = census(reg);
552
+ process.stdout.write(`Merged hook registry — ${c.mesh} active registrations in the mesh, ${c.mirrors} in code-copy mirrors\n\n`);
553
+ for (const row of c.rows) {
554
+ const tag = row.present ? String(row.count).padStart(3) : ' --';
555
+ process.stdout.write(`${tag} ${row.inMesh ? ' ' : '~'} ${row.layer.padEnd(20)} ${row.present ? row.file : '(absent on this machine)'}\n`);
556
+ }
557
+ process.stdout.write('\n');
558
+ for (const r of reg.records) {
559
+ process.stdout.write(
560
+ `${r.layer.padEnd(20)} ${r.locator.padEnd(20)} ${r.event.padEnd(17)} ${String(r.matcher || '*').padEnd(34)} `
561
+ + `t=${String(r.timeout ?? 'NONE').padEnd(5)} ${String(r.mode ?? r.effectiveMode).padEnd(16)} `
562
+ + `off=${String(r.offBehavior ?? 'UNDECLARED').padEnd(11)} ${r.reachesStrangers ? 'ships' : 'local'} ${r.handler ?? r.command}\n`,
563
+ );
564
+ }
565
+ for (const e of reg.errors) process.stdout.write(`\nERROR ${e.layer}: ${e.file}: ${e.error}\n`);
566
+ }
567
+ }