ruvnet-brain 4.4.0 → 4.5.0

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 (105) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +679 -121
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +258 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +14 -2
  58. package/plugin/scripts/project-progression-store.mjs +153 -6
  59. package/plugin/scripts/protect-brain-state.sh +4 -1
  60. package/plugin/scripts/session-snapshot-hook.mjs +225 -41
  61. package/plugin/scripts/session-start-budget.mjs +1 -0
  62. package/plugin/scripts/session-start-core.mjs +34 -4
  63. package/plugin/scripts/session-start-health.mjs +7 -1
  64. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  65. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  66. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  67. package/plugin/skills/brain-console/SKILL.md +3 -3
  68. package/plugin/skills/rnbc/SKILL.md +24 -0
  69. package/plugin/skills/rvbc/SKILL.md +2 -2
  70. package/scripts/approved-runtime.mjs +2 -2
  71. package/scripts/ci/warm-brain-models.mjs +28 -0
  72. package/scripts/codex-hook-trust.mjs +94 -0
  73. package/scripts/console-instances.mjs +70 -12
  74. package/scripts/console-runtime-identity.mjs +5 -0
  75. package/scripts/corpus-canary.mjs +46 -6
  76. package/scripts/corpus-dispatch-decision.mjs +2 -2
  77. package/scripts/corpus-promotion.mjs +1 -1
  78. package/scripts/full-suite-gate.mjs +9 -2
  79. package/scripts/hook-qualify-hosts.mjs +15 -3
  80. package/scripts/host-install-matrix.mjs +63 -2
  81. package/scripts/human-approval-phrases.mjs +46 -0
  82. package/scripts/installed-brain-health.mjs +53 -0
  83. package/scripts/move-brain.mjs +310 -0
  84. package/scripts/onboarding-console.mjs +93 -10
  85. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  86. package/scripts/oracle/abstain-trace.mjs +139 -0
  87. package/scripts/oracle/doc2query-generate.mjs +162 -0
  88. package/scripts/oracle/doc2query-reach.mjs +110 -0
  89. package/scripts/oracle/judge-train.mjs +158 -0
  90. package/scripts/oracle/need-set-split.mjs +48 -0
  91. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  92. package/scripts/package-cards.mjs +374 -0
  93. package/scripts/publication-receipt.mjs +37 -9
  94. package/scripts/recommendation-e2e.mjs +110 -0
  95. package/scripts/recommendation-eval.mjs +105 -0
  96. package/scripts/recommendation-floor.mjs +56 -0
  97. package/scripts/recommendation-judge-score.mjs +74 -0
  98. package/scripts/recommendation-latency.mjs +95 -0
  99. package/scripts/recommendation-real-host-score.mjs +76 -0
  100. package/scripts/recommendation-real-host.mjs +137 -0
  101. package/scripts/release-channel-kind.mjs +1 -1
  102. package/scripts/release-environment-policy.mjs +33 -0
  103. package/scripts/single-source-check.mjs +15 -10
  104. package/scripts/sync-commands.mjs +5 -2
  105. package/scripts/wired-check.mjs +17 -2
@@ -29,11 +29,85 @@
29
29
  import fs from 'node:fs';
30
30
  import { fileURLToPath } from 'node:url';
31
31
 
32
- /** Parse the raw stdin payload into the hook event object, or null if it isn't valid JSON. */
32
+ // ── GROK CLI PAYLOADS (4.5) ──────────────────────────────────────────────────────────────────────
33
+ //
34
+ // Grok CLI runs Claude-format hooks (its docs: ~/.grok/docs/user-guide/10-hooks.md, "Hook Locations"
35
+ // and "Tool Name Aliases"), but its payload is not Claude's. Captured from grok 1.0.13
36
+ // (tests/fixtures/hook-payloads/grok/):
37
+ // · event names are snake_case: hook_event_name "pre_tool_use", "stop", "user_prompt_submit";
38
+ // · tool names are Grok's own: the measured file tool is `write` (docs also name `search_replace`,
39
+ // and `run_terminal_command` / `run_terminal_cmd` for the shell);
40
+ // · camelCase fields (hookEventName, toolName, toolInput, toolResult, stopHookActive,
41
+ // lastAssistantMessage) with snake_case duplicates for SOME of them only — the Stop payload has no
42
+ // `stop_hook_active` and no `last_assistant_message` at all.
43
+ // A Grok matcher written with Claude names still fires (Grok aliases Write→its tool in the MATCHER), but
44
+ // the payload keeps the native name — so every policy that compared tool_name to "Write" saw "write" and
45
+ // silently allowed. Normalising HERE, in the one parser, means every consumer reads Claude's shape.
46
+ // Only a payload that is recognisably Grok's is rewritten; a Claude or Codex payload comes back untouched.
47
+ export const GROK_TOOL_ALIASES = Object.freeze({
48
+ write: 'Write', search_replace: 'Edit', edit: 'Edit', multi_edit: 'MultiEdit',
49
+ run_terminal_command: 'Bash', run_terminal_cmd: 'Bash', read_file: 'Read', grep: 'Grep',
50
+ list_dir: 'Glob', web_search: 'WebSearch', web_fetch: 'WebFetch', spawn_subagent: 'Task', task: 'Task',
51
+ });
52
+
53
+ /** True for a payload in Grok's shape (camelCase envelope, or a snake_case lowercase event name). */
54
+ export function isGrokEvent(ev) {
55
+ if (!ev || typeof ev !== 'object') return false;
56
+ if (typeof ev.hookEventName === 'string') return true;
57
+ return typeof ev.hook_event_name === 'string' && /^[a-z]+(?:_[a-z]+)+$|^stop$/.test(ev.hook_event_name);
58
+ }
59
+
60
+ const pascalEvent = (e) => String(e).split('_').filter(Boolean).map((x) => x[0].toUpperCase() + x.slice(1)).join('');
61
+
62
+ /** Claude's tool name for a host tool name (Grok's `write` → `Write`); unknown names pass through. */
63
+ export function canonicalToolName(name) {
64
+ const s = typeof name === 'string' ? name : '';
65
+ return Object.prototype.hasOwnProperty.call(GROK_TOOL_ALIASES, s) ? GROK_TOOL_ALIASES[s] : s;
66
+ }
67
+
68
+ /**
69
+ * Is this a FILE-WRITE tool, on any host? Case-insensitive, so a host that spells Write in another case
70
+ * (Grok's `write`) can never walk past a write guard again. apply_patch is Codex's raw name.
71
+ */
72
+ const WRITE_TOOLS = new Set(['write', 'edit', 'multiedit', 'multi_edit', 'notebookedit', 'search_replace', 'apply_patch']);
73
+ export function isWriteTool(name) {
74
+ return typeof name === 'string' && WRITE_TOOLS.has(name.toLowerCase());
75
+ }
76
+
77
+ /** A Claude-shaped copy of a Grok payload; any other payload is returned as-is (same object). */
78
+ export function normalizeHostEvent(ev) {
79
+ if (!isGrokEvent(ev)) return ev;
80
+ const out = { ...ev };
81
+ const set = (snake, camel) => { if (out[snake] === undefined && ev[camel] !== undefined) out[snake] = ev[camel]; };
82
+ out.hook_event_name = pascalEvent(ev.hook_event_name || ev.hookEventName || '');
83
+ set('session_id', 'sessionId');
84
+ set('transcript_path', 'transcriptPath');
85
+ set('permission_mode', 'permissionMode');
86
+ set('tool_input', 'toolInput');
87
+ set('tool_response', 'toolResult');
88
+ set('tool_use_id', 'toolUseId');
89
+ set('stop_hook_active', 'stopHookActive');
90
+ set('last_assistant_message', 'lastAssistantMessage');
91
+ const native = typeof ev.tool_name === 'string' ? ev.tool_name : (typeof ev.toolName === 'string' ? ev.toolName : undefined);
92
+ if (native !== undefined) {
93
+ out.tool_name = canonicalToolName(native);
94
+ if (out.tool_name !== native) out.host_tool_name = native;
95
+ }
96
+ out.host = 'grok';
97
+ return out;
98
+ }
99
+
100
+ /** The raw payload TEXT, rewritten to Claude's shape only when it is a Grok payload (else unchanged bytes). */
101
+ export function normalizePayloadText(raw) {
102
+ const ev = (() => { try { const j = JSON.parse(raw); return j && typeof j === 'object' ? j : null; } catch { return null; } })();
103
+ return ev && isGrokEvent(ev) ? JSON.stringify(normalizeHostEvent(ev)) : raw;
104
+ }
105
+
106
+ /** Parse the raw stdin payload into the hook event object (Claude-shaped), or null if it isn't valid JSON. */
33
107
  export function parseHookEvent(raw) {
34
108
  try {
35
109
  const j = JSON.parse(raw);
36
- return j && typeof j === 'object' ? j : null;
110
+ return j && typeof j === 'object' ? normalizeHostEvent(j) : null;
37
111
  } catch {
38
112
  return null;
39
113
  }
@@ -107,13 +181,13 @@ export async function readStopHookInput() {
107
181
  if (process.stdin.isTTY) return { __source: 'tty' };
108
182
  try {
109
183
  const raw = (await readStdinBounded()).toString('utf8');
110
- return { ...JSON.parse(raw || '{}'), __source: 'stdin' };
184
+ return { ...normalizeHostEvent(JSON.parse(raw || '{}')), __source: 'stdin' };
111
185
  } catch { return { __source: 'unreadable' }; }
112
186
  }
113
187
 
114
188
  /** The tool being invoked ("Bash", "Write", …), or "" if absent. */
115
189
  export function toolName(ev) {
116
- return ev && typeof ev.tool_name === 'string' ? ev.tool_name : '';
190
+ return ev && typeof ev.tool_name === 'string' ? canonicalToolName(ev.tool_name) : '';
117
191
  }
118
192
 
119
193
  /**
@@ -0,0 +1,148 @@
1
+ // DISTINCT-FROM: kb/forge-update.mjs reclaimBackups/assertRedundantBackup — that proof requires every byte of a copy to survive in live, which no older generation satisfies; this one requires byte-identity only for private-store files and public provenance for stores the live brain lacks (ADR-0098).
2
+ //
3
+ // kb-copy-proof.mjs — may a full copy of the knowledge base be deleted? The ONE proof used by the
4
+ // footprint sweep (plugin/scripts/brain-footprint.mjs) and by the installer right after it activates a
5
+ // new generation (bin/install.mjs unzipInto). Pure read; never follows a link; never writes.
6
+ //
7
+ // A copy is DISPOSABLE only when nothing in it is unique. Every file must be one of:
8
+ // * a PRIVATE-store file (names from the PRIVATE-STORES.json fence of the live brain AND of the copy, plus
9
+ // every updateManaged:false store in either SOURCE.json; membership rule = kb/forge-update.mjs
10
+ // capturePrivateOverlayState) that exists BYTE-IDENTICAL at the same path in the live brain — nothing
11
+ // else excuses a private file;
12
+ // * a public release file: listed with these exact bytes in the copy's own ARCHIVE-MANIFEST.json, or a
13
+ // name the live generation ships, or a member of a public store family (named by either COVERAGE.json
14
+ // or the live SOURCE.json) that a newer release replaced or retired;
15
+ // * installer-written or reinstallable (node_modules/, the updater/validator files the installer places);
16
+ // * a symbolic link identical in the live brain (links are compared, never followed).
17
+ // Anything else — a user's own file, an unfenced store, a link the live brain lacks — KEEPS the copy, and
18
+ // is named. The live brain itself must be present, so a copy is never removed while it may be the only one.
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+ import { isVolumeMetadata } from './footprint-io.mjs';
23
+
24
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
25
+ const lstat = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
26
+ const names = (dir) => { try { return fs.readdirSync(dir).sort(); } catch { return []; } };
27
+ const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
28
+
29
+ /** Every regular file and link under `root`, relative, without following links. macOS volume metadata
30
+ * (AppleDouble `._*` shadows on an exFAT disk, .DS_Store, …) is the volume's, never a copy's unique data. */
31
+ function walk(root, prefix = '', out = []) {
32
+ for (const name of names(path.join(root, prefix)).filter((n) => !isVolumeMetadata(n))) {
33
+ const relative = prefix ? path.join(prefix, name) : name;
34
+ const st = lstat(path.join(root, relative));
35
+ if (!st) continue;
36
+ if (st.isSymbolicLink()) out.push({ relative, link: true });
37
+ else if (st.isDirectory()) walk(root, relative, out);
38
+ else if (st.isFile()) out.push({ relative, link: false, size: st.size });
39
+ }
40
+ return out;
41
+ }
42
+
43
+ const storeList = (source) => {
44
+ const stores = source?.stores;
45
+ if (Array.isArray(stores)) return stores;
46
+ return stores && typeof stores === 'object' ? Object.entries(stores).map(([kbName, value]) => ({ kbName, ...value })) : [];
47
+ };
48
+ const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
49
+ // The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>.meta.json, <store>.passages.jsonl,
50
+ // <store>.symbols.json, <store>-primer.md.
51
+ const storeStem = (file) => path.basename(String(file)).toLowerCase()
52
+ .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '').replace(/\.(?:meta|symbols)\.json$|\.passages\.jsonl$/, '');
53
+ // Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
54
+ // placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
55
+ const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
56
+ 'forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs', 'update-storage-transaction.mjs',
57
+ 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'verify-citation.mjs', 'package-lock.json', '.DS_Store']);
58
+
59
+ /** A directory that is a knowledge-base tree (SOURCE.json plus a store or the search entry point). */
60
+ export const isKbTree = (dir) => Boolean(lstat(path.join(dir, 'SOURCE.json')))
61
+ && names(dir).some((n) => /\.rvf$/i.test(n) || n === 'forge-mcp-all.mjs');
62
+
63
+ /** Private store names the given trees declare: PRIVATE-STORES.json fences and updateManaged:false stores. */
64
+ export function privateStoreNames(dirs) {
65
+ const out = new Set();
66
+ for (const dir of dirs) {
67
+ const fence = readJson(path.join(dir, 'PRIVATE-STORES.json'))?.privateStores;
68
+ for (const name of Array.isArray(fence) ? fence : []) out.add(String(name).toLowerCase());
69
+ for (const store of storeList(readJson(path.join(dir, 'SOURCE.json')))) {
70
+ if (store?.updateManaged === false && store.kbName) out.add(String(store.kbName).toLowerCase());
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** The capturePrivateOverlayState membership rule (kb/forge-update.mjs), applied to one relative path. */
77
+ function belongsToPrivate(relative, privateNames, privateArtifacts) {
78
+ const base = path.basename(relative).toLowerCase();
79
+ for (const name of privateNames) if (base === name || base.startsWith(`${name}.`) || base.startsWith(`${name}-`)) return true;
80
+ return privateArtifacts.some(({ directory, basename, stem }) => path.dirname(relative) === directory
81
+ && (base === basename || base.startsWith(`${basename}.`) || base.startsWith(`${stem}.`) || base.startsWith(`${stem}-`)));
82
+ }
83
+
84
+ const sameBytes = (left, right) => {
85
+ const a = lstat(left); const b = lstat(right);
86
+ if (!a || !b || !a.isFile() || !b.isFile() || a.isSymbolicLink() || b.isSymbolicLink() || a.size !== b.size) return false;
87
+ try { return sha256File(left) === sha256File(right); } catch { return false; }
88
+ };
89
+
90
+ /**
91
+ * @returns {{disposable: boolean, unique: {file: string, why: string}[], reason: string}} — a kept copy
92
+ * always names the files that keep it.
93
+ */
94
+ export function kbCopyProof({ copyDir, liveDir }) {
95
+ const copy = lstat(copyDir);
96
+ if (!copy || copy.isSymbolicLink() || !copy.isDirectory()) return { disposable: false, unique: [], reason: 'not a real directory (a link is never entered)' };
97
+ const live = lstat(liveDir);
98
+ if (!live || !live.isDirectory() || !readJson(path.join(liveDir, 'SOURCE.json'))
99
+ || !names(liveDir).some((name) => /\.rvf$/i.test(name))) {
100
+ return { disposable: false, unique: [], reason: 'the live brain is missing or incomplete, so this copy may be the only good one' };
101
+ }
102
+ const privateNames = privateStoreNames([liveDir, copyDir]);
103
+ const generations = readJson(path.join(copyDir, 'RVF-GENERATIONS.json'))?.stores || {};
104
+ const privateArtifacts = Object.entries(generations).filter(([name]) => privateNames.has(name.toLowerCase()))
105
+ .map(([, g]) => String(g?.file || '')).filter(Boolean).map((file) => ({ directory: path.dirname(path.normalize(file)),
106
+ basename: path.basename(file).toLowerCase(), stem: stemOf(file) }));
107
+ const coverageNames = (dir) => (readJson(path.join(dir, 'COVERAGE.json'))?.rows || [])
108
+ .flatMap((row) => [row?.artifact?.store, row?.name]).filter(Boolean).map((n) => String(n).toLowerCase());
109
+ const publicNames = new Set([...coverageNames(liveDir), ...coverageNames(copyDir),
110
+ ...storeList(readJson(path.join(liveDir, 'SOURCE.json'))).filter((s) => s?.updateManaged !== false)
111
+ .map((s) => String(s.kbName || '').toLowerCase())]);
112
+ for (const name of privateNames) publicNames.delete(name); // a fenced name is never "public provenance"
113
+ // The copy's own signed release manifest (scripts/build-bundle.mjs, every bundle since 2026-08-23): a file
114
+ // it lists with these exact bytes is a public, re-downloadable release file.
115
+ const shipped = new Map((readJson(path.join(copyDir, 'ARCHIVE-MANIFEST.json'))?.files || [])
116
+ .filter((f) => typeof f?.path === 'string').map((f) => [path.normalize(f.path), f]));
117
+ const unique = [];
118
+ let files;
119
+ try { files = walk(copyDir); } catch (error) { return { disposable: false, unique, reason: `unreadable copy: ${error.message}` }; }
120
+ for (const { relative, link, size } of files) {
121
+ // node_modules (npm reinstalls it) and .console-runtime (bin/install.mjs installConsoleRuntime re-places it)
122
+ if (['node_modules', '.console-runtime'].includes(relative.split(path.sep)[0])
123
+ || (!relative.includes(path.sep) && INSTALLER_WRITTEN.has(relative))) continue;
124
+ const isPrivate = belongsToPrivate(relative, privateNames, privateArtifacts);
125
+ const inLive = path.join(liveDir, relative);
126
+ if (link) {
127
+ const liveLink = lstat(inLive);
128
+ const same = liveLink?.isSymbolicLink() && fs.readlinkSync(inLive) === fs.readlinkSync(path.join(copyDir, relative));
129
+ if (!same) unique.push({ file: relative, why: 'a symbolic link the live brain does not have (never followed)' });
130
+ continue;
131
+ }
132
+ if (isPrivate) {
133
+ if (!sameBytes(path.join(copyDir, relative), inLive)) {
134
+ unique.push({ file: relative, why: lstat(inLive) ? 'private file differs from the live brain' : 'private file absent from the live brain' });
135
+ }
136
+ continue;
137
+ }
138
+ if (relative === 'ARCHIVE-MANIFEST.json' && shipped.size) continue; // the release manifest itself
139
+ const listed = shipped.get(relative);
140
+ if (listed && listed.bytes === size && listed.sha256 === sha256File(path.join(copyDir, relative))) continue;
141
+ if (lstat(inLive)) continue; // a release-owned name the live generation ships (same or newer bytes)
142
+ if (publicNames.has(storeStem(relative))) continue; // a public store family a newer release replaced or retired
143
+ unique.push({ file: relative, why: 'not in the live brain, not in this copy\'s release manifest, not a public store' });
144
+ }
145
+ return unique.length
146
+ ? { disposable: false, unique, reason: `holds ${unique.length} file(s) the live brain does not: ${unique.slice(0, 4).map((u) => u.file).join(', ')}${unique.length > 4 ? ', …' : ''}` }
147
+ : { disposable: true, unique, reason: 'every private file is byte-identical in the live brain; public bytes are re-downloadable' };
148
+ }
@@ -23,6 +23,7 @@ import path from 'node:path';
23
23
  import { execFileSync } from 'node:child_process';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { makeLesson, updateLessons, loadLessons, ENFORCEMENT, ORIGIN, STATUS, TRIGGERS } from './lesson-store.mjs';
26
+ import { loadNodeSqlite } from './node-sqlite.mjs';
26
27
 
27
28
  /** Every bridged lesson id starts with this. It is how a merge knows which rows it owns. */
28
29
  export const BRIDGE_PREFIX = 'G-';
@@ -108,8 +109,11 @@ export function readProjectRows(dbPath = PROJECT_DB) {
108
109
  } catch { return []; }
109
110
  }
110
111
 
111
- /** Indirection so a missing node:sqlite is a caught throw rather than a module-load crash. */
112
- function require$(id) { return process.getBuiltinModule ? process.getBuiltinModule(id) : null; }
112
+ /**
113
+ * Indirection so a missing node:sqlite is a caught throw rather than a module-load crash, loaded
114
+ * through node-sqlite.mjs so Node 22's SQLite ExperimentalWarning never reaches hook output.
115
+ */
116
+ function require$(id) { return id === 'node:sqlite' ? loadNodeSqlite() : (process.getBuiltinModule ? process.getBuiltinModule(id) : null); }
113
117
 
114
118
  // ── Row → lesson ─────────────────────────────────────────────────────────────────────────────────
115
119
 
@@ -73,6 +73,11 @@ export function nightlyStatus(options = {}) {
73
73
  testMode: options.testMode ?? env.RUVNET_BRAIN_SCHEDULER_TEST === '1' });
74
74
  }
75
75
 
76
+ export function stripAnsi(text) {
77
+ // eslint-disable-next-line no-control-regex
78
+ return String(text).replace(/\u001b\[[0-9;]*[A-Za-z]/g, '');
79
+ }
80
+
76
81
  export function applyNightlyChoice(enabled, options = {}) {
77
82
  if (typeof enabled !== 'boolean') return { ok: false, log: 'nightly must be true or false' };
78
83
  const env = schedulerEnvironment(options.env || process.env);
@@ -97,6 +102,8 @@ export function applyNightlyChoice(enabled, options = {}) {
97
102
  after,
98
103
  log: ok
99
104
  ? `Nightly refresh is ${desired}; ${after.evidence}.`
100
- : `Nightly refresh did not reach ${desired}: ${run.error?.message || run.stderr?.trim() || run.stdout?.trim() || `exit ${run.status}`}`,
105
+ // The installer colours its terminal output; this string is shown in the console page, where
106
+ // raw ANSI escapes render as "[31m✗" noise (RNBC QA 2026-10-01). Strip them here, once.
107
+ : `Nightly refresh did not reach ${desired}: ${stripAnsi(run.error?.message || run.stderr?.trim() || run.stdout?.trim() || `exit ${run.status}`)}`,
101
108
  };
102
109
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * node-sqlite.mjs — the ONE way plugin code loads `node:sqlite`, lazily and without hook noise.
3
+ *
4
+ * WHY (measured 2026-10-01, Node 22.13.1 locally and Node 22 on the Linux CI full suite, run 36894944735):
5
+ * on Node 22 the first load of `node:sqlite` prints
6
+ * (node:PID) ExperimentalWarning: SQLite is an experimental feature and might change at any time
7
+ * to stderr. SessionStart reads the store through project-progression-reader.mjs, so every SessionStart
8
+ * in an adopted git project on Node 22 carried that line — hook noise a customer sees on every session.
9
+ * Node 24 does not warn, which is why the owner's machine never showed it.
10
+ *
11
+ * WHAT: the module is required only when a caller actually needs it (never at import time), and during
12
+ * that one synchronous require `process.emitWarning` drops exactly ONE warning shape — type
13
+ * ExperimentalWarning whose message names SQLite — and passes every other warning through untouched.
14
+ * The original `process.emitWarning` is restored before returning, whether the require worked or not.
15
+ * Node's emitExperimentalWarning calls the public process.emitWarning synchronously while the builtin
16
+ * is being compiled (node:internal/util emitExperimentalWarning), which is what makes the scope exact.
17
+ */
18
+ import { createRequire } from 'node:module';
19
+
20
+ const requireBuiltin = createRequire(import.meta.url);
21
+ let loaded;
22
+
23
+ /** True for the one warning this module exists to keep out of hook output. */
24
+ export function isSqliteExperimentalWarning(warning, typeOrOptions) {
25
+ const type = typeof typeOrOptions === 'string' ? typeOrOptions : typeOrOptions?.type;
26
+ const name = warning && typeof warning === 'object' ? warning.name : undefined;
27
+ const message = typeof warning === 'string' ? warning : warning?.message;
28
+ return (type === 'ExperimentalWarning' || name === 'ExperimentalWarning') && /\bSQLite\b/.test(String(message ?? ''));
29
+ }
30
+
31
+ /** The `node:sqlite` module, or null where this Node has none. Loaded once; never warns about SQLite. */
32
+ export function loadNodeSqlite({ load = (id) => requireBuiltin(id) } = {}) {
33
+ if (loaded !== undefined) return loaded;
34
+ const original = process.emitWarning;
35
+ process.emitWarning = function emitWarningExceptSqlite(warning, ...rest) {
36
+ if (isSqliteExperimentalWarning(warning, rest[0])) return undefined;
37
+ return original.call(this, warning, ...rest);
38
+ };
39
+ try { loaded = load('node:sqlite'); } catch { loaded = null; } finally { process.emitWarning = original; }
40
+ return loaded;
41
+ }