ruvnet-brain 3.9.134-dev → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/README.md +2 -2
  3. package/bin/install.mjs +284 -33
  4. package/kb/zip-extract.mjs +53 -14
  5. package/package.json +7 -1
  6. package/plugin/.claude-plugin/marketplace.json +13 -0
  7. package/plugin/.claude-plugin/plugin.json +23 -0
  8. package/plugin/.codex-plugin/plugin.json +21 -0
  9. package/plugin/.mcp.json +8 -0
  10. package/plugin/commands/brain-console.md +16 -0
  11. package/plugin/commands/configure.md +32 -0
  12. package/plugin/commands/rvbc.md +78 -0
  13. package/plugin/commands/rvcb.md +16 -0
  14. package/plugin/commands/whats-new.md +57 -0
  15. package/plugin/hooks/codex-hooks.json +160 -0
  16. package/plugin/hooks/hook-contracts.json +77 -0
  17. package/plugin/hooks/hooks.json +203 -0
  18. package/plugin/mcp/server.mjs +35 -6
  19. package/plugin/scripts/anticipate.sh +534 -0
  20. package/plugin/scripts/codex-hook-adapter.mjs +96 -0
  21. package/plugin/scripts/continuation-gate.mjs +267 -0
  22. package/plugin/scripts/design-wall.sh +137 -0
  23. package/plugin/scripts/detach.mjs +168 -0
  24. package/plugin/scripts/finalize-token-meter.mjs +25 -0
  25. package/plugin/scripts/gate-receipt.sh +35 -0
  26. package/plugin/scripts/ground-before-write.sh +199 -0
  27. package/plugin/scripts/ground-ruvnet.sh +507 -0
  28. package/plugin/scripts/grounding-stamp.sh +113 -0
  29. package/plugin/scripts/grounding-substance.mjs +595 -0
  30. package/plugin/scripts/hijack-ruvnet.sh +81 -0
  31. package/plugin/scripts/hook-input.mjs +558 -0
  32. package/plugin/scripts/hook-shim-bash.mjs +55 -0
  33. package/plugin/scripts/hook-shim.mjs +303 -0
  34. package/plugin/scripts/host-update.mjs +58 -0
  35. package/plugin/scripts/kling-preflight.sh +146 -0
  36. package/plugin/scripts/learn-capture.sh +154 -0
  37. package/plugin/scripts/learn-flush.mjs +138 -0
  38. package/plugin/scripts/lesson-hooks.sh +213 -0
  39. package/plugin/scripts/md-stamp.mjs +219 -0
  40. package/plugin/scripts/protect-brain-state.sh +84 -0
  41. package/plugin/scripts/route-dispatch.sh +147 -0
  42. package/plugin/scripts/routing-outcome-capture.mjs +89 -0
  43. package/plugin/scripts/session-start.sh +868 -0
  44. package/plugin/scripts/signal-watch.mjs +193 -0
  45. package/plugin/scripts/unprompted-runtime.mjs +377 -0
  46. package/plugin/scripts/update-apply.mjs +419 -0
  47. package/plugin/scripts/verify-interface.sh +53 -0
  48. package/plugin/scripts/version-bump-gate.sh +112 -0
  49. package/plugin/skills/brain-build/SKILL.md +123 -0
  50. package/plugin/skills/brain-console/SKILL.md +20 -0
  51. package/plugin/skills/brain-prompt/SKILL.md +83 -0
  52. package/plugin/skills/brain-score/SKILL.md +101 -0
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +117 -0
  54. package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
  55. package/plugin/skills/rvbc/SKILL.md +20 -0
  56. package/plugin/skills/savings/SKILL.md +46 -0
  57. package/plugin/skills/whats-new/SKILL.md +22 -0
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env node
2
+ // grounding-substance.mjs — THE FOURTH WALL (ADR-055 §3, build item 5; issue #46).
3
+ //
4
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
5
+ // THE INCIDENT THIS EXISTS FOR (observed live 2026-07-27, owner session).
6
+ //
7
+ // The model called search_ruvnet and received rUv's own working browser example —
8
+ // `ruvector/examples/rvf/scripts/rvf-browser.html`: "npm install @ruvector/rvf-wasm",
9
+ // "No backend required." Hours later it wrote browser code importing that exact runtime from
10
+ // `https://esm.sh/@ruvector/rvf-wasm`, contradicting both the returned source and the product's
11
+ // nothing-leaves-your-device premise. EVERY GATE STAYED GREEN. GPT-5.6 replayed that write
12
+ // against the live gate during the ADR-055 duel and measured LIVE_GATE_EXIT=0.
13
+ //
14
+ // The reason is one sentence: the grounding stamp records that a search RAN, never what it
15
+ // RETURNED. A term and a timestamp cannot contradict anything. The model's own admission:
16
+ // "The Brain did its job. I ignored it."
17
+ //
18
+ // This file binds the WRITE to the ANSWER. It runs after ground-before-write.sh's recency wall
19
+ // passes, reads the evidence ledger kb/forge-evidence.mjs appends on every successful search, and
20
+ // refuses a write that CONTRADICTS the stamped source.
21
+ //
22
+ // ── THE FIVE RULES THAT KEEP IT FROM BECOMING THEATRE ───────────────────────────────────────────
23
+ //
24
+ // 1. CONTRADICTION ONLY. "Not found in retrieval" is never sufficient to block (ADR-055 §1, §3.3 —
25
+ // GPT-5.6's correction, adopted). Every detector here needs a POSITIVE fact from the stamped
26
+ // source that the write positively contradicts. No evidence ⇒ no finding, ever.
27
+ //
28
+ // 2. NO ALLOWLISTS. ADR-055 §3.7.4 refuses "CDN/package allowlists standing in for selected
29
+ // evidence" by name. D1 does not know what a CDN is. It compares the write's remote origin
30
+ // against the origins THE STAMPED SOURCE ITSELF CARRIES — because `rvf-browser.html` offers a
31
+ // CDN alternative in its own text (F22), and a naive "any CDN" rule is a false-positive machine.
32
+ //
33
+ // 3. MALFUNCTION IS NOT A DECISION (ADR-055 §1.2). Unparseable payload, missing ledger, unreadable
34
+ // policy, an exception anywhere: exit 0. Only a detector may produce exit 2.
35
+ //
36
+ // 4. THE REFUSAL CARRIES THE EVIDENCE (ADR-055 §1.3). Source path, the install command verbatim,
37
+ // the source's own words, and the compliant replacement — so compliance is cheaper than defiance.
38
+ //
39
+ // 5. THE OVERRIDE IS IN-BAND, REASONED AND RECEIPTED (ADR-055 §3.5). An environment variable
40
+ // CANNOT be set on a Write call — that was a named defect in this repo's own walls twice
41
+ // (issue #12 defect 2, and design-wall.sh's header). So the token lives in the write payload,
42
+ // carries a mandatory stated reason, and writes its own receipt for later adjudication.
43
+ //
44
+ // ── SCOPE, DELIBERATELY NARROW ──────────────────────────────────────────────────────────────────
45
+ // Detectors D1, D2, D3 (contradiction-shaped) plus D4 where a user-owned policy file states an
46
+ // invariant. ADR-055 §3.3's D5 (receipt integrity: tampered / cross-session / changed source
47
+ // hashes) is NOT implemented here and is recorded as deferred rather than faked: its blocking form
48
+ // needs the outcome ledger and adjudication of build item 7, and a D5 that blocked on "the ledger
49
+ // looks odd" would be a malfunction manufacturing a refusal — precisely rule 3 above. Absent or
50
+ // unreadable evidence is treated as no evidence, which is exit 0.
51
+ //
52
+ // CONTRACT: stdin = the raw PreToolUse payload. exit 0 = allow (also every failure mode).
53
+ // exit 2 + refusal on stderr = BLOCK. Never writes to stdout.
54
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
55
+
56
+ import fs from 'node:fs';
57
+ import path from 'node:path';
58
+ import os from 'node:os';
59
+ import crypto from 'node:crypto';
60
+ import { spawnSync } from 'node:child_process';
61
+ import { fileURLToPath, pathToFileURL } from 'node:url';
62
+ import { parseHookEvent, toolName } from './hook-input.mjs';
63
+
64
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
65
+
66
+ // ── The three pure helpers, DUPLICATED from kb/forge-evidence.mjs on purpose. ────────────────────
67
+ // The plugin and the knowledge bundle are two independently-shipped artifacts: the bundle lives in
68
+ // ~/.cache/ruvnet-brain/kb and the plugin in the Claude Code plugin cache, and neither can import
69
+ // the other (kb/ has its own no-imports-outside-kb rule — issue #32, MODULE_NOT_FOUND twice). This
70
+ // is the same deliberate duplication the brain-off sentinel read already carries in three files,
71
+ // and it is held THE SAME WAY: by test, not by comment. tests/unit/fourth-wall.test.mjs drives one
72
+ // shared table of cases through BOTH copies and fails if they ever disagree.
73
+ export function pkgBase(name) {
74
+ const s = String(name || '');
75
+ const i = s.lastIndexOf('/');
76
+ return i >= 0 ? s.slice(i + 1) : s;
77
+ }
78
+ export function specToPackage(spec) {
79
+ const s = String(spec || '').trim();
80
+ if (!s || s.startsWith('.') || s.startsWith('/') || s.startsWith('node:') || /^https?:/i.test(s)) return null;
81
+ const parts = s.split('/');
82
+ if (s.startsWith('@')) return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
83
+ return parts[0] || null;
84
+ }
85
+ export function packageFromUrl(url) {
86
+ try {
87
+ const m = /^https?:\/\/[^/]+\/(.*)$/i.exec(String(url || ''));
88
+ if (!m) return null;
89
+ let rest = m[1].replace(/^(?:npm|gh|pkg)\//, '');
90
+ rest = rest.split('?')[0].split('#')[0];
91
+ const parts = rest.split('/').filter(Boolean);
92
+ if (!parts.length) return null;
93
+ let name = parts[0].startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
94
+ const at = name.lastIndexOf('@');
95
+ if (at > 0) name = name.slice(0, at);
96
+ if (!/^(?:@[\w.-]+\/)?[\w.][\w.-]*$/.test(name)) return null;
97
+ if (/\.(?:js|mjs|cjs|ts|wasm|json|html|css|map)$/i.test(name)) return null;
98
+ return name;
99
+ } catch { return null; }
100
+ }
101
+
102
+ // ── Paths ───────────────────────────────────────────────────────────────────────────────────────
103
+ function cacheDir() {
104
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
105
+ return process.env.XDG_CACHE_HOME
106
+ ? path.join(process.env.XDG_CACHE_HOME, 'ruvnet-brain')
107
+ : path.join(home, '.cache', 'ruvnet-brain');
108
+ }
109
+ const evidenceFile = () => process.env.RUVNET_EVIDENCE_FILE || path.join(cacheDir(), 'evidence.jsonl');
110
+ const overrideLedger = () => process.env.RUVNET_OVERRIDE_FILE || path.join(cacheDir(), 'grounding-overrides.jsonl');
111
+
112
+ const sha12 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 12);
113
+
114
+ /**
115
+ * Newest receipts first, bounded. `RUVNET_EVIDENCE_MAX_AGE_H` (default 24h) keeps the wall bound to
116
+ * what the model actually saw recently, matching the recency layer beneath it (ADR-012, narrowed by
117
+ * ADR-055 §3.1 to exactly that role).
118
+ */
119
+ function readReceipts() {
120
+ try {
121
+ const raw = fs.readFileSync(evidenceFile(), 'utf8');
122
+ const lines = raw.split('\n').filter(Boolean);
123
+ const maxAgeH = Number(process.env.RUVNET_EVIDENCE_MAX_AGE_H ?? 24);
124
+ const cutoff = Number.isFinite(maxAgeH) && maxAgeH > 0 ? Date.now() - maxAgeH * 3600_000 : 0;
125
+ const out = [];
126
+ for (let i = lines.length - 1; i >= 0 && out.length < 60; i--) {
127
+ let r; try { r = JSON.parse(lines[i]); } catch { continue; }
128
+ if (!r || !Array.isArray(r.sources)) continue;
129
+ if (cutoff && Date.parse(r.ts || '') && Date.parse(r.ts) < cutoff) continue;
130
+ out.push(r);
131
+ }
132
+ return out;
133
+ } catch { return []; }
134
+ }
135
+
136
+ /**
137
+ * The user-owned project policy (ADR-055 §3.2). It resolves ambiguity the SOURCE cannot: when a
138
+ * source supports both a local install and a CDN, only the user can say which this project uses.
139
+ * Absent ⇒ `source-supported`, i.e. what the source carries, the source permits. That default
140
+ * refuses nothing the source itself offers, which is why shipping it does not need a migration.
141
+ */
142
+ function readPolicy(filePath) {
143
+ const tryRead = (p) => {
144
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
145
+ };
146
+ if (process.env.RUVNET_IMPL_POLICY) {
147
+ const p = tryRead(process.env.RUVNET_IMPL_POLICY);
148
+ if (p) return { policy: p, from: process.env.RUVNET_IMPL_POLICY };
149
+ }
150
+ const roots = [];
151
+ if (process.env.CLAUDE_PROJECT_DIR) roots.push(process.env.CLAUDE_PROJECT_DIR);
152
+ try {
153
+ let d = path.dirname(path.resolve(filePath || '.'));
154
+ for (let i = 0; i < 6 && d && d !== path.dirname(d); i++) { roots.push(d); d = path.dirname(d); }
155
+ } catch { /* unresolvable path — the CLAUDE_PROJECT_DIR candidate still stands */ }
156
+ for (const r of roots) {
157
+ const p = path.join(r, '.ruvnet', 'implementation-policy.json');
158
+ const j = tryRead(p);
159
+ if (j) return { policy: j, from: p };
160
+ }
161
+ return { policy: null, from: null };
162
+ }
163
+
164
+ // ── The write, read out of the payload ──────────────────────────────────────────────────────────
165
+
166
+ /** Everything this tool call would ADD to the file. Write/Edit/MultiEdit, all shapes. */
167
+ function addedText(ev) {
168
+ const ti = (ev && ev.tool_input) || {};
169
+ const parts = [];
170
+ if (typeof ti.content === 'string') parts.push(ti.content);
171
+ if (typeof ti.new_string === 'string') parts.push(ti.new_string);
172
+ if (Array.isArray(ti.edits)) for (const e of ti.edits) if (e && typeof e.new_string === 'string') parts.push(e.new_string);
173
+ return parts.join('\n');
174
+ }
175
+
176
+ /**
177
+ * Strip comments before any detector looks at the text.
178
+ *
179
+ * This is not tidiness — it is the single biggest false-positive class the ADR's known-good corpus
180
+ * names: "comments discussing esm.sh" must pass (§8). A line that says
181
+ * `// do NOT do: import x from "https://esm.sh/@ruvector/rvf-wasm"` is a warning ABOUT the bug, and
182
+ * a wall that refuses it teaches people to stop writing warnings. Deterministic and conservative:
183
+ * `//` only when not preceded by `:` (spares `https://`), plus block comments, plus `#` at line
184
+ * start for shell/python.
185
+ */
186
+ function stripComments(src) {
187
+ return String(src || '')
188
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
189
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1')
190
+ .replace(/^[ \t]*#[^\n]*$/gm, '');
191
+ }
192
+
193
+ /** Executable remote origins the write INTRODUCES. Only import/require/script-src/importScripts. */
194
+ function executableRemoteRefs(code) {
195
+ const refs = [];
196
+ const push = (url, how) => {
197
+ const u = String(url || '');
198
+ if (!/^https?:\/\//i.test(u)) return;
199
+ const host = (/^https?:\/\/([^/]+)/i.exec(u) || [])[1];
200
+ if (!host) return;
201
+ refs.push({ url: u, host: host.toLowerCase(), pkg: packageFromUrl(u), how });
202
+ };
203
+ for (const m of code.matchAll(/\b(?:import|export)\b[^;'"\n]*?\bfrom\s*['"]([^'"\n]+)['"]/g)) push(m[1], 'import');
204
+ for (const m of code.matchAll(/\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) push(m[1], 'dynamic import');
205
+ for (const m of code.matchAll(/\bimport\s*['"]([^'"\n]+)['"]/g)) push(m[1], 'import');
206
+ for (const m of code.matchAll(/\brequire\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) push(m[1], 'require');
207
+ for (const m of code.matchAll(/\bimportScripts\s*\(\s*['"]([^'"\n]+)['"]/g)) push(m[1], 'importScripts');
208
+ for (const m of code.matchAll(/<script[^>]*\ssrc\s*=\s*['"]([^'"\n]+)['"]/gi)) push(m[1], 'script src');
209
+ return refs;
210
+ }
211
+
212
+ /** Bare packages the write imports or installs. */
213
+ function writtenPackages(code) {
214
+ const out = new Map();
215
+ const add = (name, how) => { if (name && !out.has(name)) out.set(name, { name, how }); };
216
+ for (const m of code.matchAll(/\b(?:import|export)\b[^;'"\n]*?\bfrom\s*['"]([^'"\n]+)['"]/g)) add(specToPackage(m[1]), 'import');
217
+ for (const m of code.matchAll(/\brequire\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) add(specToPackage(m[1]), 'require');
218
+ for (const m of code.matchAll(/\bimport\s*['"]([^'"\n]+)['"]/g)) add(specToPackage(m[1]), 'import');
219
+ for (const m of code.matchAll(/(?:npm\s+(?:install|i|add)|pnpm\s+(?:install|i|add)|yarn\s+add|bun\s+add)(?:\s+-{1,2}[\w-]+)*\s+((?:@[\w.-]+\/)?[\w.][\w.-]*)/g)) add(m[1], 'install');
220
+ return [...out.values()];
221
+ }
222
+
223
+ /** Symbols the write DEFINES (the hand-roll shape D3 looks for). */
224
+ function definedSymbols(code) {
225
+ const out = new Set();
226
+ const res = [
227
+ /\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
228
+ /\b(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)/g,
229
+ /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|class\b)/g,
230
+ ];
231
+ for (const re of res) for (const m of code.matchAll(re)) out.add(m[1]);
232
+ return [...out];
233
+ }
234
+
235
+ /** Symbols the write IMPORTS by name — proof it is USING the stamped API rather than re-writing it. */
236
+ function importedSymbols(code) {
237
+ const out = new Set();
238
+ for (const m of code.matchAll(/\bimport\s+([^;'"\n]*?)\bfrom\s*['"][^'"\n]+['"]/g)) {
239
+ const braced = /\{([^}]*)\}/.exec(m[1]);
240
+ if (braced) for (const raw of braced[1].split(',')) {
241
+ const nm = raw.trim().split(/\s+as\s+/).pop().trim();
242
+ if (/^[A-Za-z_$][\w$]*$/.test(nm)) out.add(nm);
243
+ }
244
+ }
245
+ for (const m of code.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\s*\(/g)) {
246
+ for (const raw of m[1].split(',')) {
247
+ const nm = raw.trim().split(':').pop().trim();
248
+ if (/^[A-Za-z_$][\w$]*$/.test(nm)) out.add(nm);
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /**
255
+ * Package basenames too generic to carry an owner claim. D2 asks "same thing, different owner?" —
256
+ * a basename of `wasm`, `core` or `client` means the question cannot be answered from the name, so
257
+ * D2 declines rather than guesses. Every entry here shrinks the detector; none of them grows it.
258
+ */
259
+ const GENERIC_BASENAMES = new Set([
260
+ 'wasm', 'core', 'cli', 'sdk', 'api', 'db', 'utils', 'util', 'common', 'client', 'server', 'node',
261
+ 'js', 'ts', 'lib', 'types', 'index', 'main', 'app', 'web', 'browser', 'runtime', 'plugin', 'tools',
262
+ 'memory', 'hooks', 'test', 'tests', 'config', 'shared', 'data', 'src', 'bin', 'dist', 'pkg',
263
+ ]);
264
+
265
+ /**
266
+ * Identifiers too ordinary to prove a hand-roll. D3's claim is "you re-implemented THAT api";
267
+ * `init`, `search` or `create` cannot support it. Names must also be ≥5 chars for the same reason.
268
+ */
269
+ const GENERIC_SYMBOLS = new Set([
270
+ 'init', 'main', 'run', 'start', 'stop', 'get', 'set', 'add', 'remove', 'delete', 'update', 'query',
271
+ 'search', 'create', 'close', 'open', 'read', 'write', 'load', 'save', 'send', 'parse', 'format',
272
+ 'connect', 'insert', 'index', 'build', 'render', 'store', 'fetch', 'clear', 'reset', 'log',
273
+ 'config', 'options', 'result', 'value', 'error', 'handler', 'callback', 'client', 'server',
274
+ ]);
275
+
276
+ // ── Detectors ───────────────────────────────────────────────────────────────────────────────────
277
+
278
+ const DISABLED = new Set(
279
+ String(process.env.RUVNET_GROUNDING_DISABLE || '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean),
280
+ );
281
+ /**
282
+ * THE MUTATION SEAM (ADR-055 §8: "break each detector independently; every mutant killed or no
283
+ * release"). It is deliberately LOUD: a silent way to switch a wall off is a bypass, an announced
284
+ * one is a declared capability. tests/unit/fourth-wall.test.mjs T3 uses it to prove each detector
285
+ * is load-bearing — a detector whose removal changes nothing was never protecting anything.
286
+ */
287
+ function enabled(id) {
288
+ if (!DISABLED.has(id)) return true;
289
+ process.stderr.write(`[grounding-substance] DETECTOR DISABLED: ${id} (RUVNET_GROUNDING_DISABLE test seam)\n`);
290
+ return false;
291
+ }
292
+
293
+ /**
294
+ * D1 GROUNDING-NET — a remote origin for a capability the stamped source ships LOCALLY.
295
+ *
296
+ * Fires only when ALL of these hold, which is what keeps it off the known-good corpus:
297
+ * a) the write executes code from a remote URL (import / require / script src / importScripts),
298
+ * b) that URL NAMES a package,
299
+ * c) a stamped source ships that same package with a LOCAL install command, and
300
+ * d) NO stamped source that ships it carries the host being used.
301
+ * Drop (d) and you get the "any CDN is bad" rule ADR-055 §3.7.4 forbids and F22 disproves.
302
+ *
303
+ * ── WHY (d) IS EVALUATED ACROSS ALL SOURCES AND NOT PER SOURCE ──────────────────────────────────
304
+ * The first version tested (d) inside the source loop and `continue`d on a permitting source. That
305
+ * is a latent false positive with a completely ordinary trigger: two stamped documents for one
306
+ * package — say the README that documents the CDN and the example that only shows the local
307
+ * install. Whichever one the loop reached second would fire, so the refusal depended on ledger
308
+ * ORDER rather than on evidence. Permission is a property of the held evidence as a whole: if any
309
+ * stamped source that ships this package carries this origin, the origin is source-supported.
310
+ */
311
+ function detectD1({ refs, receipts, policy }) {
312
+ if (!enabled('D1')) return [];
313
+ const bundledOnly = policy?.network?.runtimeOrigins === 'bundled-only';
314
+ for (const ref of refs) {
315
+ if (!ref.pkg) continue;
316
+ // Every stamped source that ships this package, gathered before any verdict is formed.
317
+ const shippers = [];
318
+ for (const r of receipts) {
319
+ for (const s of r.sources) {
320
+ const local = s.packages.find((p) => p.name === ref.pkg)
321
+ || s.packages.find((p) => pkgBase(p.name) === pkgBase(ref.pkg) && !GENERIC_BASENAMES.has(pkgBase(p.name).toLowerCase()));
322
+ if (local) shippers.push({ r, s, local });
323
+ }
324
+ }
325
+ if (!shippers.length) continue; // nothing stamped ships it ⇒ nothing to contradict
326
+ const permitting = shippers.find(({ s }) => (s.origins || []).includes(ref.host));
327
+ if (permitting && !bundledOnly) continue; // the evidence itself offers this origin
328
+ // The clearest single finding: prefer the source that names the host when the objection is the
329
+ // user's policy, so the refusal quotes the document the user is actually overriding.
330
+ const { r, s, local } = permitting || shippers[0];
331
+ return [{
332
+ id: permitting ? 'D4' : 'D1',
333
+ name: permitting ? 'GROUNDING-POLICY' : 'GROUNDING-NET',
334
+ headline: permitting
335
+ ? `remote origin ${ref.host} is source-supported but this project's policy requires bundled delivery`
336
+ : `${ref.how} of ${ref.pkg} from ${ref.host} — the stamped source ships it locally`,
337
+ receipt: r.id,
338
+ source: `${s.repo}/${s.path}`,
339
+ install: local.install,
340
+ pkg: local.name,
341
+ posture: s.posture || [],
342
+ origins: s.origins || [],
343
+ offending: ref.url,
344
+ fix: `${local.install}\n then import it by name: import … from '${local.name}'`,
345
+ }]; // one refusal, the clearest one — a wall that lists twelve findings is noise
346
+ }
347
+ return [];
348
+ }
349
+
350
+ /**
351
+ * D2 GROUNDING-PKG — package substitution: the same thing, a different owner.
352
+ *
353
+ * The failure this replays is the wrong-crate class (bare `rvf` vs `rvf-runtime`; a hand-named
354
+ * "MetaHarness router" vs `@metaharness/router`). Deterministic form: the write pulls in a package
355
+ * whose BASENAME matches a stamped package's basename while the full name differs. Generic
356
+ * basenames are excluded outright, and a user policy may name denied packages outright.
357
+ */
358
+ function detectD2({ pkgs, receipts, policy }) {
359
+ if (!enabled('D2')) return [];
360
+ const denied = new Set((policy?.deniedPackages || []).map(String));
361
+ for (const w of pkgs) {
362
+ if (denied.has(w.name)) {
363
+ return [{
364
+ id: 'D4', name: 'GROUNDING-POLICY', headline: `${w.name} is denied by this project's implementation policy`,
365
+ receipt: null, source: null, install: null, pkg: w.name, posture: [], origins: [], offending: w.name,
366
+ fix: `remove the ${w.how} of ${w.name}, or change .ruvnet/implementation-policy.json yourself`,
367
+ }];
368
+ }
369
+ const base = pkgBase(w.name).toLowerCase();
370
+ if (base.length < 4 || GENERIC_BASENAMES.has(base)) continue;
371
+ for (const r of receipts) {
372
+ for (const s of r.sources) {
373
+ const stamped = s.packages.find((p) => pkgBase(p.name).toLowerCase() === base && p.name !== w.name);
374
+ if (!stamped) continue;
375
+ return [{
376
+ id: 'D2', name: 'GROUNDING-PKG',
377
+ headline: `${w.how} of ${w.name} — the stamped source names ${stamped.name} for this`,
378
+ receipt: r.id, source: `${s.repo}/${s.path}`, install: stamped.install, pkg: stamped.name,
379
+ posture: s.posture || [], origins: s.origins || [], offending: w.name,
380
+ fix: `${stamped.install}\n then use '${stamped.name}', not '${w.name}'`,
381
+ }];
382
+ }
383
+ }
384
+ }
385
+ return [];
386
+ }
387
+
388
+ /**
389
+ * D3 GROUNDING-API — hand-rolling an API the stamped source ships, and the explicit-negative case.
390
+ *
391
+ * D3a (hand-roll): the write DEFINES a distinctive symbol that a stamped source EXPORTS, does not
392
+ * import that symbol from anywhere, and names the stamped package in its own text. All four
393
+ * conditions are required; each one alone is ordinary code.
394
+ * D3b (explicit negative): the source SAYS "does not export X" and the write uses X on the stamped
395
+ * package. ADR-055 §3.3, adopted verbatim: absence from retrieval is ADVISORY ONLY and never
396
+ * reaches this function — only a fact the source states may block.
397
+ */
398
+ function detectD3({ code, prose, receipts }) {
399
+ if (!enabled('D3')) return [];
400
+ const defined = definedSymbols(code);
401
+ const imported = importedSymbols(code);
402
+ // `prose` is the added text WITH comments; `code` is without. The domain link is read from prose
403
+ // on purpose — a hand-roll characteristically does NOT import the package (that is the whole
404
+ // failure), so the only place it names it is a comment: "a local reimplementation of X". The
405
+ // symbol test stays on `code`, so a comment can never by itself define or import anything.
406
+ for (const r of receipts) {
407
+ for (const s of r.sources) {
408
+ // D3b — explicit negative facts.
409
+ for (const neg of (s.negatives || [])) {
410
+ const sym = neg.symbol;
411
+ if (!sym || sym.length < 4) continue;
412
+ const used = new RegExp(String.raw`\.\s*${sym}\s*\(`).test(code) || new RegExp(String.raw`\b${sym}\s*\(`).test(code);
413
+ const namesPkg = s.packages.some((p) => prose.includes(p.name));
414
+ if (used && namesPkg) {
415
+ return [{
416
+ id: 'D3', name: 'GROUNDING-API', headline: `${sym}() — the stamped source states it does not exist`,
417
+ receipt: r.id, source: `${s.repo}/${s.path}`, install: s.packages[0]?.install || null,
418
+ pkg: s.packages[0]?.name || null, posture: [neg.quote], origins: s.origins || [], offending: sym,
419
+ fix: `remove the call to ${sym}; the source says: "${neg.quote}"`,
420
+ }];
421
+ }
422
+ }
423
+ // D3a — the hand-roll.
424
+ if (!s.packages.length) continue;
425
+ const namedPkg = s.packages.find((p) => prose.includes(p.name));
426
+ if (!namedPkg) continue;
427
+ for (const sym of defined) {
428
+ if (sym.length < 5 || GENERIC_SYMBOLS.has(sym.toLowerCase())) continue;
429
+ if (imported.has(sym)) continue;
430
+ const exported = (s.symbols || []).find((x) => x.name === sym);
431
+ if (!exported) continue;
432
+ return [{
433
+ id: 'D3', name: 'GROUNDING-API',
434
+ headline: `you are defining ${sym}, which ${namedPkg.name} already exports`,
435
+ receipt: r.id, source: `${s.repo}/${s.path}`, install: namedPkg.install, pkg: namedPkg.name,
436
+ posture: s.posture || [], origins: s.origins || [], offending: sym,
437
+ fix: `${namedPkg.install}\n then import it: import { ${sym} } from '${namedPkg.name}'`,
438
+ }];
439
+ }
440
+ }
441
+ }
442
+ return [];
443
+ }
444
+
445
+ // ── Override (ADR-055 §3.5) ─────────────────────────────────────────────────────────────────────
446
+
447
+ /**
448
+ * IN-BAND, because an env var cannot be set on a Write call.
449
+ *
450
+ * That is not a style preference — it is a defect this repo has already shipped twice and paid for:
451
+ * verify-interface.sh advertised `RUVNET_SKIP_INTERFACE_CHECK=1` while reading it from the HOOK's
452
+ * own environment (issue #12 defect 2), and design-wall.sh reintroduced the same shape. A PreToolUse
453
+ * hook is spawned BEFORE the tool runs, so the only channel the caller controls is the payload.
454
+ *
455
+ * RUVNET_GROUNDING_OVERRIDE: <a real reason, at least 12 characters>
456
+ *
457
+ * Scoped by construction: the token lives inside one specific write, so it is one-use and cannot
458
+ * leak into the next call the way an exported variable does. Every mint is receipted for later
459
+ * adjudication (§4), which is what distinguishes "recorded disagreement" from "wall switched off".
460
+ */
461
+ const OVERRIDE_RE = /RUVNET_GROUNDING_OVERRIDE\s*[:=]\s*(.+)/;
462
+ /**
463
+ * Read from the DECODED write text, never the raw payload. Scanning the raw JSON looked like it
464
+ * worked and quietly appended the payload's own closing `"}}` to every recorded reason — an
465
+ * adjudication ledger full of corrupted reasons is worse than none, because it reads as data.
466
+ */
467
+ function overrideFrom(added) {
468
+ const m = OVERRIDE_RE.exec(String(added || ''));
469
+ if (!m) return null;
470
+ const reason = m[1].replace(/\*\/\s*$/, '').replace(/["'`,;]+\s*$/, '').trim();
471
+ if (reason.length < 12) return { reason, valid: false };
472
+ return { reason: reason.slice(0, 300), valid: true };
473
+ }
474
+
475
+ function writeOverrideReceipt(entry) {
476
+ try {
477
+ const f = overrideLedger();
478
+ fs.mkdirSync(path.dirname(f), { recursive: true });
479
+ fs.appendFileSync(f, `${JSON.stringify(entry)}\n`);
480
+ } catch { /* a receipt that cannot be written must not turn an allow into a block */ }
481
+ }
482
+
483
+ function gateReceipt(subject, reason) {
484
+ try {
485
+ const script = path.join(HERE, 'gate-receipt.sh');
486
+ if (!fs.existsSync(script)) return;
487
+ spawnSync('bash', [script, 'grounding-substance', subject, reason], { stdio: 'ignore', timeout: 3000 });
488
+ } catch { /* never */ }
489
+ }
490
+
491
+ // ── The refusal ─────────────────────────────────────────────────────────────────────────────────
492
+
493
+ function refusal(f, filePath) {
494
+ const words = (f.posture || []).slice(0, 2).map((p) => ` "${p}"`).join('\n');
495
+ // `null` drops a line; `''` keeps a deliberate blank one. (Filtering on `''` collapsed every
496
+ // paragraph break the first time this ran — the refusal is read by a model under pressure, so
497
+ // its shape is part of whether it works.)
498
+ return [
499
+ `⛔ BLOCKED — ${f.name} (${f.id}): this write contradicts what the brain showed you.`,
500
+ '',
501
+ ` ${f.headline}`,
502
+ '',
503
+ 'THE SOURCE THE BRAIN RETURNED:',
504
+ f.source ? ` ${f.source}` : ' (project policy — .ruvnet/implementation-policy.json)',
505
+ f.install ? ` ${f.install}` : null,
506
+ words ? `\nIN THE SOURCE'S OWN WORDS:\n${words}` : null,
507
+ f.origins && f.origins.length ? `\nORIGINS THAT SOURCE ITSELF CARRIES: ${f.origins.slice(0, 6).join(', ')}` : null,
508
+ '',
509
+ 'YOUR WRITE INTRODUCES:',
510
+ ` ${f.offending}${filePath ? ` (in ${filePath})` : ''}`,
511
+ '',
512
+ 'DO THIS INSTEAD:',
513
+ ` ${f.fix}`,
514
+ '',
515
+ 'This is the founding failure of this product, made mechanical (issue #46, ADR-055 §3): the model',
516
+ 'searched, received the right answer, and wrote its opposite with every gate green — because the',
517
+ 'stamp recorded that a search RAN, never what it RETURNED. It records both now.',
518
+ '',
519
+ 'If you genuinely mean it, say why IN THE WRITE ITSELF (a receipt is recorded and adjudicated):',
520
+ ' RUVNET_GROUNDING_OVERRIDE: <your reason, at least 12 characters>',
521
+ f.receipt ? `\n[receipt ${f.receipt}]` : null,
522
+ ].filter((l) => l !== null).join('\n');
523
+ }
524
+
525
+ // ── Main ────────────────────────────────────────────────────────────────────────────────────────
526
+
527
+ function main() {
528
+ let raw = '';
529
+ try { raw = fs.readFileSync(0, 'utf8'); } catch { return 0; }
530
+ if (!raw.trim()) return 0;
531
+
532
+ const ev = parseHookEvent(raw);
533
+ if (!ev) return 0; // malfunction is not a decision
534
+ const tool = toolName(ev);
535
+ if (!/^(Write|Edit|MultiEdit)$/.test(tool)) return 0;
536
+
537
+ const filePath = typeof ev.tool_input?.file_path === 'string' ? ev.tool_input.file_path : '';
538
+ const added = addedText(ev);
539
+ if (!added) return 0;
540
+
541
+ const receipts = readReceipts();
542
+ if (!receipts.length) return 0; // NEVER block on absence of evidence
543
+
544
+ const { policy } = readPolicy(filePath);
545
+ const code = stripComments(added);
546
+ const refs = executableRemoteRefs(code);
547
+ const pkgs = writtenPackages(code);
548
+
549
+ const findings = [
550
+ ...detectD1({ refs, receipts, policy }),
551
+ ...detectD2({ pkgs, receipts, policy }),
552
+ ...detectD3({ code, prose: added, receipts }),
553
+ ];
554
+ if (!findings.length) return 0;
555
+ const f = findings[0];
556
+
557
+ const ovr = overrideFrom(added);
558
+ if (ovr && ovr.valid) {
559
+ writeOverrideReceipt({
560
+ at: new Date().toISOString(), detector: f.id, receipt: f.receipt || null,
561
+ file: filePath, offending: f.offending, diffHash: sha12(added), reason: ovr.reason,
562
+ adjudication: 'unadjudicated',
563
+ });
564
+ gateReceipt(`override:${f.id}`, `overridden in-band: ${ovr.reason}`.slice(0, 150));
565
+ // Loud on purpose (§3.5: overrides are loud). exit 0 — the write proceeds, on the record.
566
+ process.stderr.write(
567
+ `[grounding-substance] OVERRIDE ACCEPTED for ${f.id} (${f.offending}) — reason: ${ovr.reason}\n`
568
+ + `[grounding-substance] receipt written to ${overrideLedger()} for adjudication.\n`,
569
+ );
570
+ return 0;
571
+ }
572
+
573
+ gateReceipt(f.offending, `${f.id} ${f.name}: ${f.headline}`.slice(0, 150));
574
+ process.stderr.write(`${refusal(f, filePath)}\n`);
575
+ if (ovr && !ovr.valid) {
576
+ process.stderr.write('\n[grounding-substance] an override token was present but its reason was too short (<12 chars) — not accepted.\n');
577
+ }
578
+ return 2;
579
+ }
580
+
581
+ // Rule 3, at the outermost boundary: nothing this file can throw may become a refusal.
582
+ //
583
+ // Guarded on being the ENTRYPOINT so the pure helpers above can be imported by the parity test
584
+ // without this file reading stdin and exiting the test runner — the same shape hook-input.mjs uses.
585
+ // stdin here is a pipe bash has already drained and re-fed (`printf '%s' "$INPUT" | node …`), so the
586
+ // held-open-stdin hole ADR-055 F20 found in unprompted-runtime.mjs cannot occur on this path: there
587
+ // is no interactive terminal on the other end, ever.
588
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
589
+ let code = 0;
590
+ try { code = main(); } catch (e) {
591
+ try { process.stderr.write(`[grounding-substance] degraded (${e && e.message}) — write permitted\n`); } catch { /* never */ }
592
+ code = 0;
593
+ }
594
+ process.exit(code);
595
+ }