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.
- package/README.md +3 -3
- package/bin/install.mjs +679 -121
- package/console/app.js +178 -81
- package/console/index.html +1 -1
- package/console/install-architecture.html +1 -0
- package/console/scope.css +4 -1
- package/console/style.css +13 -0
- package/console/tips.html +4 -4
- package/kb/brain-profile.mjs +1 -0
- package/kb/corpus-release-identity.mjs +1 -1
- package/kb/forge-update.mjs +41 -17
- package/kb/model-requirements.mjs +4 -1
- package/kb/update-storage-transaction.mjs +79 -0
- package/kb/zip-extract.mjs +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +5 -4
- package/plugin/commands/configure.md +5 -4
- package/plugin/commands/rnb-brief.md +41 -0
- package/plugin/commands/rnb.md +80 -0
- package/plugin/commands/rnbc.md +80 -0
- package/plugin/commands/rvbc.md +5 -4
- package/plugin/commands/rvcb.md +5 -4
- package/plugin/commands/whats-new.md +4 -4
- package/plugin/mcp/server.mjs +10 -2
- package/plugin/scripts/advocacy-route.mjs +59 -23
- package/plugin/scripts/anticipate.sh +4 -0
- package/plugin/scripts/brain-confirmation.mjs +258 -0
- package/plugin/scripts/brain-footprint.mjs +494 -0
- package/plugin/scripts/brain-location.mjs +47 -0
- package/plugin/scripts/capability-registry.mjs +11 -1
- package/plugin/scripts/continuity-brief.mjs +324 -0
- package/plugin/scripts/continuity-events.mjs +327 -0
- package/plugin/scripts/continuity-journal.mjs +500 -0
- package/plugin/scripts/decision-gate.mjs +56 -3
- package/plugin/scripts/footprint-io.mjs +186 -0
- package/plugin/scripts/ground-before-write.sh +8 -1
- package/plugin/scripts/ground-ruvnet.sh +103 -12
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-substance.mjs +1 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
- package/plugin/scripts/hook-input.mjs +78 -4
- package/plugin/scripts/kb-copy-proof.mjs +148 -0
- package/plugin/scripts/lesson-bridge.mjs +6 -2
- package/plugin/scripts/nightly-controller.mjs +8 -1
- package/plugin/scripts/node-sqlite.mjs +41 -0
- package/plugin/scripts/package-cards.json +797 -0
- package/plugin/scripts/package-cards.rvf +0 -0
- package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
- package/plugin/scripts/package-cards.rvf.meta.json +1 -0
- package/plugin/scripts/package-recommender-client.mjs +138 -0
- package/plugin/scripts/package-recommender-flag.mjs +30 -0
- package/plugin/scripts/package-recommender.mjs +391 -0
- package/plugin/scripts/project-progression-outbox.mjs +26 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -2
- package/plugin/scripts/project-progression-store.mjs +153 -6
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +225 -41
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +34 -4
- package/plugin/scripts/session-start-health.mjs +7 -1
- package/plugin/scripts/session-start-update-plane.mjs +35 -0
- package/plugin/scripts/turn-outcome-capture.mjs +12 -1
- package/plugin/scripts/unprompted-runtime.mjs +2 -2
- package/plugin/skills/brain-console/SKILL.md +3 -3
- package/plugin/skills/rnbc/SKILL.md +24 -0
- package/plugin/skills/rvbc/SKILL.md +2 -2
- package/scripts/approved-runtime.mjs +2 -2
- package/scripts/ci/warm-brain-models.mjs +28 -0
- package/scripts/codex-hook-trust.mjs +94 -0
- package/scripts/console-instances.mjs +70 -12
- package/scripts/console-runtime-identity.mjs +5 -0
- package/scripts/corpus-canary.mjs +46 -6
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +1 -1
- package/scripts/full-suite-gate.mjs +9 -2
- package/scripts/hook-qualify-hosts.mjs +15 -3
- package/scripts/host-install-matrix.mjs +63 -2
- package/scripts/human-approval-phrases.mjs +46 -0
- package/scripts/installed-brain-health.mjs +53 -0
- package/scripts/move-brain.mjs +310 -0
- package/scripts/onboarding-console.mjs +93 -10
- package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
- package/scripts/oracle/abstain-trace.mjs +139 -0
- package/scripts/oracle/doc2query-generate.mjs +162 -0
- package/scripts/oracle/doc2query-reach.mjs +110 -0
- package/scripts/oracle/judge-train.mjs +158 -0
- package/scripts/oracle/need-set-split.mjs +48 -0
- package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
- package/scripts/package-cards.mjs +374 -0
- package/scripts/publication-receipt.mjs +37 -9
- package/scripts/recommendation-e2e.mjs +110 -0
- package/scripts/recommendation-eval.mjs +105 -0
- package/scripts/recommendation-floor.mjs +56 -0
- package/scripts/recommendation-judge-score.mjs +74 -0
- package/scripts/recommendation-latency.mjs +95 -0
- package/scripts/recommendation-real-host-score.mjs +76 -0
- package/scripts/recommendation-real-host.mjs +137 -0
- package/scripts/release-channel-kind.mjs +1 -1
- package/scripts/release-environment-policy.mjs +33 -0
- package/scripts/single-source-check.mjs +15 -10
- package/scripts/sync-commands.mjs +5 -2
- 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
|
-
|
|
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: !
|
|
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
|
-
|
|
1973
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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();
|