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
@@ -0,0 +1,310 @@
1
+ // move-brain.mjs — `npx ruvnet-brain --move-brain <dir>`: put the whole Brain on another disk with one
2
+ // command, and `--move-brain --back` to bring it home.
3
+ //
4
+ // Design (decided 2026-10-01): the Brain stays addressed by its default path, ~/.cache/ruvnet-brain, which
5
+ // becomes a symlink (a junction on Windows) to the new location. Every reader, hook, the MCP server and the
6
+ // nightly keep their path and follow the link, so nothing else needs configuring (an env var would not reach
7
+ // GUI-launched hosts or launchd). The move is: take the refresh lock the updater takes, preflight space, copy,
8
+ // PROVE every source file arrived byte-identical, swap the link, then remove the old copy. Any failure before
9
+ // the swap leaves the Brain exactly where it was and removes the copy; a failure DURING the swap is rolled
10
+ // back step by step, and the message says plainly where the Brain is.
11
+ //
12
+ // What the proof ignores, and why (review S1/S2, reproduced on a real exFAT image 2026-10-01):
13
+ // - VOLUME METADATA, on either side. macOS writes an AppleDouble `._<name>` beside every copied file that
14
+ // has extended attributes on exFAT/FAT/NTFS/SMB, and volumes grow .DS_Store/.Spotlight-V100/.fseventsd/
15
+ // .Trashes. They are the filesystem's, not the Brain's (no release ships one; coverage-integrity skips
16
+ // `._*` too). So they are never copied — `--back` from exFAT used to bring `._ruvector.rvf` home, where
17
+ // every reader saw a fake `._ruvector` store — and never compared or required on either side.
18
+ // - TRANSIENT IPC in run/. A search worker's recommender socket (run/recommend-<pid>.sock) is a live
19
+ // endpoint, not data; a SIGKILLed worker leaves it behind. Stale ones are swept exactly as the endpoint
20
+ // sweeps them on start (kb/recommend-endpoint.mjs sweepStale — not shipped in the npm package, so the
21
+ // rule is repeated here and held equal by tests/unit/move-brain.test.mjs); live ones are not copied.
22
+ // Any other socket, FIFO or device anywhere in the Brain refuses the move by name, before anything is copied.
23
+ import crypto from 'node:crypto';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { brainLocation, defaultBrainHome } from '../plugin/scripts/brain-location.mjs';
28
+ import { checkDiskSpace, directoryBytes } from '../kb/update-storage-transaction.mjs';
29
+ import { acquireRefreshLock, releaseRefreshLock } from '../kb/refresh-run.mjs';
30
+
31
+ // The plugin updater's own locks (plugin/scripts/update-apply.mjs, session-start-health.mjs). The refresh
32
+ // lock (kb/refresh-run.mjs) is not merely checked: the move HOLDS it, so no update can start mid-move.
33
+ const PLUGIN_LOCK_NAMES = ['.update.lock', 'auto-update.lock'];
34
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
35
+ const VOLUME_METADATA = /^(?:\._.+|\.DS_Store|\.Spotlight-V100|\.fseventsd|\.Trashes|\.TemporaryItems|\.apdisk|System Volume Information|\$RECYCLE\.BIN)$/;
36
+ const STALE_ENDPOINT = /^recommend-(\d+)\.(json|sock)$/;
37
+
38
+ class MoveRefused extends Error {}
39
+ export { MoveRefused };
40
+
41
+ const refuse = (message) => { throw new MoveRefused(message); };
42
+ const describe = (error) => (error?.code ? `${error.code}: ${error.message}` : String(error?.message || error));
43
+
44
+ function isEmptyDir(dir) {
45
+ try { return fs.lstatSync(dir).isDirectory() && fs.readdirSync(dir).length === 0; } catch { return false; }
46
+ }
47
+
48
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; } };
49
+
50
+ /** The endpoint's own stale rule (kb/recommend-endpoint.mjs sweepStale): a descriptor or socket whose pid is dead. */
51
+ export function sweepStaleEndpoints(dir) {
52
+ let names = [];
53
+ try { names = fs.readdirSync(dir); } catch { return 0; }
54
+ let removed = 0;
55
+ for (const n of names) {
56
+ const m = n.match(STALE_ENDPOINT);
57
+ if (!m || pidAlive(Number(m[1]))) continue;
58
+ try { fs.rmSync(path.join(dir, n), { force: true }); removed++; } catch { /* raced */ }
59
+ }
60
+ return removed;
61
+ }
62
+
63
+ /**
64
+ * Walk a Brain tree without following links. Returns Map<relative, {type, size?, target?}> and the list of
65
+ * transient run/ entries (sockets/FIFOs) that are left behind. Any other special file refuses by name.
66
+ */
67
+ function inventory(root, { strict = true } = {}) {
68
+ const entries = new Map();
69
+ const transient = [];
70
+ const walk = (rel) => {
71
+ for (const name of fs.readdirSync(path.join(root, rel)).sort()) {
72
+ const relative = rel ? path.join(rel, name) : name;
73
+ if (VOLUME_METADATA.test(name)) continue; // the volume's, not the Brain's (header)
74
+ const stat = fs.lstatSync(path.join(root, relative));
75
+ if (stat.isSymbolicLink()) entries.set(relative, { type: 'symlink', target: fs.readlinkSync(path.join(root, relative)) });
76
+ else if (stat.isDirectory()) { entries.set(relative, { type: 'dir' }); walk(relative); }
77
+ else if (stat.isFile()) entries.set(relative, { type: 'file', size: stat.size });
78
+ else if ((stat.isSocket() || stat.isFIFO()) && path.dirname(relative) === 'run') transient.push(relative);
79
+ else if (strict) refuse(`${path.join(root, relative)} is a ${stat.isSocket() ? 'socket' : stat.isFIFO() ? 'named pipe' : 'device or special file'}, `
80
+ + 'which cannot be copied to another disk. Stop whatever created it (or remove it if nothing is using it), then retry. Nothing was moved.');
81
+ else entries.set(relative, { type: 'special' });
82
+ }
83
+ };
84
+ walk('');
85
+ return { entries, transient };
86
+ }
87
+
88
+ function fileSha256(file) {
89
+ const hash = crypto.createHash('sha256');
90
+ const fd = fs.openSync(file, 'r');
91
+ try {
92
+ const buffer = Buffer.allocUnsafe(1 << 20);
93
+ for (let n; (n = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0;) hash.update(buffer.subarray(0, n));
94
+ } finally { fs.closeSync(fd); }
95
+ return hash.digest('hex');
96
+ }
97
+
98
+ /**
99
+ * Every source entry must exist at the copy with the same type, and every file the same bytes; the copy may
100
+ * carry nothing else. Volume metadata is invisible to both inventories. Returns null, or what differs.
101
+ */
102
+ export function verifyCopy(src, copy, { transient = [] } = {}) {
103
+ const skipped = new Set(transient);
104
+ const source = inventory(src, { strict: false }).entries;
105
+ const landed = inventory(copy, { strict: false }).entries;
106
+ const problems = [];
107
+ for (const [relative, entry] of source) {
108
+ if (skipped.has(relative)) continue;
109
+ const other = landed.get(relative);
110
+ if (!other) problems.push(`${relative} is missing from the copy`);
111
+ else if (other.type !== entry.type) problems.push(`${relative} is a ${other.type} in the copy but a ${entry.type} in the original`);
112
+ else if (entry.type === 'symlink' && other.target !== entry.target) problems.push(`link ${relative} points elsewhere in the copy`);
113
+ else if (entry.type === 'file' && (other.size !== entry.size || fileSha256(path.join(copy, relative)) !== fileSha256(path.join(src, relative)))) {
114
+ problems.push(`${relative} differs (${entry.size} bytes in the original, ${other.size} in the copy)`);
115
+ }
116
+ }
117
+ for (const relative of landed.keys()) {
118
+ if (!source.has(relative)) problems.push(`${relative} is in the copy but not in the original`);
119
+ }
120
+ return problems.length ? `${problems.slice(0, 3).join('; ')}${problems.length > 3 ? `; and ${problems.length - 3} more` : ''}` : null;
121
+ }
122
+
123
+ /** The default fs operations the swap uses — a seam so a test can make any one step fail. */
124
+ export const defaultOps = Object.freeze({
125
+ renameSync: (a, b) => fs.renameSync(a, b),
126
+ symlinkSync: (target, link, type) => fs.symlinkSync(target, link, type),
127
+ rmdirSync: (dir) => fs.rmdirSync(dir),
128
+ mkdirSync: (dir) => fs.mkdirSync(dir),
129
+ rmSync: (dir) => fs.rmSync(dir, { recursive: true, force: true }),
130
+ copyTree: (src, dest, filter) => fs.cpSync(src, dest, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true,
131
+ errorOnExist: true, force: false, filter }),
132
+ });
133
+
134
+ /**
135
+ * @param {{ home?: string, to?: string, back?: boolean, available?: Function, log?: Function, ops?: object,
136
+ * lock?: Function }} options
137
+ * @returns {{ from: string, to: string, bytes: number, link: string, warnings: string[] }}
138
+ */
139
+ export function moveBrain({ home = os.homedir(), to, back = false, available, log = () => {}, ops: opsOverride = {},
140
+ lock = (brainHome) => acquireRefreshLock({ kbDir: path.join(brainHome, 'kb'), brainHome, action: 'move-brain' }) } = {}) {
141
+ const ops = { ...defaultOps, ...opsOverride };
142
+ const brainHome = defaultBrainHome(home);
143
+ const where = brainLocation({ home });
144
+ if (where.state === 'unmounted') refuse(where.message);
145
+ if (where.state === 'absent') refuse(`no RuvNet Brain is installed at ${brainHome}; nothing to move.`);
146
+ const src = where.real;
147
+ for (const name of PLUGIN_LOCK_NAMES) {
148
+ if (fs.existsSync(path.join(src, name))) refuse(`an update is running (${path.join(src, name)}); retry when it finishes. Nothing was moved.`);
149
+ }
150
+
151
+ let dest;
152
+ if (back) {
153
+ if (where.state === 'local') refuse(`the Brain is already at its default location, ${brainHome}.`);
154
+ dest = brainHome;
155
+ } else {
156
+ if (!to || !path.isAbsolute(to)) refuse('--move-brain needs an absolute directory, e.g. --move-brain /Volumes/SanDisk/ruvnet-brain');
157
+ dest = path.resolve(to);
158
+ if (dest === path.resolve(brainHome)) refuse('that is the default location; use --move-brain --back to return there.');
159
+ if (!fs.existsSync(path.dirname(dest))) refuse(`the target's parent ${path.dirname(dest)} does not exist (is the disk mounted?). Nothing was moved.`);
160
+ if (fs.existsSync(dest) && fs.realpathSync(dest) === src) refuse(`the Brain is already at ${dest}.`);
161
+ const rel = path.relative(src, dest);
162
+ if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) refuse('the target is inside the Brain itself.');
163
+ if (fs.existsSync(dest) && !isEmptyDir(dest)) refuse(`the target ${dest} exists and is not empty; choose a new or empty directory.`);
164
+ // The copy is staged beside the target and renamed into place, so the target must be a folder ON the disk,
165
+ // never the disk's own root: staging would land on the parent's filesystem (measured there, copied there).
166
+ if (fs.existsSync(dest) && fs.statSync(dest).dev !== fs.statSync(path.dirname(dest)).dev) {
167
+ refuse(`${dest} is the top of a disk; choose a folder on it, e.g. --move-brain ${path.join(dest, 'ruvnet-brain')}`);
168
+ }
169
+ }
170
+
171
+ let held;
172
+ try { held = lock(brainHome); } catch (error) {
173
+ refuse(`an update is running or its lock is unclear (${describe(error)}); retry when it finishes. Nothing was moved.`);
174
+ }
175
+ try {
176
+ return moveLocked({ where, brainHome, src, dest, back, available, log, ops });
177
+ } finally {
178
+ // Through the default path: after a successful swap the held lock directory lives in the new copy.
179
+ if (held) releaseRefreshLock(held);
180
+ }
181
+ }
182
+
183
+ function moveLocked({ where, brainHome, src, dest, back, available, log, ops }) {
184
+ const warnings = [];
185
+ sweepStaleEndpoints(path.join(src, 'run'));
186
+ const { transient } = inventory(src); // refuses, by name, on any socket/FIFO/device outside run/
187
+ const bytes = directoryBytes(src);
188
+ const stagingDir = path.dirname(dest);
189
+ try {
190
+ const space = checkDiskSpace([{ dir: stagingDir, bytes, purpose: 'Brain copy' }],
191
+ { what: 'move the Brain', bigger: 'choose a folder on a bigger disk', ...(available ? { available } : {}) });
192
+ if (!space.ok) refuse(space.message);
193
+ } catch (error) {
194
+ if (error instanceof MoveRefused) throw error;
195
+ // Node < 18.15 has no fs.statfsSync; an unmeasurable disk is not a refusal. The copy is still verified,
196
+ // and running out of space part-way removes the copy and leaves the Brain where it was.
197
+ const line = `could not measure free space on ${stagingDir} (${describe(error)}); copying anyway — a full disk stops the move safely`;
198
+ warnings.push(line);
199
+ log(line);
200
+ }
201
+
202
+ const staging = path.join(stagingDir, `.${path.basename(dest)}.moving-${process.pid}`);
203
+ fs.rmSync(staging, { recursive: true, force: true });
204
+ const skip = new Set(transient.map((relative) => path.join(src, relative)));
205
+ log(`copying ${src} -> ${dest} …`);
206
+ try {
207
+ try { ops.copyTree(src, staging, (from) => !skip.has(from) && (from === src || !VOLUME_METADATA.test(path.basename(from)))); } catch (error) {
208
+ refuse(error?.code === 'ENOSPC'
209
+ ? `${stagingDir} ran out of space while copying. The partial copy was removed and the Brain is unchanged at ${src}.`
210
+ : `copying to ${stagingDir} failed (${describe(error)}). The partial copy was removed and the Brain is unchanged at ${src}.`);
211
+ }
212
+ const drift = verifyCopy(src, staging, { transient });
213
+ if (drift) {
214
+ refuse(`the copy does not match the original: ${drift}. Either a file changed while it was being copied (retry when no `
215
+ + `search or update is running) or ${stagingDir} did not store it faithfully. The copy was removed and the Brain is unchanged at ${src}.`);
216
+ }
217
+ } catch (error) {
218
+ fs.rmSync(staging, { recursive: true, force: true });
219
+ throw error;
220
+ }
221
+
222
+ swap({ where, brainHome, src, dest, staging, back, ops });
223
+ const landed = fs.realpathSync(brainHome);
224
+ // The previous copy goes only once the default path is proven to resolve to the new one.
225
+ const previous = back || where.state === 'linked' ? src : `${brainHome}.old-${process.pid}`;
226
+ try { if (fs.existsSync(previous)) ops.rmSync(previous); } catch (error) {
227
+ const line = `the previous copy at ${previous} could not be removed (${describe(error)}); it is no longer used — delete it by hand.`;
228
+ warnings.push(line);
229
+ log(line);
230
+ }
231
+ log(`the Brain now lives at ${landed}${back ? '' : ` (${brainHome} links to it)`}`);
232
+ return { from: src, to: landed, bytes, link: brainHome, warnings };
233
+ }
234
+
235
+ /**
236
+ * Point the default path at the verified copy. Every step is recorded and undone in reverse on failure, so
237
+ * the only outcomes are "moved" or "unchanged" — and the message says which, and what is left where.
238
+ */
239
+ function swap({ where, brainHome, src, dest, staging, back, ops }) {
240
+ const undo = [];
241
+ const step = (name, run, revert) => { run(); undo.push({ name, revert }); };
242
+ const asideLink = `${brainHome}.link-old-${process.pid}`;
243
+ const tmpLink = `${brainHome}.link-${process.pid}`;
244
+ let failedAt = null;
245
+ let originalAt = src; // where the original Brain's bytes are right now (a local brain home is set aside mid-swap)
246
+ try {
247
+ if (back) {
248
+ failedAt = 'set the old link aside';
249
+ step(failedAt, () => ops.renameSync(brainHome, asideLink), () => ops.renameSync(asideLink, brainHome));
250
+ failedAt = `move the copy into ${brainHome}`;
251
+ step(failedAt, () => ops.renameSync(staging, brainHome), () => ops.renameSync(brainHome, staging));
252
+ } else {
253
+ if (isEmptyDir(dest)) {
254
+ failedAt = `replace the empty folder ${dest}`;
255
+ step(failedAt, () => ops.rmdirSync(dest), () => ops.mkdirSync(dest));
256
+ }
257
+ failedAt = `move the copy into ${dest}`;
258
+ step(failedAt, () => ops.renameSync(staging, dest), () => ops.renameSync(dest, staging));
259
+ failedAt = `create the link ${tmpLink}`;
260
+ fs.rmSync(tmpLink, { force: true });
261
+ step(failedAt, () => ops.symlinkSync(dest, tmpLink, linkType), () => fs.rmSync(tmpLink, { force: true }));
262
+ if (where.state === 'local') {
263
+ const old = `${brainHome}.old-${process.pid}`;
264
+ failedAt = `set ${brainHome} aside`;
265
+ step(failedAt, () => { ops.renameSync(brainHome, old); originalAt = old; },
266
+ () => { ops.renameSync(old, brainHome); originalAt = src; });
267
+ failedAt = `put the link at ${brainHome}`;
268
+ step(failedAt, () => ops.renameSync(tmpLink, brainHome), () => ops.renameSync(brainHome, tmpLink));
269
+ } else {
270
+ failedAt = `replace the link at ${brainHome}`;
271
+ try {
272
+ step(failedAt, () => ops.renameSync(tmpLink, brainHome), () => ops.renameSync(brainHome, tmpLink)); // atomic on POSIX
273
+ } catch (error) {
274
+ // Windows will not rename over an existing junction (EPERM/EEXIST): set the old link aside, then rename.
275
+ if (!['EPERM', 'EEXIST', 'EACCES', 'ENOTEMPTY', 'EISDIR'].includes(error?.code)) throw error;
276
+ failedAt = 'set the old link aside';
277
+ step(failedAt, () => ops.renameSync(brainHome, asideLink), () => ops.renameSync(asideLink, brainHome));
278
+ failedAt = `put the link at ${brainHome}`;
279
+ step(failedAt, () => ops.renameSync(tmpLink, brainHome), () => ops.renameSync(brainHome, tmpLink));
280
+ }
281
+ }
282
+ }
283
+ failedAt = `prove ${brainHome} resolves to ${dest}`;
284
+ const landed = fs.realpathSync(brainHome);
285
+ if (landed !== fs.realpathSync(dest)) throw new Error(`${brainHome} resolves to ${landed}`);
286
+ } catch (error) {
287
+ const unrestored = [];
288
+ for (const { name, revert } of undo.reverse()) {
289
+ try { revert(); } catch (revertError) { unrestored.push(`${name} (${describe(revertError)})`); }
290
+ }
291
+ // The staged copy is a verified duplicate; the original is intact either way, so it can always go.
292
+ let leftover = '';
293
+ try { fs.rmSync(staging, { recursive: true, force: true }); } catch { leftover = staging; }
294
+ try {
295
+ if (!leftover && !back && !isEmptyDir(dest) && fs.realpathSync(dest) !== fs.realpathSync(originalAt)) leftover = dest;
296
+ } catch { /* dest absent: nothing left there */ }
297
+ if (unrestored.length) {
298
+ refuse(`moving the Brain failed while trying to ${failedAt} (${describe(error)}), and undoing it also failed at: `
299
+ + `${unrestored.join('; ')}. The original Brain is intact at ${originalAt}; ${brainHome} must point to it again `
300
+ + '(make it a link to that folder, or move that folder back to it) before the Brain is used.'
301
+ + (leftover ? ` The unused copy at ${leftover} can be removed.` : ''));
302
+ }
303
+ // Undone: the default path is exactly as it was.
304
+ try { fs.rmSync(asideLink, { force: true }); } catch { /* only exists if a revert failed, reported above */ }
305
+ refuse(`moving the Brain failed while trying to ${failedAt} (${describe(error)}). Everything was put back: the Brain is `
306
+ + `unchanged at ${src}${where.state === 'linked' ? ` (${brainHome} links to it)` : ''}.`
307
+ + (leftover ? ` A copy at ${leftover} is not used and can be removed.` : ''));
308
+ }
309
+ try { fs.rmSync(asideLink, { force: true }); } catch { /* an unused old link; harmless */ }
310
+ }
@@ -48,7 +48,7 @@ import { inspectSessionSnapshots } from './session-snapshot-contract.mjs';
48
48
  import { learnings } from './learnings.mjs';
49
49
  import { gatesSurvey } from './gates.mjs';
50
50
  // The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why.
51
- import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
51
+ import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, revertSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
52
52
  // The brain on/off switch (ADR-054). The sentinel is the enforcement artifact; settings.json holds
53
53
  // only a mirror. The console is the ONE surface allowed to flip it — protect-brain-state.sh walls
54
54
  // the file off from agent edits — so both halves of the write live here, in saveBrainPower().
@@ -68,6 +68,7 @@ import {
68
68
  openRouterCredentialStatus,
69
69
  saveOpenRouterCredential,
70
70
  learnerCwd,
71
+ loadRuntimePreferences,
71
72
  } from '../plugin/scripts/runtime-preferences.mjs';
72
73
  import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
73
74
  // One canonical answer to "which directory is this, and have I counted it already?" — shared with
@@ -770,6 +771,30 @@ function gatherInventory() {
770
771
  };
771
772
  }
772
773
 
774
+ /**
775
+ * Keys a project-level <project>/.swarm/ruvnet-brain-settings.json overrides FOR THE RUNTIME THAT
776
+ * READS IT. runtime-preferences merges that file over the user-level choices, and route-cheap, the
777
+ * managed-CLI gate (routing, qeFleet) and learn-capture/learn-flush (learningScope) obey the merge.
778
+ * The console saves user-level values only, so without this a project seeded by "Apply these choices
779
+ * to new projects" silently ignored every later change made here (RNBC QA 2026-10-01). Only keys whose
780
+ * consumer honours the project file are reported — advocacy, autoApply and provider are read at user
781
+ * level by their consumers, so a project value for them changes nothing and is not claimed to.
782
+ */
783
+ const PROJECT_HONOURED_KEYS = Object.freeze(['routing', 'qeFleet', 'learningScope']);
784
+ function projectOverrides(keys) {
785
+ try {
786
+ const prefs = loadRuntimePreferences({ cwd: process.cwd() });
787
+ if (!prefs.projectInherited) return null;
788
+ const raw = readJSON(prefs.paths.project) || {};
789
+ const values = raw.values && typeof raw.values === 'object' ? raw.values : raw;
790
+ const out = {};
791
+ for (const key of keys) {
792
+ if (PROJECT_HONOURED_KEYS.includes(key) && Object.hasOwn(values, key) && prefs.values[key] === values[key]) out[key] = values[key];
793
+ }
794
+ return Object.keys(out).length ? { path: prefs.paths.project.replace(CONSOLE_ROOT, '~'), values: out } : null;
795
+ } catch { return null; }
796
+ }
797
+
773
798
  function gatherConfig() {
774
799
  const cfg = readJSON(CONFIG_PATH) || {};
775
800
  const credential = openRouterCredentialStatus({ cwd: process.cwd() });
@@ -797,6 +822,7 @@ function gatherConfig() {
797
822
  // What the project would pick FOR you, kept separate from what you actually picked. The form can
798
823
  // then say "recommended: on" without ever claiming that is the current state.
799
824
  defaults: { provider: 'auto', nightly: true, routing: 'auto', qeFleet: false },
825
+ projectOverrides: projectOverrides(CONFIG_SCHEMA.map((field) => field.key)),
800
826
  schema: CONFIG_SCHEMA.filter((field) =>
801
827
  !Object.hasOwn(CONFIG_CONTROL_SUPPORT, field.key)
802
828
  && (field.key !== 'nightly' || schedule.artifact.supported)),
@@ -841,6 +867,7 @@ function gatherAdvocacy() {
841
867
  Object.hasOwn(chosen, field.key) ? state.values[field.key] : null,
842
868
  ])),
843
869
  defaults: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [field.key, field.default])),
870
+ projectOverrides: projectOverrides(LIVE_USER_SETTING_KEYS),
844
871
  schema: LIVE_USER_FIELDS,
845
872
  unavailable: [],
846
873
  };
@@ -880,8 +907,19 @@ function saveAdvocacy(values) {
880
907
  const result = saveSettings(supplied);
881
908
  if (!result.ok) return { ok: false, rejected: result.errors || [], log: result.log };
882
909
  publishSettingsToCache();
910
+ // THE SAVE IS REVERSIBLE FROM WHERE IT WAS MADE. The Settings card promises "every save is
911
+ // reversible", and saveSettings already writes the backup and the existedBefore flag its own
912
+ // revertSettings() consumes — but this form returned no undo token, so the console showed an Undo
913
+ // button for config.json and none here (RNBC QA 2026-10-01). Journalled only after the write.
914
+ const undoToken = journalUndo({
915
+ kind: 'restore-user-settings',
916
+ file: result.file,
917
+ backup: result.backup,
918
+ existedBefore: result.existedBefore,
919
+ });
883
920
  return {
884
921
  ok: true,
922
+ undoToken,
885
923
  backup: result.backup ? result.backup.replace(CONSOLE_ROOT, '~') : null,
886
924
  values: Object.fromEntries(LIVE_USER_SETTING_KEYS.map((key) => [key, result.values[key]])),
887
925
  log: result.log,
@@ -1113,6 +1151,14 @@ function publishSettingsToCache() {
1113
1151
  if (!c || !c.data || !c.data.sections) return;
1114
1152
  c.data.sections.config = gatherConfig();
1115
1153
  c.data.sections.userSettings = gatherAdvocacy();
1154
+ // The Savings card reads two of the same choices (routing, and whether an OpenRouter key exists).
1155
+ // Patching only Settings left the Savings card saying "No key added" / the old routing state on
1156
+ // the next reload, beside a Settings card saying the opposite (RNBC QA 2026-10-01).
1157
+ if (c.data.sections.savings) {
1158
+ const cfgNow = readJSON(CONFIG_PATH) || {};
1159
+ c.data.sections.savings.routing = cfgNow.routing === 'off' ? 'off' : cfgNow.routing === 'auto' ? 'auto' : null;
1160
+ try { c.data.sections.savings.routerEngine = gatherRouterEngine(); } catch { /* the refresh replaces it */ }
1161
+ }
1116
1162
  writeCache(STATE_CACHE, new Date(0).toISOString(), c.data, c.scope ?? null);
1117
1163
  } catch { /* the authoritative stores are already correct; refresh will replace an unreadable cache */ }
1118
1164
  }
@@ -1199,7 +1245,13 @@ function gatherLessons() {
1199
1245
  const trig = TRIGGER_BY_KEY.get(l.trigger);
1200
1246
  const meaning = ENFORCEMENT_MEANING[l.enforcement] || { label: l.enforcement, detail: '' };
1201
1247
  const userStated = l.origin === ORIGIN.USER_STATED && l.sourceClass === SOURCE_CLASS.CURRENT_USER;
1202
- const quarantined = l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION;
1248
+ // Quarantine is about whether history can BECOME policy, so it applies to an imported row that
1249
+ // was never ratified. A row that WAS ratified is delivered by lessonsFor() today whatever its
1250
+ // source class — RNBC QA 2026-10-01 measured 12 such rows filed here as "quarantined, cannot be
1251
+ // switched on" behind a checked, disabled box while the gate enforced every one of them. A rule in
1252
+ // force must be reported in force and must keep a working off switch.
1253
+ const importedClass = l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION;
1254
+ const quarantined = importedClass && l.status !== STATUS.RATIFIED && l.status !== STATUS.ACTIVE;
1203
1255
  const origin = l.sourceClass === SOURCE_CLASS.CURRENT_USER
1204
1256
  ? 'you taught me this'
1205
1257
  : l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER
@@ -1234,7 +1286,7 @@ function gatherLessons() {
1234
1286
  // Honest ceiling: ratifying a model-inferred lesson can NOT raise it to block
1235
1287
  // (lesson-store.mjs:380). Say so before they click, not after.
1236
1288
  canReachBlock: userStated,
1237
- canRatify: !quarantined,
1289
+ canRatify: !importedClass,
1238
1290
  intendedEnforcement: l.intendedEnforcement || null,
1239
1291
  };
1240
1292
  });
@@ -1969,8 +2021,12 @@ function gatherRouterEngine() {
1969
2021
  const cfg = readJSON(CONFIG_PATH) || {};
1970
2022
  // User-constraint detection (Brain-side by design — a fact about THIS user, not routing logic):
1971
2023
  // an OpenRouter key decides whether metered cross-provider candidates are even reachable.
1972
- let openrouterKey = !!process.env.OPENROUTER_API_KEY;
1973
- if (!openrouterKey) openrouterKey = !!(cfg.openrouterKey && String(cfg.openrouterKey).length > 8);
2024
+ // The SAME reader the Settings card uses (env → SOPS+age store → legacy plaintext). This read only
2025
+ // env + plaintext, so a key saved through Settings (encrypted, plaintext retired) showed "No key
2026
+ // added" here under a Settings row saying "•••• set" (RNBC QA 2026-10-01).
2027
+ let openrouterKey = false;
2028
+ try { openrouterKey = openRouterCredentialStatus({ cwd: process.cwd() }).configured === true; }
2029
+ catch { openrouterKey = !!process.env.OPENROUTER_API_KEY || !!(cfg.openrouterKey && String(cfg.openrouterKey).length > 8); }
1974
2030
  // House (issue #21): three mechanisms used to disagree — Settings wrote config.json's `provider`,
1975
2031
  // but the chip strip derived "yours" from whichever pool candidate happened to be
1976
2032
  // subscriptionCovered first, sourced from profile.json (a file nothing in the console writes). The
@@ -2383,7 +2439,7 @@ function gatherStack() {
2383
2439
  const rows = a.rows.map((r) => ({ name: r.name, installed: r.installed, target: r.target, tag: r.tag, state: r.state, source: r.source ?? 'npm-global', marketplace: r.marketplace ?? null }));
2384
2440
  const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(SYSTEM_HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
2385
2441
  const by = (st) => rows.filter((r) => r.state === st).length;
2386
- const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), shadows: shadows.length, stale: a.stale.length };
2442
+ const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), unverified: by('INSTALLED_UNVERIFIED'), shadows: shadows.length, stale: a.stale.length };
2387
2443
  const recommendations = buildStackRecommendations({ rows: a.rows, stale: a.stale });
2388
2444
  const result = { error: a.error, packages: rows, shadows, summary, recommendations };
2389
2445
  // Cache the last good audit so repeat page-loads render instantly ("as of HH:MM — re-checking").
@@ -2708,7 +2764,12 @@ function saveConfig(values) {
2708
2764
  credentialChange = saveOpenRouterCredential(requestedSecret, { cwd: process.cwd() });
2709
2765
  if (!credentialChange.ok) return { ok: false, rejected, log: credentialChange.log };
2710
2766
  }
2711
- if (requestedNightly !== undefined) {
2767
+ // The page sends every field the person has ever chosen, so changing only the model house carries the
2768
+ // already-saved nightly value with it. Re-running the installer for a choice the scheduler already
2769
+ // satisfies rewrote the plist and re-registered the runner on every unrelated save (RNBC review
2770
+ // 2026-10-01). Only a request that differs from the measured scheduler state is a scheduler change; a
2771
+ // degraded or unknown state still goes to the installer, which is how it gets repaired.
2772
+ if (requestedNightly !== undefined && nightlyStatus().state !== (requestedNightly ? 'on' : 'off')) {
2712
2773
  nightlyChange = applyNightlyChoice(requestedNightly);
2713
2774
  if (!nightlyChange.ok) {
2714
2775
  rollbackCredential();
@@ -2809,6 +2870,10 @@ function undo(undoToken) {
2809
2870
  if (!fs.existsSync(UNDO_JOURNAL)) return { ok: false, log: 'no undo history' };
2810
2871
  const journal = readUndoJournal();
2811
2872
  const entry = journal.find((e) => e.token === undoToken);
2873
+ // "Saved again after this point" is decided by POSITION in the append-only journal, never by the `at`
2874
+ // stamp: it has millisecond resolution and two saves do land in the same millisecond (a Linux runner,
2875
+ // 2026-10-01), which made the later save invisible and let a stale undo wipe it.
2876
+ const savedLater = (kind) => journal.slice(journal.indexOf(entry) + 1).some((e) => e.kind === kind && e.token && e.token !== undoToken);
2812
2877
  if (!entry) return { ok: false, log: 'that undo token was not found' };
2813
2878
 
2814
2879
  // ONE UNDO, ONCE. The token was never consumed, so the same button replayed forever: clicking it
@@ -2829,7 +2894,7 @@ function undo(undoToken) {
2829
2894
  // An undo can only speak for the last write. If something was written after it, the honest answer
2830
2895
  // is to refuse and say so — restoring anyway would be destroying newer data while claiming to
2831
2896
  // protect older data.
2832
- const laterSave = journal.some((e) => e.kind === 'restore-config' && e.at > entry.at && e.token !== undoToken);
2897
+ const laterSave = savedLater('restore-config');
2833
2898
  if (laterSave) {
2834
2899
  return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save, or restore a backup by hand.' };
2835
2900
  }
@@ -2869,6 +2934,19 @@ function undo(undoToken) {
2869
2934
  }
2870
2935
  return { ok: false, log: 'no backup available to restore' };
2871
2936
  }
2937
+ if (entry.kind === 'restore-user-settings') {
2938
+ // Same "an undo speaks only for the last write" rule as restore-config: a later save through this
2939
+ // form would be wiped out by restoring an older backup.
2940
+ const laterSave = savedLater('restore-user-settings');
2941
+ if (laterSave) {
2942
+ return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save.' };
2943
+ }
2944
+ const r = revertSettings({ file: entry.file, backup: entry.backup || undefined, existedBefore: entry.existedBefore });
2945
+ if (!r.ok) return { ok: false, log: r.log };
2946
+ markUndoConsumed(undoToken);
2947
+ publishSettingsToCache();
2948
+ return { ok: true, log: entry.backup ? 'restored your previous settings' : 'removed the settings file (there was none before this save)' };
2949
+ }
2872
2950
  // EVERY branch below marks its token consumed on success, for the reason spelled out on the
2873
2951
  // restore-config branch above: these all copy a saved snapshot over a live file, so replaying one
2874
2952
  // re-applies an old state over whatever the user has done since. The replay guard at the top of
@@ -2953,7 +3031,7 @@ function undo(undoToken) {
2953
3031
  // The undo kinds this function actually implements. Exported so the closure test can check the
2954
3032
  // registry against the REAL handler set rather than a hand-copied list that would drift from it.
2955
3033
  export const HANDLED_UNDO_KINDS = Object.freeze([
2956
- 'restore-config', 'reinstall-version', 'restore-backup',
3034
+ 'restore-config', 'restore-user-settings', 'reinstall-version', 'restore-backup',
2957
3035
  'restore-memory-backup', 'restore-store-backups', 'restore-project-distill', 'auto-rebuild', 'none',
2958
3036
  ]);
2959
3037
 
@@ -2962,7 +3040,10 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; cha
2962
3040
  function serveStatic(req, res) {
2963
3041
  const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '') || 'index.html';
2964
3042
  const file = path.join(CONSOLE_DIR, rel);
2965
- if (!file.startsWith(CONSOLE_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
3043
+ // Containment by path, not by string prefix: `startsWith(CONSOLE_DIR)` also accepted a sibling such
3044
+ // as `<root>/console-anything/…` reached with an encoded `..` (RNBC review 2026-10-01).
3045
+ const inside = path.relative(CONSOLE_DIR, file);
3046
+ if (!inside || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
2966
3047
  let body = fs.readFileSync(file);
2967
3048
  const ext = path.extname(file);
2968
3049
  if (ext === '.html') body = Buffer.from(String(body).replace('</head>', `<script>window.__CONSOLE_TOKEN__=${JSON.stringify(TOKEN)}</script></head>`));
@@ -3444,6 +3525,8 @@ export {
3444
3525
  saveBrainPower,
3445
3526
  gatherBrainProfile,
3446
3527
  saveBrainProfile,
3528
+ setLesson,
3529
+ gatherLessons,
3447
3530
  gatherRouterEngine,
3448
3531
  autoEligibleIds,
3449
3532
  gatherConfig,
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/oracle/abstain-threshold-sweep.mjs — what would moving the abstain threshold do?
4
+ *
5
+ * The product abstains when the top cross-encoder logit is below 0. The threshold is applied AFTER
6
+ * scoring, so replaying a measured run at another threshold t is exact for the top citation: no
7
+ * model is re-run. For each t it reports, with 95% Wilson intervals:
8
+ * (abstained = no citation, or a numeric top logit < t -- the rule both harnesses apply)
9
+ * needs confident hits (gold or pre-registered alternative within 5 AND top logit >= t),
10
+ * confident misses (top logit >= t but neither within 5), their precision, and how
11
+ * many confident answers at least cite the gold repository first
12
+ * offTopic adversarial questions still abstained (no citation, or top logit < t)
13
+ * heldOut routed passes of the named/described/scenario strata (grounded AND routed AND
14
+ * a citation with logit >= t), the gated eval-brain metric
15
+ *
16
+ * node scripts/oracle/abstain-threshold-sweep.mjs --needs <needs.json> --adversarial <adversarial.json>
17
+ * --heldout <heldout.json> [--thresholds 0,-1,-2,-3,-4] [--out <file>]
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { wilson } from '../eval-brain.mjs';
23
+
24
+ const rate = (k, n) => { const w = wilson(k, n); return { k, n, p: +w.p.toFixed(4), lo: +w.lo.toFixed(4), hi: +w.hi.toFixed(4) }; };
25
+ const within5 = (r) => (r.fileRank != null && r.fileRank <= 5) || (r.altFileRank != null && r.altFileRank <= 5);
26
+
27
+ export function sweep({ needs = [], adversarial = [], heldout = [] }, thresholds) {
28
+ return thresholds.map((t) => {
29
+ // The abstain rule both harnesses apply (measure-need-set scoreRow, eval-brain gradeQuestion): no
30
+ // citation, or a NUMERIC top logit below the threshold. A citation without a logit (a card answer)
31
+ // is not an abstention.
32
+ const answered = (cited, ce) => Boolean(cited) && !(typeof ce === 'number' && ce < t);
33
+ const confident = needs.filter((r) => answered(r.cited, r.topCe));
34
+ const hits = confident.filter(within5).length;
35
+ const routedRows = heldout.filter((r) => ['named', 'described', 'scenario'].includes(r.stratum));
36
+ return {
37
+ threshold: t,
38
+ needs: { confidentHit: rate(hits, needs.length), confidentMiss: rate(confident.length - hits, needs.length),
39
+ precision: rate(hits, confident.length),
40
+ // Weaker than a file hit: the top citation is at least from the gold repository.
41
+ confidentRightRepo: rate(confident.filter((r) => r.repoRank === 1).length, confident.length) },
42
+ offTopicAbstain: rate(adversarial.filter((r) => !answered(r.citedPath, r.ce)).length, adversarial.length),
43
+ heldOutRouted: rate(routedRows.filter((r) => r.grounded && r.routed && answered(r.citedPath, r.ce)).length, routedRows.length),
44
+ };
45
+ });
46
+ }
47
+
48
+ async function main() {
49
+ const args = process.argv.slice(2);
50
+ const arg = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
51
+ const read = (f) => (f ? JSON.parse(fs.readFileSync(f, 'utf8')).rows : []);
52
+ const thresholds = String(arg('--thresholds') || '0,-0.5,-1,-1.5,-2,-3,-4').split(',').map(Number);
53
+ const out = { kind: 'ruvnet-brain-abstain-threshold-sweep',
54
+ rows: sweep({ needs: read(arg('--needs')), adversarial: read(arg('--adversarial')), heldout: read(arg('--heldout')) }, thresholds) };
55
+ if (arg('--out')) fs.writeFileSync(arg('--out'), `${JSON.stringify(out, null, 1)}\n`);
56
+ const f = (m) => `${m.k}/${m.n} [${(100 * m.lo).toFixed(1)}-${(100 * m.hi).toFixed(1)}]`;
57
+ for (const r of out.rows) {
58
+ console.log(`t=${r.threshold}: needs hit ${f(r.needs.confidentHit)} miss ${f(r.needs.confidentMiss)} precision ${f(r.needs.precision)} right-repo ${f(r.needs.confidentRightRepo)} | off-topic abstain ${f(r.offTopicAbstain)} | held-out routed ${f(r.heldOutRouted)}`);
59
+ }
60
+ }
61
+
62
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();