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