ruvnet-brain 3.9.85-dev → 3.9.129-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.
- package/README.md +17 -15
- package/bin/install.mjs +698 -70
- package/kb/brain-profile.mjs +145 -0
- package/kb/model-requirements.mjs +72 -0
- package/kb/zip-extract.mjs +297 -0
- package/package.json +32 -4
- package/plugin/mcp/managed-cli-interface.mjs +236 -0
- package/plugin/mcp/server.mjs +133 -32
- package/plugin/scripts/codex-hook-wrapper.mjs +37 -0
- package/scripts/dual-host-deliberation.mjs +284 -0
- package/scripts/dual-host-suggest.mjs +58 -0
- package/scripts/hook-registry.mjs +567 -0
- package/scripts/install-scope.mjs +708 -0
- package/scripts/model-router-outcome.mjs +19 -7
- package/scripts/selfcheck.mjs +646 -0
- package/scripts/subscription-hosts.mjs +105 -0
- package/scripts/upgrade-notice.mjs +465 -0
- package/scripts/user-settings.mjs +640 -0
|
@@ -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
|
+
}
|