ruvnet-brain 4.0.12 → 4.0.28

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 (46) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +2 -2
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  6. package/plugin/scripts/anticipate.sh +80 -14
  7. package/plugin/scripts/capability-registry.mjs +994 -0
  8. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  9. package/plugin/scripts/continuation-gate.mjs +129 -1
  10. package/plugin/scripts/gates.mjs +146 -0
  11. package/plugin/scripts/goal-match.mjs +398 -0
  12. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  13. package/plugin/scripts/hook-registry.mjs +616 -0
  14. package/plugin/scripts/hook-shim.mjs +13 -2
  15. package/plugin/scripts/learning-enable.mjs +382 -0
  16. package/plugin/scripts/lesson-promote.mjs +262 -0
  17. package/plugin/scripts/lesson-provenance.mjs +43 -0
  18. package/plugin/scripts/lesson-store.mjs +67 -56
  19. package/plugin/scripts/memory-doctor.mjs +345 -0
  20. package/plugin/scripts/nightly-controller.mjs +98 -0
  21. package/plugin/scripts/runtime-preferences.mjs +18 -0
  22. package/plugin/scripts/session-start-core.mjs +3 -3
  23. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  24. package/plugin/scripts/user-settings.mjs +672 -0
  25. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  26. package/scripts/advocacy-outcomes.mjs +4 -808
  27. package/scripts/capability-registry.mjs +4 -876
  28. package/scripts/corpus-qa.mjs +44 -6
  29. package/scripts/doc-currency.mjs +30 -2
  30. package/scripts/gates.mjs +4 -146
  31. package/scripts/goal-match.mjs +4 -398
  32. package/scripts/hook-registry.mjs +4 -567
  33. package/scripts/issue-watch.mjs +108 -0
  34. package/scripts/learning-enable.mjs +4 -380
  35. package/scripts/lesson-promote.mjs +4 -262
  36. package/scripts/memory-doctor.mjs +4 -345
  37. package/scripts/nightly-controller.mjs +4 -66
  38. package/scripts/nightly-wrapper.sh +23 -1
  39. package/scripts/proactivity-metrics.mjs +8 -1
  40. package/scripts/qe/ux-suite.mjs +72 -1
  41. package/scripts/release-abort-stale.mjs +111 -0
  42. package/scripts/release-convergence-watchdog.mjs +119 -0
  43. package/scripts/release-transaction-provider.mjs +61 -7
  44. package/scripts/release-transaction.mjs +63 -17
  45. package/scripts/self-update.mjs +63 -10
  46. package/scripts/user-settings.mjs +4 -640
@@ -0,0 +1,616 @@
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
+
65
+ /**
66
+ * THE DEFAULT ROOT, RESOLVED BY PROBE — because this file now lives inside the payload, and `..`
67
+ * means two different things there (2026-08-06, the L4 payload-boundary move).
68
+ *
69
+ * This was `path.resolve(HERE, '..')` when the module sat at `<src>/scripts/`, where that expression
70
+ * gave the repo root. Moving the file to `<src>/plugin/scripts/` — so the Stable Spine and the Codex
71
+ * install carry it at all — silently REDEFINED it to `<src>/plugin`, and every default-argument
72
+ * consumer below (discoverSources's `plugin`/`project` layers, shimTable, loadContracts,
73
+ * capability-registry's dispatchGateWiring) would then look for `plugin/hooks/hooks.json` under
74
+ * `<src>/plugin/` and find nothing. None of them throw on a missing file: they return present:false
75
+ * / {} / no contracts. The census would have gone QUIET rather than red — the same shape of failure
76
+ * as the inert hook this move exists to fix.
77
+ *
78
+ * The test below is EXACT rather than a heuristic, and deliberately so: this file's own directory is
79
+ * `<candidate>/plugin/scripts` if and ONLY IF `<candidate>` is a non-flattened root. It therefore
80
+ * needs no probe file, and cannot be fooled by a root that legitimately omits one — the Console's
81
+ * runtime root, for instance, carries `plugin/scripts` but not `plugin/hooks` (see
82
+ * CONSOLE_RUNTIME_SURFACE in scripts/console-runtime-identity.mjs). Measured layouts:
83
+ *
84
+ * <src>/plugin/scripts/hook-registry.mjs → ../../plugin/scripts === HERE → <src>
85
+ * ~/.cache/ruvnet-brain/versions/<gen>/scripts/… → does not match → .. (<gen>)
86
+ * ~/.claude/plugins/cache/…/<ver>/scripts/… → does not match → .. (<ver>)
87
+ */
88
+ function resolveRoot(here) {
89
+ return path.resolve(here, '..', '..', 'plugin', 'scripts') === here
90
+ ? path.resolve(here, '..', '..')
91
+ : path.resolve(here, '..');
92
+ }
93
+ export const REPO = resolveRoot(HERE);
94
+
95
+ /** Events whose matcher selects a TOOL. Everything else matches a lifecycle source, not a tool. */
96
+ export const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
97
+
98
+ /**
99
+ * The tool names a matcher can select. Not exhaustive of Claude Code's surface and not meant to be:
100
+ * it is the set this repo's walls actually reason about, plus the two names that caused real
101
+ * accidents — `NotebookEdit` (caught by the unanchored `Write|Edit|MultiEdit` substring, F4) and
102
+ * `TaskStop` (caught by the unanchored `Task`, F3). A tool missing from this list can only make the
103
+ * duplicate analysis MISS a pair, never invent one, so the list is safe to extend.
104
+ */
105
+ export const TOOLS = Object.freeze([
106
+ 'Task', 'TaskStop', 'Agent', 'Bash', 'BashOutput', 'Read', 'Write', 'Edit', 'MultiEdit',
107
+ 'NotebookEdit', 'NotebookRead', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'TodoWrite', 'Skill',
108
+ ]);
109
+
110
+ /** `*` and `.*` and `` are Claude Code's "everything" spellings; `*` is not a legal regex. */
111
+ const WILDCARDS = new Set(['', '*', '.*']);
112
+
113
+ /**
114
+ * Which tools a matcher selects. Claude Code SEARCHES the tool name with the matcher as a regex
115
+ * (not a full match) — which is exactly why `Task` also hits `TaskStop` and `Edit` also hits
116
+ * `MultiEdit`/`NotebookEdit`. Modelling it as `.test()` reproduces the real semantics, including
117
+ * the accidents. An unparseable matcher returns `['?']` so it lands in its own bucket instead of
118
+ * silently colliding with everything.
119
+ */
120
+ export function matchedTools(matcher, event) {
121
+ if (!TOOL_EVENTS.has(event)) return ['*'];
122
+ const m = (matcher ?? '').trim();
123
+ if (WILDCARDS.has(m)) return ['*'];
124
+ let re;
125
+ try { re = new RegExp(m); } catch { return ['?']; }
126
+ const hits = TOOLS.filter((t) => re.test(t));
127
+ return hits.length ? hits : ['?'];
128
+ }
129
+
130
+ /** Anchored = pinned at both ends. `^(Write|Edit)$` yes; `Write|Edit` no; `Task` no. */
131
+ export function isAnchored(matcher) {
132
+ const m = (matcher ?? '').trim();
133
+ return m.startsWith('^') && m.endsWith('$');
134
+ }
135
+
136
+ /** A trailing `|| true` (or `; true`) forces exit 0 — the harness can never see a refusal. */
137
+ export function hasFailsafe(command) {
138
+ return /(\|\||;)\s*true\s*$/.test((command ?? '').trim());
139
+ }
140
+
141
+ /** Script basenames named literally in a command string, in order. */
142
+ export function basenamesIn(command) {
143
+ return (command ?? '').match(/[\w.-]+\.(?:mjs|sh|py|cjs|js|cmd)\b/g) || [];
144
+ }
145
+
146
+ /** The hook-shim dispatch id, when this command routes through the shim. */
147
+ export function shimIdIn(command) {
148
+ const m = (command ?? '').match(/hook-shim\.mjs["'`]?\s+([a-zA-Z][\w-]*)/);
149
+ return m ? m[1] : null;
150
+ }
151
+
152
+ /**
153
+ * hook-shim.mjs's dispatch TABLE, parsed rather than re-implemented — it is the authority for
154
+ * `mode` and `offBehavior` on every shim-routed registration (ADR-054 §3: the OFF contract lives in
155
+ * that table as DATA). Each entry is a single-line object literal by convention, which
156
+ * brain-off.test.mjs already relies on.
157
+ */
158
+ export function shimTable(repo = REPO) {
159
+ let src = '';
160
+ // TWO LAYOUTS, ONE PARSER. In the checkout the payload sits under `plugin/`; in a PACKED install
161
+ // (~/.claude/plugins/cache/ruvnet-brain/ruvnet-brain/<v>/) the payload IS the root — `scripts/` and
162
+ // `hooks/` hang directly off it. The self-check reads the INSTALLED tree on a stranger's machine,
163
+ // so the authority-parser has to resolve both or it silently returns {} there and every mode/
164
+ // offBehavior assertion degrades to "undeclared" — a hand-copied list by omission.
165
+ for (const rel of ['plugin/scripts/hook-shim.mjs', 'scripts/hook-shim.mjs']) {
166
+ try { src = fs.readFileSync(path.join(repo, rel), 'utf8'); break; } catch { /* try next layout */ }
167
+ }
168
+ if (!src) return {};
169
+ const table = {};
170
+ const re = /'([\w-]+)':\s*\{([^}]*)\}/g;
171
+ let m;
172
+ while ((m = re.exec(src))) {
173
+ const [, id, body] = m;
174
+ const field = (name) => body.match(new RegExp(`${name}:\\s*'([\\w.-]+)'`))?.[1] ?? null;
175
+ if (!field('file')) continue; // not a dispatch entry
176
+ table[id] = {
177
+ file: field('file'),
178
+ interpreter: field('interpreter'),
179
+ mode: field('mode'),
180
+ offBehavior: field('offBehavior'),
181
+ };
182
+ }
183
+ return table;
184
+ }
185
+
186
+ /** The checked-in out-of-shim contract file (ADR-055 §6). Missing file → no contracts, not a throw. */
187
+ export function loadContracts(repo = REPO) {
188
+ // Same two-layout rule as shimTable() above — checkout (`plugin/hooks/`) or packed install
189
+ // (`hooks/`). Absence stays a non-throw empty result: on a packed install that predates this file
190
+ // the honest answer is "no out-of-shim contracts shipped here", not a crash and not an invention.
191
+ const candidates = ['plugin/hooks/hook-contracts.json', 'hooks/hook-contracts.json']
192
+ .map((rel) => path.join(repo, rel));
193
+ const file = candidates.find((f) => fs.existsSync(f)) ?? candidates[0];
194
+ try {
195
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
196
+ return {
197
+ file,
198
+ contracts: Array.isArray(doc.contracts) ? doc.contracts : [],
199
+ matcherAllowlist: Array.isArray(doc.matcherAllowlist) ? doc.matcherAllowlist : [],
200
+ };
201
+ } catch {
202
+ return { file, contracts: [], matcherAllowlist: [] };
203
+ }
204
+ }
205
+
206
+ /** Does a contract entry describe this registration? All declared fields must match. */
207
+ export function contractMatches(contract, rec) {
208
+ if (contract.layer && contract.layer !== rec.layer) return false;
209
+ if (contract.event && contract.event !== rec.event) return false;
210
+ if (contract.matcher !== undefined && contract.matcher !== (rec.matcher ?? '')) return false;
211
+ if (contract.commandIncludes && !rec.command.includes(contract.commandIncludes)) return false;
212
+ return Boolean(contract.commandIncludes || contract.event);
213
+ }
214
+
215
+ /** 1-based line of the `occurrence`-th literal appearance of a JSON string value. 0 if not found. */
216
+ function lineOfValue(raw, value, occurrence) {
217
+ const needle = JSON.stringify(value);
218
+ let idx = -1;
219
+ for (let i = 0; i <= occurrence; i += 1) {
220
+ idx = raw.indexOf(needle, idx + 1);
221
+ if (idx === -1) return 0;
222
+ }
223
+ return raw.slice(0, idx).split('\n').length;
224
+ }
225
+
226
+ /**
227
+ * Every `{ hooks: { <Event>: [ { matcher, hooks: [ {command, timeout, ...} ] } ] } }` document —
228
+ * the shape plugin hooks.json and every settings.json share. Returns raw registration tuples.
229
+ */
230
+ function readRegistrations(file) {
231
+ const raw = fs.readFileSync(file, 'utf8');
232
+ const doc = JSON.parse(raw);
233
+ const node = doc.hooks ?? doc; // settings.json nests under .hooks; a bare hooks map is accepted too
234
+ const out = [];
235
+ const seen = new Map(); // command → how many times already located, so repeats get distinct lines
236
+ for (const [event, entries] of Object.entries(node)) {
237
+ if (!Array.isArray(entries)) continue;
238
+ for (const group of entries) {
239
+ for (const h of group?.hooks ?? []) {
240
+ if (typeof h?.command !== 'string') continue;
241
+ const n = seen.get(h.command) ?? 0;
242
+ seen.set(h.command, n + 1);
243
+ out.push({
244
+ event,
245
+ matcher: group.matcher ?? '',
246
+ command: h.command,
247
+ timeout: typeof h.timeout === 'number' ? h.timeout : null,
248
+ asyncRewake: h.asyncRewake === true,
249
+ async: h.async === true,
250
+ if: typeof h.if === 'string' ? h.if : null,
251
+ line: lineOfValue(raw, h.command, n),
252
+ });
253
+ }
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /**
260
+ * The ruvnet-brain plugin copy Claude Code actually booted, if this machine has one installed.
261
+ * Exported because the post-install self-check (scripts/selfcheck.mjs) must read the INSTALLED
262
+ * hooks.json rather than the repo's — a stranger's machine has no checkout, and a self-check that
263
+ * reads the preimage instead of the booted copy is the adjacent-door defect ADR-055 F16 names.
264
+ */
265
+ export function installedPluginHooks(home = os.homedir()) {
266
+ const base = path.join(home, '.claude', 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
267
+ let versions = [];
268
+ try { versions = fs.readdirSync(base); } catch { return null; }
269
+ const hits = versions
270
+ .map((v) => path.join(base, v, 'hooks', 'hooks.json'))
271
+ .filter((p) => fs.existsSync(p));
272
+ if (!hits.length) return null;
273
+ // Newest mtime wins — several generations can sit in the cache at once.
274
+ hits.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
275
+ return hits[0];
276
+ }
277
+
278
+ /** Enabled third-party plugins that register hooks, read from the machine's own plugin state. */
279
+ function thirdPartySources(home) {
280
+ const settings = readJsonSafe(path.join(home, '.claude', 'settings.json')) ?? {};
281
+ const enabled = settings.enabledPlugins ?? {};
282
+ const installed = readJsonSafe(path.join(home, '.claude', 'plugins', 'installed_plugins.json'));
283
+ if (!installed?.plugins) return [];
284
+ const out = [];
285
+ for (const [key, entries] of Object.entries(installed.plugins)) {
286
+ if (enabled[key] !== true) continue; // CC only loads enabled plugins
287
+ if (key.startsWith('ruvnet-brain@')) continue; // ours — enumerated as `plugin` + mirrors
288
+ for (const e of entries) {
289
+ if (e.scope !== 'user') continue; // project-scoped installs belong to that project
290
+ const file = path.join(e.installPath ?? '', 'hooks', 'hooks.json');
291
+ if (!fs.existsSync(file)) continue;
292
+ out.push({ layer: `third-party:${key.split('@')[0]}`, file, role: 'active', inMesh: true, reachesStrangers: false, machineLocal: true });
293
+ break;
294
+ }
295
+ }
296
+ return out.sort((a, b) => a.layer.localeCompare(b.layer));
297
+ }
298
+
299
+ function readJsonSafe(file) {
300
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
301
+ }
302
+
303
+ /**
304
+ * Every registry a session on this machine loads, plus the two code-copy mirrors of our own plugin.
305
+ * `includeMachine: false` returns only the two the repo owns — exactly what a CI runner has.
306
+ */
307
+ export function discoverSources({ repo = REPO, home = os.homedir(), includeMachine = true } = {}) {
308
+ // TWO LAYOUTS, ONE CENSUS — the identical rule shimTable() and loadContracts() above already state,
309
+ // which this function was the only one of the three NOT to apply. In a checkout the payload sits
310
+ // under `plugin/`; in a packed install (the Spine's versions/<gen>/, the plugin cache's <ver>/) the
311
+ // payload IS the root and `hooks/` hangs directly off it. Resolving only the first form made the
312
+ // `plugin` layer — the one row that `reachesStrangers` — report present:false on every real install,
313
+ // so the mesh census silently described a machine with no shipped hooks at all.
314
+ const pluginHooks = ['plugin/hooks/hooks.json', 'hooks/hooks.json']
315
+ .map((rel) => path.join(repo, rel));
316
+ const sources = [
317
+ { layer: 'plugin', file: pluginHooks.find((f) => fs.existsSync(f)) ?? pluginHooks[0], role: 'shipped', inMesh: true, reachesStrangers: true, machineLocal: false },
318
+ { layer: 'project', file: path.join(repo, '.claude/settings.json'), role: 'active', inMesh: true, reachesStrangers: false, machineLocal: false },
319
+ ];
320
+ if (includeMachine) {
321
+ sources.push({ layer: 'user', file: path.join(home, '.claude/settings.json'), role: 'active', inMesh: true, reachesStrangers: false, machineLocal: true });
322
+ sources.push(...thirdPartySources(home));
323
+ const installed = installedPluginHooks(home);
324
+ if (installed) sources.push({ layer: 'plugin-installed', file: installed, role: 'mirror', inMesh: false, reachesStrangers: false, machineLocal: true });
325
+ 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 });
326
+ }
327
+ return sources.map((s) => ({ ...s, present: fs.existsSync(s.file) }));
328
+ }
329
+
330
+ /**
331
+ * The census. One normalized record per registration, across every present source.
332
+ * Returns { records, sources, errors } — a malformed registry is an `errors` row, never a throw:
333
+ * a census that dies on one bad file tells you nothing about the other five.
334
+ */
335
+ export function buildRegistry({ repo = REPO, home = os.homedir(), includeMachine = true } = {}) {
336
+ const sources = discoverSources({ repo, home, includeMachine });
337
+ const table = shimTable(repo);
338
+ const { contracts, matcherAllowlist, file: contractsFile } = loadContracts(repo);
339
+ const records = [];
340
+ const errors = [];
341
+
342
+ for (const src of sources) {
343
+ if (!src.present) continue;
344
+ let regs;
345
+ try { regs = readRegistrations(src.file); } catch (e) { errors.push({ file: src.file, layer: src.layer, error: String(e.message ?? e) }); continue; }
346
+ for (const r of regs) {
347
+ const shimId = shimIdIn(r.command);
348
+ const shim = shimId ? table[shimId] : null;
349
+ const base = {
350
+ layer: src.layer,
351
+ file: src.file,
352
+ locator: `${path.basename(src.file)}:${r.line}`,
353
+ event: r.event,
354
+ matcher: r.matcher,
355
+ command: r.command,
356
+ timeout: r.timeout,
357
+ reachesStrangers: src.reachesStrangers,
358
+ };
359
+ const rec = {
360
+ ...base,
361
+ // ── derived, in the order the lint reads them ──
362
+ role: src.role,
363
+ inMesh: src.inMesh,
364
+ machineLocal: src.machineLocal,
365
+ shimId,
366
+ handler: shim?.file ?? basenamesIn(r.command).filter((b) => b !== 'hook-shim.mjs').pop() ?? null,
367
+ hasFailsafe: hasFailsafe(r.command),
368
+ anchored: isAnchored(r.matcher),
369
+ tools: matchedTools(r.matcher, r.event),
370
+ asyncRewake: r.asyncRewake,
371
+ if: r.if,
372
+ mode: null,
373
+ offBehavior: null,
374
+ contractSource: null,
375
+ };
376
+ if (shim) {
377
+ rec.mode = shim.mode;
378
+ rec.offBehavior = shim.offBehavior;
379
+ rec.contractSource = 'shim-table';
380
+ } else {
381
+ const c = contracts.find((x) => contractMatches(x, rec));
382
+ if (c) {
383
+ rec.mode = c.mode ?? null;
384
+ rec.offBehavior = c.offBehavior ?? null;
385
+ rec.contractSource = 'hook-contracts.json';
386
+ rec.contract = c;
387
+ }
388
+ }
389
+ // The harness's own view, independent of what anyone declared: no failsafe → the exit code
390
+ // reaches Claude Code, so this registration CAN block whatever its author meant.
391
+ rec.effectiveMode = rec.mode ?? (rec.hasFailsafe ? 'advisory' : 'blocking-capable');
392
+ // Which code copy the body comes from — the axis F3/F6 are about.
393
+ rec.codeRoot = codeRootOf(rec, repo, home);
394
+ records.push(rec);
395
+ }
396
+ }
397
+ return { records, sources, errors, contractsFile, matcherAllowlist, shimTable: table };
398
+ }
399
+
400
+ /**
401
+ * The code copy a registration executes from. This is the axis that makes a duplicate a DEFECT
402
+ * rather than a harmless repeat: two registrations of the same handler from ONE root are one
403
+ * behavior; from TWO roots they are two behaviors that can disagree the moment one copy updates.
404
+ */
405
+ export function codeRootOf(rec, repo = REPO, home = os.homedir()) {
406
+ const c = rec.command;
407
+ if (c.includes('${CLAUDE_PLUGIN_ROOT}')) {
408
+ // Shim-routed commands resolve their BODY from the active spine generation, not from the
409
+ // plugin root — that indirection is the whole point of ADR-023.
410
+ return rec.shimId ? 'spine' : 'plugin-root';
411
+ }
412
+ // (`<user>` below is a DECLARED placeholder from codex-wiring.test.mjs's allowlist, not a real
413
+ // account. That scan covers everything under plugin/, which this file joined in ADR-065; the
414
+ // example previously wrote an ellipsis where the account name goes, and an ellipsis is
415
+ // indistinguishable from a leaked home directory to a scanner that cannot read intent.)
416
+ // The SCRIPT path, not the interpreter's: `/bin/bash "/Users/<user>/route-dispatch.sh"` names two
417
+ // absolute paths and only the second one says which copy of the code runs. Taking the first
418
+ // reported every user-layer hook as living in /bin, which would have hidden F3's whole point.
419
+ const paths = c.match(/\/[^\s"']+/g) ?? [];
420
+ const p = paths.find((x) => /\.(?:mjs|sh|py|cjs|js|cmd)$/.test(x)) ?? paths[0];
421
+ if (!p) return 'unknown';
422
+ if (p.startsWith(path.join(home, '.claude/plugins/marketplaces'))) return 'marketplace-clone';
423
+ if (p.startsWith(path.join(home, '.claude/plugins/cache'))) return 'plugin-cache';
424
+ if (p.startsWith(path.join(home, '.claude/hooks'))) return 'user-hooks';
425
+ if (p.startsWith(path.join(home, '.claude/skills'))) return 'user-skills';
426
+ if (p.startsWith(repo)) return 'repo-checkout';
427
+ // No guessing beyond this point: the owning directory IS the identity of the code copy, and
428
+ // naming it verbatim keeps "two roots" an exact comparison instead of a bucketing heuristic.
429
+ return `dir:${path.dirname(p)}`;
430
+ }
431
+
432
+ /** Per-layer totals, in the shape ADR-055 appendix A states them. */
433
+ export function census(reg) {
434
+ const byLayer = new Map();
435
+ for (const s of reg.sources) byLayer.set(s.layer, { layer: s.layer, file: s.file, present: s.present, inMesh: s.inMesh, count: 0 });
436
+ for (const r of reg.records) byLayer.get(r.layer).count += 1;
437
+ const rows = [...byLayer.values()];
438
+ return {
439
+ rows,
440
+ mesh: rows.filter((r) => r.inMesh).reduce((n, r) => n + r.count, 0),
441
+ mirrors: rows.filter((r) => !r.inMesh).reduce((n, r) => n + r.count, 0),
442
+ total: reg.records.length,
443
+ };
444
+ }
445
+
446
+ // ── THE MESH INVARIANTS (ADR-055 §7) ────────────────────────────────────────────────────────────
447
+ //
448
+ // Each is a PURE function from records → findings, for three reasons that all matter:
449
+ // 1. one implementation, shared by the vitest lint and by any future pre-push/CI gate — the
450
+ // "adjacent door" defect (F16) is exactly what happens when a gate and its test are two
451
+ // different code paths;
452
+ // 2. a finding carries its own evidence (layer, locator, what was expected) so a refusal is
453
+ // actionable rather than a boolean;
454
+ // 3. they can be fed SYNTHETIC records, which is how §7.15 falsifiability is proven — break the
455
+ // input, watch the invariant go red. An invariant that has never been shown to fail is a
456
+ // claim, not a check.
457
+ // `mesh(records)` is the shared filter: the mirrors are the same registrations delivered twice, and
458
+ // counting them would make every invariant fire on itself.
459
+
460
+ export const mesh = (records) => records.filter((r) => r.inMesh);
461
+
462
+ /**
463
+ * M1 — no HANDLER is registered twice, on an overlapping (event, tool), FROM TWO DIFFERENT CODE
464
+ * ROOTS. Two registrations of one handler from one root are one behavior. From two roots they are
465
+ * two behaviors that diverge the instant one copy updates — and one of them is invisible to the
466
+ * other's tests. Live instances (ADR-055 F3, F6): route-dispatch.sh registered by the plugin
467
+ * (body resolved from the spine) AND by the user layer (body from the marketplace clone);
468
+ * continuation-gate.mjs registered by the plugin AND by this repo's own project settings.
469
+ * `blocking: true` marks the severe class — two walls that can both refuse the same call.
470
+ */
471
+ export function lintM1(records) {
472
+ const groups = new Map();
473
+ for (const r of mesh(records)) {
474
+ if (!r.handler) continue;
475
+ for (const tool of r.tools) {
476
+ const key = `${r.event}::${tool}::${r.handler}`;
477
+ if (!groups.has(key)) groups.set(key, []);
478
+ groups.get(key).push(r);
479
+ }
480
+ }
481
+ const findings = [];
482
+ for (const [key, rs] of groups) {
483
+ const roots = [...new Set(rs.map((r) => r.codeRoot))];
484
+ if (roots.length < 2) continue;
485
+ findings.push({
486
+ invariant: 'M1',
487
+ key,
488
+ roots,
489
+ blocking: rs.some((r) => r.effectiveMode !== 'advisory'),
490
+ where: rs.map((r) => `${r.layer} ${r.locator} [${r.codeRoot}] ${r.effectiveMode}`),
491
+ });
492
+ }
493
+ return findings.sort((a, b) => a.key.localeCompare(b.key));
494
+ }
495
+
496
+ /**
497
+ * M3 — TIMEOUT TOTALITY. Two failures, one invariant, because they are the same defect seen from
498
+ * either side: a MISSING timeout is Claude Code's default (600s outside the prompt path) silently
499
+ * applied to a stranger's session, and a value >60 is a milliseconds-intent number in a seconds
500
+ * field — "any timeout over 60 is a wrong-unit bug by fiat" (ADR-055 §7.5, Fable's rule). The user
501
+ * layer's whole 2000/3000/5000/30000 schism (F1) and its untimed blocking `Task|Agent` wall (F2)
502
+ * were both this, and both are now regression fixtures rather than open findings.
503
+ */
504
+ export function lintM3(records) {
505
+ const findings = [];
506
+ for (const r of mesh(records)) {
507
+ 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' });
508
+ 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` });
509
+ }
510
+ return findings;
511
+ }
512
+
513
+ /**
514
+ * M5 — ANCHORED MATCHERS on the two events whose matcher names a TOOL. Claude Code SEARCHES the
515
+ * tool name, so `Task` also selects TaskStop and `Write|Edit|MultiEdit` also selects NotebookEdit
516
+ * (F3, F4) — a wall silently guarding a tool nobody chose to guard. Anchoring is meaningless for
517
+ * SessionStart/Stop/SessionEnd/UserPromptSubmit, whose matcher selects a lifecycle source, so they
518
+ * are out of scope rather than allowlisted en masse.
519
+ *
520
+ * The allowlist is a RATCHET, not an amnesty: each entry names one exact registration and the ADR
521
+ * item that retires it, stale entries are themselves a failure (see `lintAllowlistStale`), and a
522
+ * NEW unanchored matcher is red on arrival. ADR-055's own build order puts registration changes at
523
+ * item 3 behind battery v2 at item 2 — so today's anchoring debt is recorded, not hidden, and not
524
+ * fixed out of order.
525
+ */
526
+ export function lintM5(records, allowlist = []) {
527
+ const findings = [];
528
+ for (const r of mesh(records)) {
529
+ if (!TOOL_EVENTS.has(r.event)) continue;
530
+ if (r.anchored) continue;
531
+ if (allowlist.some((a) => allowlistMatches(a, r))) continue;
532
+ findings.push({ invariant: 'M5', layer: r.layer, locator: r.locator, event: r.event, matcher: r.matcher, handler: r.handler, tools: r.tools });
533
+ }
534
+ return findings;
535
+ }
536
+
537
+ const allowlistMatches = (a, r) => a.layer === r.layer && a.event === r.event && a.matcher === (r.matcher ?? '')
538
+ && (!a.handler || a.handler === r.handler);
539
+
540
+ /** An allowlist entry matching no live registration is fiction. Fiction rots into permission. */
541
+ export function lintAllowlistStale(records, allowlist = []) {
542
+ const active = mesh(records);
543
+ return allowlist
544
+ .filter((a) => !active.some((r) => allowlistMatches(a, r)))
545
+ .map((a) => ({ invariant: 'M5-stale', entry: `${a.layer} ${a.event} ${JSON.stringify(a.matcher)} ${a.handler ?? ''}`, reason: a.reason }));
546
+ }
547
+
548
+ /**
549
+ * M6 — OFF-BEHAVIOR TOTALITY (ADR-055 §7.4, closing F14/F5). ADR-054 made brain-OFF a per-hook
550
+ * contract carried as DATA — but only for the shim's eleven table entries. Every other
551
+ * registration on this machine, INCLUDING the plugin's own Stop hook, had no declared answer at
552
+ * all: nobody could say whether it runs, goes silent, or splits when the user switches the brain
553
+ * off, which means "off" was never a testable state for two thirds of the mesh. A registration
554
+ * must resolve to `silence | run | partial` through the shim table or through the checked-in
555
+ * hook-contracts.json; absent from both is the finding.
556
+ */
557
+ export const OFF_BEHAVIORS = Object.freeze(['silence', 'run', 'partial']);
558
+
559
+ export function lintM6(records) {
560
+ return mesh(records)
561
+ .filter((r) => !OFF_BEHAVIORS.includes(r.offBehavior))
562
+ .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 }));
563
+ }
564
+
565
+ /** Every invariant at once, keyed by name — the shape a report or a gate wants. */
566
+ export function lintAll(reg) {
567
+ return {
568
+ M1: lintM1(reg.records),
569
+ M3: lintM3(reg.records),
570
+ M5: lintM5(reg.records, reg.matcherAllowlist),
571
+ 'M5-stale': lintAllowlistStale(reg.records, reg.matcherAllowlist),
572
+ M6: lintM6(reg.records),
573
+ };
574
+ }
575
+
576
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────────────
577
+ // BASENAME, not path identity. The strict `realpathSync(argv[1]) === realpathSync(self)` form this
578
+ // replaced stopped firing the moment `scripts/hook-registry.mjs` became a re-export shim over the
579
+ // payload copy: `node scripts/hook-registry.mjs --lint` (the invocation this file's own header and
580
+ // ADR-055 both document) loads a DIFFERENT file URL than argv[1] names, so the whole CLI became a
581
+ // no-op that exits 0 — a dead command, silently. The basename test is the idiom four of this file's
582
+ // siblings already use (capability-registry, user-settings, memory-doctor, lesson-promote) and it is
583
+ // true through the shim and at the payload path alike.
584
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith(`${path.sep}hook-registry.mjs`);
585
+ if (invokedDirectly) {
586
+ const includeMachine = !process.argv.includes('--machine=0') && process.env.CI !== 'true';
587
+ const reg = buildRegistry({ includeMachine });
588
+ if (process.argv.includes('--json')) {
589
+ process.stdout.write(`${JSON.stringify(reg.records, null, 2)}\n`);
590
+ } else if (process.argv.includes('--lint')) {
591
+ const found = lintAll(reg);
592
+ let n = 0;
593
+ for (const [name, findings] of Object.entries(found)) {
594
+ process.stdout.write(`\n${name}: ${findings.length ? `${findings.length} FINDING(S)` : 'clean'}\n`);
595
+ for (const f of findings) { n += 1; process.stdout.write(` ${JSON.stringify(f)}\n`); }
596
+ }
597
+ process.stdout.write(`\n${n} finding(s) over ${mesh(reg.records).length} mesh registrations${includeMachine ? '' : ' (repo-owned layers only)'}\n`);
598
+ process.exit(n ? 1 : 0);
599
+ } else {
600
+ const c = census(reg);
601
+ process.stdout.write(`Merged hook registry — ${c.mesh} active registrations in the mesh, ${c.mirrors} in code-copy mirrors\n\n`);
602
+ for (const row of c.rows) {
603
+ const tag = row.present ? String(row.count).padStart(3) : ' --';
604
+ process.stdout.write(`${tag} ${row.inMesh ? ' ' : '~'} ${row.layer.padEnd(20)} ${row.present ? row.file : '(absent on this machine)'}\n`);
605
+ }
606
+ process.stdout.write('\n');
607
+ for (const r of reg.records) {
608
+ process.stdout.write(
609
+ `${r.layer.padEnd(20)} ${r.locator.padEnd(20)} ${r.event.padEnd(17)} ${String(r.matcher || '*').padEnd(34)} `
610
+ + `t=${String(r.timeout ?? 'NONE').padEnd(5)} ${String(r.mode ?? r.effectiveMode).padEnd(16)} `
611
+ + `off=${String(r.offBehavior ?? 'UNDECLARED').padEnd(11)} ${r.reachesStrangers ? 'ships' : 'local'} ${r.handler ?? r.command}\n`,
612
+ );
613
+ }
614
+ for (const e of reg.errors) process.stdout.write(`\nERROR ${e.layer}: ${e.file}: ${e.error}\n`);
615
+ }
616
+ }
@@ -83,7 +83,10 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
83
83
  const TABLE = {
84
84
  'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
85
85
  'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
86
- 'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
86
+ // ADR-063 / issue #103: `blocking` so an opt-in refusal can actually reach the host. The hook
87
+ // still exits 0 for every user at the shipped default (managedMemoryBoundary=advise), so this
88
+ // changes the CEILING of what it may do, not what it does.
89
+ 'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'silence' },
87
90
  // Claude Code 2.1.220 consumes Agent/Task PreToolUse results after tool_dispatch_end (#84).
88
91
  // A refusal here would be late and therefore false enforcement; retain only bounded audit.
89
92
  'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
@@ -269,7 +272,15 @@ function runHook(file) {
269
272
  process.stderr.write(`[hook-shim] ${entry.file}: ${r.error.message}\n`);
270
273
  return entry.mode === 'blocking' ? 1 : 0;
271
274
  }
272
- // Blocking hooks: the exit code IS the contract (route-dispatch's exit-2 wall). Advisory: always 0.
275
+ // Blocking hooks: the exit code IS the contract — ground-before-write, design-wall, protect-state,
276
+ // and hijack-ruvnet at an opted-in managedMemoryBoundary (ADR-063). Advisory hooks: always 0.
277
+ //
278
+ // This comment used to cite "route-dispatch's exit-2 wall" as the example. That was FALSE and had
279
+ // to go (issue #84): route-dispatch is `mode: 'advisory'` in the table above, and it is advisory
280
+ // for a measured reason — Claude Code registers PreToolUse:Agent/Task hooks asynchronously and
281
+ // consumes the result ~140ms AFTER tool_dispatch_end, so an exit-2 there arrives after the
282
+ // subagent has already run. A comment naming an advisory hook as the canonical wall is exactly the
283
+ // kind of internal contradiction that later gets read as a capability we ship. We do not.
273
284
  return entry.mode === 'blocking' ? (r.status ?? 0) : 0;
274
285
  }
275
286