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.
- package/README.md +5 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/advocacy-outcomes.mjs +808 -0
- package/plugin/scripts/anticipate.sh +80 -14
- package/plugin/scripts/capability-registry.mjs +994 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
- package/plugin/scripts/continuation-gate.mjs +129 -1
- package/plugin/scripts/gates.mjs +146 -0
- package/plugin/scripts/goal-match.mjs +398 -0
- package/plugin/scripts/hijack-ruvnet.sh +69 -1
- package/plugin/scripts/hook-registry.mjs +616 -0
- package/plugin/scripts/hook-shim.mjs +13 -2
- package/plugin/scripts/learning-enable.mjs +382 -0
- package/plugin/scripts/lesson-promote.mjs +262 -0
- package/plugin/scripts/lesson-provenance.mjs +43 -0
- package/plugin/scripts/lesson-store.mjs +67 -56
- package/plugin/scripts/memory-doctor.mjs +345 -0
- package/plugin/scripts/nightly-controller.mjs +98 -0
- package/plugin/scripts/runtime-preferences.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +3 -3
- package/plugin/scripts/unprompted-runtime.mjs +22 -7
- package/plugin/scripts/user-settings.mjs +672 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/advocacy-outcomes.mjs +4 -808
- package/scripts/capability-registry.mjs +4 -876
- package/scripts/corpus-qa.mjs +44 -6
- package/scripts/doc-currency.mjs +30 -2
- package/scripts/gates.mjs +4 -146
- package/scripts/goal-match.mjs +4 -398
- package/scripts/hook-registry.mjs +4 -567
- package/scripts/issue-watch.mjs +108 -0
- package/scripts/learning-enable.mjs +4 -380
- package/scripts/lesson-promote.mjs +4 -262
- package/scripts/memory-doctor.mjs +4 -345
- package/scripts/nightly-controller.mjs +4 -66
- package/scripts/nightly-wrapper.sh +23 -1
- package/scripts/proactivity-metrics.mjs +8 -1
- package/scripts/qe/ux-suite.mjs +72 -1
- package/scripts/release-abort-stale.mjs +111 -0
- package/scripts/release-convergence-watchdog.mjs +119 -0
- package/scripts/release-transaction-provider.mjs +61 -7
- package/scripts/release-transaction.mjs +63 -17
- package/scripts/self-update.mjs +63 -10
- 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
|
-
|
|
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
|
|
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
|
|