ruvnet-brain 4.0.36 β 4.0.90-dev
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 +4 -4
- package/bin/install.mjs +283 -23
- package/data/model-catalog.json +104 -15
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +72 -9
- package/plugin/commands/configure.md +67 -21
- package/plugin/commands/rvcb.md +72 -9
- package/plugin/hooks/codex-hooks.json +40 -33
- package/plugin/hooks/hook-contracts.json +14 -24
- package/plugin/hooks/hooks.json +7 -42
- package/plugin/mcp/server.mjs +23 -6
- package/plugin/scripts/adr-currency-gate.mjs +150 -0
- package/plugin/scripts/capability-registry.mjs +10 -1
- package/plugin/scripts/codex-hook-adapter.mjs +121 -19
- package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
- package/plugin/scripts/continuation-gate.mjs +148 -8
- package/plugin/scripts/decision-gate.mjs +428 -0
- package/plugin/scripts/decision-outcomes.mjs +0 -0
- package/plugin/scripts/degradation-watch.mjs +271 -0
- package/plugin/scripts/ground-ruvnet.sh +51 -11
- package/plugin/scripts/hijack-ruvnet.sh +11 -3
- package/plugin/scripts/hook-registry.mjs +48 -3
- package/plugin/scripts/hook-shim.mjs +58 -3
- package/plugin/scripts/identifier-preflight.mjs +134 -0
- package/plugin/scripts/learn-capture.sh +50 -1
- package/plugin/scripts/learn-flush.mjs +5 -5
- package/plugin/scripts/lesson-bridge.mjs +343 -0
- package/plugin/scripts/lesson-hooks.sh +26 -0
- package/plugin/scripts/lesson-promote.mjs +50 -0
- package/plugin/scripts/lesson-store.mjs +6 -1
- package/plugin/scripts/mcp-readiness.mjs +107 -0
- package/plugin/scripts/protect-brain-state.sh +9 -0
- package/plugin/scripts/runtime-preferences.mjs +40 -0
- package/plugin/scripts/session-snapshot-hook.mjs +15 -6
- package/plugin/scripts/spend-guard.mjs +125 -0
- package/plugin/scripts/unprompted-runtime.mjs +12 -2
- package/plugin/scripts/update-apply.mjs +7 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
- package/scripts/brain-score.mjs +252 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +25 -1
- package/scripts/console-engine.mjs +1 -1
- package/scripts/health-repair.mjs +11 -2
- package/scripts/ingest-repo.mjs +66 -6
- package/scripts/learning-replay-cli.mjs +8 -3
- package/scripts/learning-replay-fixture.mjs +25 -6
- package/scripts/learning-replay-proof.mjs +38 -0
- package/scripts/nightly-wrapper.sh +13 -0
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/selfcheck.mjs +9 -1
- package/scripts/stabilization-receipt.mjs +11 -1
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-commands.mjs +117 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# π§ RuvNet Brain
|
|
6
6
|
|
|
7
|
-
### π§ RuvNet Brain β [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
8
8
|
|
|
9
9
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
10
10
|
|
|
@@ -445,7 +445,7 @@ The brain answers **both** kinds of questions. **Name the repo or ask something
|
|
|
445
445
|
|
|
446
446
|
## What it covers
|
|
447
447
|
|
|
448
|
-
|
|
448
|
+
77 of rUv's repos in the [ruvnet](https://github.com/ruvnet) org β the reusable **building blocks** you'd actually compose into a system β each deep-walked into its own canonical RVF segment. The core blocks below also carry symbol indexes and capability cards (the 8 newest repos are findable by name; their capability cards are coming).
|
|
449
449
|
|
|
450
450
|

|
|
451
451
|
|
|
@@ -504,7 +504,7 @@ Most of the numbers above were tuned against. [`evals/held-out.json`](evals/held
|
|
|
504
504
|
|
|
505
505
|
**Why not let an LLM grade it?** Because on this very repo an LLM panel scored a **zero-citation answer 98/100**. Model-as-judge is blind to the one failure that matters here.
|
|
506
506
|
|
|
507
|
-
|
|
507
|
+
Baseline **measured 2026-07-10** (n=120, [`evals/baseline.json`](evals/baseline.json)): **grounded 100/100 Β· routed 63/80 Β· abstain 18/20 Β· banner 20/20**, promotion gated on Wilson lower bounds. The routing misses are recorded, not tuned away β the recurring shapes: `"generate tests and find coverage gaps"` cites agentic-qe's real test generator, which lives *vendored inside the ruflo repo*, so the answer is right and the repo label is a corpus-attribution artifact; `"spend less money on model calls"` cites a genuine `agentic-qe/docs/guides/cheaper-model-eval-lanes.md`, the same cost/orchestration overlap noted above.
|
|
508
508
|
|
|
509
509
|
**Query it directly (CLI):**
|
|
510
510
|
|
|
@@ -522,7 +522,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
|
|
|
522
522
|
|
|
523
523
|
This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β we don't claim βdone,β βcomplete,β or βzero hallucinations.β Where it stands:
|
|
524
524
|
|
|
525
|
-
- β
**The grounding brain is real and proven** β
|
|
525
|
+
- β
**The grounding brain is real and proven** β 70 public stores Β· 140,389 public source chunks (77 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
|
|
526
526
|
- β
**Code-level depth** β the code-rich repos are indexed to full function bodies; βhow is it implemented?β returns the implementation. Verified in the shipped bundle (clean-room 3/3).
|
|
527
527
|
- β
**Routing holds** β named 47/48, described 26/28, scenario 7/8; behavioral L1βL3 all pass (**L4 downgraded β it measures that the brain spoke, not that anything listened**); private stores fenced out of the public bundle (zero-leak verified).
|
|
528
528
|
- β οΈ **Two routing residuals** (above) β surfaced, not hidden.
|
package/bin/install.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import fs from 'node:fs';
|
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { spawn, spawnSync } from 'node:child_process';
|
|
19
|
+
import { readAll as readAllReadiness, aggregate as aggregateReadiness } from '../plugin/scripts/mcp-readiness.mjs';
|
|
19
20
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
21
|
import readline from 'node:readline';
|
|
21
22
|
import crypto from 'node:crypto';
|
|
@@ -545,6 +546,126 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
545
546
|
ok(`brain unpacked to ${cacheDir}`);
|
|
546
547
|
}
|
|
547
548
|
|
|
549
|
+
/**
|
|
550
|
+
* ISSUE #128 β STALE PLUGIN GENERATIONS ARE STILL DISCOVERED.
|
|
551
|
+
*
|
|
552
|
+
* `pruneUnlistedStores` below states the rule this repo already believes: "an update is a
|
|
553
|
+
* replacement, not an overlay." That rule was applied to KB stores and never to the plugin cache.
|
|
554
|
+
*
|
|
555
|
+
* Measured on the maintainer's machine 2026-08-10: the registry names ONE generation and the cache
|
|
556
|
+
* held TWENTY-EIGHT, 22 MB, each a complete generation with its own `skills/` and `hooks/`. Claude
|
|
557
|
+
* Code resolves skills by walking that directory rather than by honouring `installPath`, so old
|
|
558
|
+
* generations keep answering. One session announced three different versions, none of them the one
|
|
559
|
+
* executing, and the stale block's mandatory phrasing β "must say ONE version, never a second
|
|
560
|
+
* number" β made the assistant state the wrong version authoritatively in its first sentence.
|
|
561
|
+
*
|
|
562
|
+
* Worse than cosmetic: the three blocks CONTRADICT each other inside one session (one says do not
|
|
563
|
+
* restart, one says an update is landing, one mandates a third version), so whichever the model
|
|
564
|
+
* obeys, the user is misinformed by a source that sounds definitive.
|
|
565
|
+
*
|
|
566
|
+
* WHAT MAKES DELETION SAFE HERE, since this removes directories from someone's machine:
|
|
567
|
+
* β’ The registry is the ONLY authority. Unreadable, missing, or naming no ruvnet-brain install β
|
|
568
|
+
* nothing is removed and a reason is returned. Never guess what to delete β the same discipline
|
|
569
|
+
* as pruneUnlistedStores' catch, for a far larger blast radius.
|
|
570
|
+
* β’ Only siblings of a registered installPath are considered, so the scan cannot wander.
|
|
571
|
+
* β’ A candidate must LOOK like a generation (its own .claude-plugin/plugin.json). Anything else is
|
|
572
|
+
* someone else's data and is left alone.
|
|
573
|
+
* β’ Symlinks are never followed or removed: scripts/dev-plugin-link.sh links a dev checkout in
|
|
574
|
+
* here, and deleting through that link would delete the working tree.
|
|
575
|
+
* β’ EVERY registered installPath is protected, not just the first β the plugin may legitimately be
|
|
576
|
+
* installed at both user and project scope, and treating one as stale would delete a live install.
|
|
577
|
+
* β’ The active generation is excluded by RESOLVED PATH, never by version string.
|
|
578
|
+
* β’ Report-only unless `apply` is set, and every removal is returned by name, so a silent sweep is
|
|
579
|
+
* impossible.
|
|
580
|
+
*/
|
|
581
|
+
export function prunePluginGenerations({
|
|
582
|
+
registryPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'),
|
|
583
|
+
apply = false,
|
|
584
|
+
} = {}) {
|
|
585
|
+
const nothing = (why) => ({ active: [], stale: [], removed: [], bytes: 0, why });
|
|
586
|
+
let registry;
|
|
587
|
+
try { registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch (e) {
|
|
588
|
+
return nothing(`plugin registry unreadable (${e.code || e.message}) β refusing to remove anything`);
|
|
589
|
+
}
|
|
590
|
+
const active = Object.entries(registry?.plugins || {})
|
|
591
|
+
.filter(([name]) => /^ruvnet-brain@/.test(name))
|
|
592
|
+
.flatMap(([, v]) => (Array.isArray(v) ? v : []))
|
|
593
|
+
.map((e) => e?.installPath)
|
|
594
|
+
.filter((p) => typeof p === 'string' && p);
|
|
595
|
+
if (!active.length) return nothing('no ruvnet-brain install is registered β refusing to remove anything');
|
|
596
|
+
|
|
597
|
+
const keep = new Set(active.map((p) => path.resolve(p)));
|
|
598
|
+
const stale = [];
|
|
599
|
+
for (const parent of new Set(active.map((p) => path.dirname(path.resolve(p))))) {
|
|
600
|
+
let names = [];
|
|
601
|
+
try { names = fs.readdirSync(parent); } catch { continue; }
|
|
602
|
+
for (const name of names) {
|
|
603
|
+
const full = path.resolve(parent, name);
|
|
604
|
+
if (keep.has(full)) continue;
|
|
605
|
+
let st;
|
|
606
|
+
try { st = fs.lstatSync(full); } catch { continue; }
|
|
607
|
+
if (st.isSymbolicLink() || !st.isDirectory()) continue;
|
|
608
|
+
if (!fs.existsSync(path.join(full, '.claude-plugin', 'plugin.json'))) continue;
|
|
609
|
+
stale.push({ path: full, version: name, bytes: dirBytes(full) });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const bytes = stale.reduce((n, s) => n + s.bytes, 0);
|
|
613
|
+
if (!apply) return { active, stale, removed: [], bytes, why: null };
|
|
614
|
+
|
|
615
|
+
const removed = [];
|
|
616
|
+
let freed = 0;
|
|
617
|
+
for (const s of stale) {
|
|
618
|
+
// One unremovable generation (a permissions quirk, a file held open) must not abort the rest.
|
|
619
|
+
try { fs.rmSync(s.path, { recursive: true, force: true }); removed.push(s); freed += s.bytes; }
|
|
620
|
+
catch { /* left in place; it is still reported in `stale` */ }
|
|
621
|
+
}
|
|
622
|
+
return { active, stale, removed, bytes: freed, why: null };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Size of a directory tree, best-effort β used only to report what was or would be freed. */
|
|
626
|
+
function dirBytes(dir) {
|
|
627
|
+
let total = 0;
|
|
628
|
+
const walk = (d) => {
|
|
629
|
+
let items = [];
|
|
630
|
+
try { items = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
631
|
+
for (const it of items) {
|
|
632
|
+
const p = path.join(d, it.name);
|
|
633
|
+
if (it.isSymbolicLink()) continue;
|
|
634
|
+
if (it.isDirectory()) { walk(p); continue; }
|
|
635
|
+
try { total += fs.statSync(p).size; } catch { /* vanished mid-walk */ }
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
walk(dir);
|
|
639
|
+
return total;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Every relative module `server.mjs` needs, transitively β read from the source, never restated.
|
|
645
|
+
*
|
|
646
|
+
* Bounded and cycle-safe. Returns `{ spec, from }` with the spec EXACTLY as written, because the
|
|
647
|
+
* copy target is resolved against the destination using that same spec: preserving the specifier is
|
|
648
|
+
* what makes `./sibling.mjs` and `../scripts/sibling.mjs` both land where the server will look.
|
|
649
|
+
*/
|
|
650
|
+
export function serverDependencies(source, seen = new Set()) {
|
|
651
|
+
const out = [];
|
|
652
|
+
const walk = (file, specPrefix) => {
|
|
653
|
+
if (seen.has(file)) return;
|
|
654
|
+
seen.add(file);
|
|
655
|
+
let src = '';
|
|
656
|
+
try { src = fs.readFileSync(file, 'utf8'); } catch { return; }
|
|
657
|
+
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*from\s*['"](\.[^'"]+)['"]/gm)) {
|
|
658
|
+
const spec = specPrefix ? path.join(specPrefix, m[1]) : m[1];
|
|
659
|
+
const from = path.resolve(path.dirname(file), m[1]);
|
|
660
|
+
if (out.some((d) => d.from === from)) continue;
|
|
661
|
+
out.push({ spec: spec.startsWith('.') ? spec : `./${spec}`, from });
|
|
662
|
+
walk(from, path.dirname(spec));
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
walk(source, '');
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
|
|
548
669
|
export function pruneUnlistedStores(cacheDir) {
|
|
549
670
|
try {
|
|
550
671
|
const manifest = JSON.parse(fs.readFileSync(path.join(cacheDir, 'manifest.json'), 'utf8'));
|
|
@@ -1002,7 +1123,23 @@ export function codexStatus({
|
|
|
1002
1123
|
const section = m ? text.slice(m.index).split(/\n[ \t]*\[/, 1)[0] : '';
|
|
1003
1124
|
const timeoutMatch = /(?:^|\n)[ \t]*startup_timeout_sec\s*=\s*(\d+)/.exec(section);
|
|
1004
1125
|
const startupTimeoutSec = timeoutMatch ? Number(timeoutMatch[1]) : null;
|
|
1126
|
+
// ISSUE #133: read the DERIVED machine state, not one contested file. Every MCP shell used to
|
|
1127
|
+
// overwrite `mcp-readiness.json`, so this read the state of whichever process wrote last and
|
|
1128
|
+
// presented it as the state of the machine. Per-process records are aggregated (degraded wins over
|
|
1129
|
+
// ready β a machine with one broken shell HAS a broken shell) with dead pids pruned on read. The
|
|
1130
|
+
// legacy file is still the fallback, so a machine mid-update, whose server predates the split,
|
|
1131
|
+
// reports something true rather than nothing.
|
|
1005
1132
|
const readinessReceipt = (() => {
|
|
1133
|
+
try {
|
|
1134
|
+
const live = readAllReadiness(brainHome);
|
|
1135
|
+
if (live.length) {
|
|
1136
|
+
const agg = aggregateReadiness(live);
|
|
1137
|
+
// Carry the winning record forward so every downstream field (pid, workerPid, phase) still
|
|
1138
|
+
// describes ONE real process rather than a blend of several.
|
|
1139
|
+
const chosen = live.find((r) => r.state === agg.state) || live[0];
|
|
1140
|
+
return { ...chosen, ...agg, shells: live.length };
|
|
1141
|
+
}
|
|
1142
|
+
} catch { /* fall through to the legacy single file */ }
|
|
1006
1143
|
try { return JSON.parse(fs.readFileSync(path.join(brainHome, 'mcp-readiness.json'), 'utf8')); }
|
|
1007
1144
|
catch { return null; }
|
|
1008
1145
|
})();
|
|
@@ -1106,27 +1243,46 @@ export function wireCodexHost({
|
|
|
1106
1243
|
if (announce) warn('MCP server missing from this bundle β Codex left untouched (non-fatal)');
|
|
1107
1244
|
return { host: true, action: 'no-source' };
|
|
1108
1245
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1246
|
+
// THE DEPENDENCY LIST IS DERIVED FROM server.mjs ITSELF, never hand-maintained.
|
|
1247
|
+
//
|
|
1248
|
+
// It used to name `managed-cli-interface.mjs` and `runtime-preferences.mjs` as literals β a second
|
|
1249
|
+
// copy of the server's own import graph, and therefore a fact in two places with no producer. It
|
|
1250
|
+
// failed exactly the way that always fails: ADR-067 added ONE import to server.mjs, this list did
|
|
1251
|
+
// not know, and the Codex host shipped a server whose sibling was absent. The npm-tarball suite
|
|
1252
|
+
// caught it as "no reply to initialize in 15s" β a dead server, on the packaging boundary, from a
|
|
1253
|
+
// change that looked local.
|
|
1254
|
+
//
|
|
1255
|
+
// Relative specifiers are preserved verbatim on copy, so `./x.mjs` and `../scripts/y.mjs` both
|
|
1256
|
+
// land where the server will look for them, and the walk is transitive because a dependency's own
|
|
1257
|
+
// dependency is no less required.
|
|
1258
|
+
const deps = serverDependencies(source);
|
|
1259
|
+
const missing = deps.filter((d) => !fs.existsSync(d.from));
|
|
1260
|
+
if (missing.length) {
|
|
1261
|
+
if (announce) warn(`MCP server dependency missing from this bundle (${missing.map((d) => d.spec).join(', ')}) β Codex left untouched (non-fatal)`);
|
|
1116
1262
|
return { host: true, action: 'no-source' };
|
|
1117
1263
|
}
|
|
1118
1264
|
const serverPath = path.join(serverDir, 'server.mjs');
|
|
1119
|
-
|
|
1120
|
-
|
|
1265
|
+
// Named for the return contract that callers and tests already depend on β RESOLVED from the
|
|
1266
|
+
// derived list rather than re-stated, so they cannot drift from what is actually copied.
|
|
1267
|
+
const depTarget = (suffix) => {
|
|
1268
|
+
const hit = deps.find((d) => d.spec.endsWith(suffix));
|
|
1269
|
+
return hit ? path.resolve(serverDir, hit.spec) : null;
|
|
1270
|
+
};
|
|
1271
|
+
const managedCliPath = depTarget('managed-cli-interface.mjs');
|
|
1272
|
+
const runtimePreferencesPath = depTarget('runtime-preferences.mjs');
|
|
1121
1273
|
fs.mkdirSync(serverDir, { recursive: true });
|
|
1122
1274
|
// Write-beside-then-rename, both here and for the config below (issue #43): an interrupted plain
|
|
1123
1275
|
// copy leaves a TORN server.mjs at the exact path a prior install's config already points at, so
|
|
1124
1276
|
// Codex spawns half a file. rename() over the target is atomic; a failure leaves the old bytes.
|
|
1125
1277
|
// Copy the dependency first. If the later server swap fails, the previously registered server
|
|
1126
1278
|
// remains byte-intact and continues to import a backward-compatible module at the same path.
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1279
|
+
// Dependencies FIRST, server last: if the server swap fails, the previously registered server
|
|
1280
|
+
// remains byte-intact and continues to import backward-compatible modules at the same paths.
|
|
1281
|
+
for (const dep of deps) {
|
|
1282
|
+
const target = path.resolve(serverDir, dep.spec);
|
|
1283
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1284
|
+
atomicReplace(target, (tmp) => fs.copyFileSync(dep.from, tmp));
|
|
1285
|
+
}
|
|
1130
1286
|
atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
|
|
1131
1287
|
if (fs.existsSync(hookWrapperSource)) {
|
|
1132
1288
|
fs.mkdirSync(path.dirname(hookWrapperPath), { recursive: true });
|
|
@@ -2254,10 +2410,73 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
|
|
|
2254
2410
|
const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
|
|
2255
2411
|
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2256
2412
|
|
|
2257
|
-
|
|
2258
|
-
|
|
2413
|
+
/**
|
|
2414
|
+
* THE ONE SCHEDULED-UPDATE COMMAND (issue #129). Every scheduler β cron, systemd, LaunchAgent β
|
|
2415
|
+
* runs THIS, and it is byte-for-byte the entrypoint plugin/scripts/host-update.mjs already uses on
|
|
2416
|
+
* SessionStart.
|
|
2417
|
+
*
|
|
2418
|
+
* It used to be `forge-update.mjs --apply`, which advances the KB BYTES ONLY. That path never
|
|
2419
|
+
* reaches host convergence, so a scheduled run could move the corpus forward while the Stable Spine,
|
|
2420
|
+
* the Claude and Codex plugin payloads, the Console runtime and `host-convergence.json` all stayed
|
|
2421
|
+
* behind β silently, on a schedule, with the update log reporting success. A machine updating itself
|
|
2422
|
+
* into a split state overnight is worse than one that never updates, because nothing looks wrong.
|
|
2423
|
+
*
|
|
2424
|
+
* Two entrypoints for one job is the defect; naming it once here is the fix. 03:47 on purpose: an
|
|
2425
|
+
* off-hour minute, so it never piles onto the :00 cron rush.
|
|
2426
|
+
*/
|
|
2427
|
+
const NIGHTLY_ARGV = ['--yes', 'ruvnet-brain@latest', '--update', '--host-sync-only', '--no-nightly-prompt'];
|
|
2428
|
+
const nightlyCommand = () => `npx ${NIGHTLY_ARGV.join(' ')}`;
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Absolute path to npx, resolved at install time β a scheduler has no shell profile, so a bare name
|
|
2432
|
+
* would resolve only on installs whose npx happens to sit in launchd's minimal PATH. Falls back to
|
|
2433
|
+
* the bare name rather than refusing: a wrong-but-present command is fixable by the user, whereas a
|
|
2434
|
+
* refusal to install any schedule leaves them with nothing.
|
|
2435
|
+
*/
|
|
2436
|
+
function npxPath() {
|
|
2437
|
+
const local = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npx.cmd' : 'npx');
|
|
2438
|
+
if (fs.existsSync(local)) return local;
|
|
2439
|
+
try {
|
|
2440
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['npx'], { encoding: 'utf8' });
|
|
2441
|
+
const found = String(r.stdout || '').split('\n')[0].trim();
|
|
2442
|
+
if (found && fs.existsSync(found)) return found;
|
|
2443
|
+
} catch { /* not on PATH here either β fall through */ }
|
|
2444
|
+
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Add the nightly line to the user's crontab, idempotently (issue #129).
|
|
2448
|
+
*
|
|
2449
|
+
* Read-modify-write through `crontab -l` / `crontab -` because that is the only portable interface;
|
|
2450
|
+
* writing /var/spool/cron directly needs root and bypasses the daemon's reload. Every failure mode
|
|
2451
|
+
* returns a REASON rather than a boolean: "no crontab binary" and "the write was rejected" need
|
|
2452
|
+
* different things from the user, and collapsing them into "failed" is how a person ends up
|
|
2453
|
+
* re-running a command that can never work.
|
|
2454
|
+
*/
|
|
2455
|
+
export function installCronEntry(line, { run = spawnSync } = {}) {
|
|
2456
|
+
const marker = 'ruvnet-brain@latest';
|
|
2457
|
+
const list = run('crontab', ['-l'], { encoding: 'utf8' });
|
|
2458
|
+
if (list.error) return { ok: false, why: 'no crontab command was found on this system' };
|
|
2459
|
+
// Exit 1 with no output is the documented "this user has no crontab yet" case, not an error.
|
|
2460
|
+
const current = String(list.stdout || '');
|
|
2461
|
+
if (current.split('\n').some((l) => l.includes(marker) && !l.trim().startsWith('#'))) {
|
|
2462
|
+
return { ok: true, already: true };
|
|
2463
|
+
}
|
|
2464
|
+
const next = `${current.replace(/\n*$/, '')}\n${line}\n`.replace(/^\n+/, '');
|
|
2465
|
+
const write = run('crontab', ['-'], { input: next, encoding: 'utf8' });
|
|
2466
|
+
if (write.error) return { ok: false, why: `crontab could not be written (${write.error.message})` };
|
|
2467
|
+
if (write.status !== 0) {
|
|
2468
|
+
return { ok: false, why: `crontab rejected the entry (exit ${write.status})${String(write.stderr || '').trim() ? `: ${String(write.stderr).trim()}` : ''}` };
|
|
2469
|
+
}
|
|
2470
|
+
return { ok: true, already: false };
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/** launchd's own minimal PATH, plus the directory node/npx actually live in on this machine. */
|
|
2474
|
+
const launchdPath = () => [...new Set([
|
|
2475
|
+
path.dirname(process.execPath), path.dirname(npxPath()),
|
|
2476
|
+
'/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin',
|
|
2477
|
+
])].filter(Boolean).join(':');
|
|
2259
2478
|
const cronExample = (kbDir) =>
|
|
2260
|
-
`47 3 * * *
|
|
2479
|
+
`47 3 * * * ${nightlyCommand()} >> ${kbDir}/update.log 2>&1`;
|
|
2261
2480
|
|
|
2262
2481
|
/**
|
|
2263
2482
|
* kb/forge-update.mjs exit 11 = "the download completed but the bundle on disk did NOT change"
|
|
@@ -2344,6 +2563,23 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
2344
2563
|
const applied = runStableSpine(apply);
|
|
2345
2564
|
const okApplied = !applied.error && applied.status === 0;
|
|
2346
2565
|
if (okApplied) {
|
|
2566
|
+
// ISSUE #128 β prune here and nowhere else. This is the moment "an update is a replacement, not
|
|
2567
|
+
// an overlay" becomes true for the host: the new generation is registered and the Stable Spine
|
|
2568
|
+
// has applied, so every OTHER generation in that cache is unreachable by design and can only
|
|
2569
|
+
// mislead β its skills still load, its hooks still fire, and its version banner still speaks.
|
|
2570
|
+
// Making it a consequence of updating, rather than a command someone has to remember, is the
|
|
2571
|
+
// whole point; a cleanup that must be invoked is a cleanup that does not happen.
|
|
2572
|
+
// Best-effort and reported by name β it must never turn a good update into a failed one.
|
|
2573
|
+
try {
|
|
2574
|
+
const gens = prunePluginGenerations({ apply: true });
|
|
2575
|
+
if (gens.removed.length) {
|
|
2576
|
+
ok(`pruned ${gens.removed.length} stale plugin generation(s), freed ${(gens.bytes / 1048576).toFixed(1)} MB: ${gens.removed.map((g) => g.version).join(', ')}`);
|
|
2577
|
+
} else if (gens.why) {
|
|
2578
|
+
warn(`stale plugin generations were not pruned: ${gens.why}`);
|
|
2579
|
+
}
|
|
2580
|
+
} catch (e) {
|
|
2581
|
+
warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
|
|
2582
|
+
}
|
|
2347
2583
|
const receiptPath = path.join(brainHome, 'host-convergence.json');
|
|
2348
2584
|
try {
|
|
2349
2585
|
runtimeTransaction.activate();
|
|
@@ -2496,12 +2732,24 @@ function enableNightly() {
|
|
|
2496
2732
|
const kbDir = resolvedKbDir();
|
|
2497
2733
|
|
|
2498
2734
|
if (process.platform !== 'darwin') {
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2735
|
+
// ISSUE #129 β A COMMAND NAMED `--enable-nightly` MAY NOT EXIT 0 WITHOUT ENABLING ANYTHING.
|
|
2736
|
+
//
|
|
2737
|
+
// This printed a cron recipe and returned success. Every automated check of "is Evergreen on?"
|
|
2738
|
+
// β a provisioning script, a CI step, a user reading the exit code β was told yes on a machine
|
|
2739
|
+
// where no schedule existed. So it now INSTALLS the entry, and when it cannot, it says so and
|
|
2740
|
+
// exits non-zero rather than dressing a manual instruction up as a completed action.
|
|
2741
|
+
const line = cronExample(kbDir);
|
|
2742
|
+
const installed = installCronEntry(line);
|
|
2743
|
+
if (installed.ok) {
|
|
2744
|
+
ok(`installed a nightly crontab entry (03:47 local):\n\n ${c.bold(line)}\n`);
|
|
2745
|
+
info(`(${c.bold('crontab -l')} to see it, ${c.bold('crontab -e')} to remove it.)`);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
console.error(`\n${c.red('β nightly updates were NOT enabled:')} ${installed.why}`);
|
|
2749
|
+
info('\nAdd this line by hand and nightly updates will work exactly as they do on macOS:');
|
|
2750
|
+
console.log(`\n ${c.bold(line)}\n`);
|
|
2503
2751
|
info(`(${c.bold('crontab -e')}, paste the line, save. Remove the line to disable.)`);
|
|
2504
|
-
|
|
2752
|
+
process.exit(1); // the caller asked for a schedule and does not have one
|
|
2505
2753
|
}
|
|
2506
2754
|
|
|
2507
2755
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
@@ -2526,12 +2774,24 @@ function enableNightly() {
|
|
|
2526
2774
|
<dict>
|
|
2527
2775
|
<key>Label</key>
|
|
2528
2776
|
<string>${NIGHTLY_LABEL}</string>
|
|
2777
|
+
<!-- Issue #129: the SAME host-convergent entrypoint the session updater runs, not the KB-only
|
|
2778
|
+
forge-update.mjs it used to schedule. See NIGHTLY_ARGV. Still no `/bin/sh -c` (ADR-038) β
|
|
2779
|
+
this execs npx directly. -->
|
|
2529
2780
|
<key>ProgramArguments</key>
|
|
2530
2781
|
<array>
|
|
2531
|
-
<string>${xmlEscape(
|
|
2532
|
-
<string
|
|
2533
|
-
<string>--apply</string>
|
|
2782
|
+
<string>${xmlEscape(npxPath())}</string>
|
|
2783
|
+
${NIGHTLY_ARGV.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n')}
|
|
2534
2784
|
</array>
|
|
2785
|
+
<!-- launchd starts agents with a MINIMAL PATH (/usr/bin:/bin:/usr/sbin:/sbin) - it does not read
|
|
2786
|
+
the user's shell profile. npx resolved by bare name would therefore fail to launch on every
|
|
2787
|
+
Homebrew/nvm/Volta install, which is most of them, and the only symptom would be a nightly
|
|
2788
|
+
that silently never ran. The absolute path above is resolved at install time; PATH is also set
|
|
2789
|
+
so npx can find node and the package's own bin shims once it starts. -->
|
|
2790
|
+
<key>EnvironmentVariables</key>
|
|
2791
|
+
<dict>
|
|
2792
|
+
<key>PATH</key>
|
|
2793
|
+
<string>${xmlEscape(launchdPath())}</string>
|
|
2794
|
+
</dict>
|
|
2535
2795
|
<key>WorkingDirectory</key>
|
|
2536
2796
|
<string>${xmlEscape(kbDir)}</string>
|
|
2537
2797
|
<key>StandardOutPath</key>
|
package/data/model-catalog.json
CHANGED
|
@@ -18,17 +18,65 @@
|
|
|
18
18
|
"providers": {
|
|
19
19
|
"anthropic": {
|
|
20
20
|
"label": "Claude (Anthropic)",
|
|
21
|
-
"detect_env": [
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
"detect_env": [
|
|
22
|
+
"ANTHROPIC_API_KEY",
|
|
23
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
24
|
+
"CLAUDECODE"
|
|
25
|
+
],
|
|
26
|
+
"frontier": {
|
|
27
|
+
"model": "claude-fable-5",
|
|
28
|
+
"in": 10,
|
|
29
|
+
"out": 50,
|
|
30
|
+
"released": "2026-06-09",
|
|
31
|
+
"rank": "AA Intelligence #1 (60) Β· Arena #1 (1508 Elo)",
|
|
32
|
+
"source": "independent (AA + Arena)"
|
|
33
|
+
},
|
|
34
|
+
"mid": {
|
|
35
|
+
"model": "claude-sonnet-5",
|
|
36
|
+
"in": 2,
|
|
37
|
+
"out": 10,
|
|
38
|
+
"released": "2026-06-30",
|
|
39
|
+
"rank": "AA 53; intro price $2/$10 β $3/$15 after 2026-08-31",
|
|
40
|
+
"source": "independent (AA)"
|
|
41
|
+
},
|
|
42
|
+
"cheap": {
|
|
43
|
+
"model": "claude-haiku-4.5",
|
|
44
|
+
"in": 1,
|
|
45
|
+
"out": 5,
|
|
46
|
+
"released": "2025-10-15",
|
|
47
|
+
"rank": "cheap tier (AA index low; cost-cascade prefers a cross-provider value pick when an OpenRouter key is present)",
|
|
48
|
+
"source": "catalog"
|
|
49
|
+
}
|
|
25
50
|
},
|
|
26
51
|
"openai": {
|
|
27
52
|
"label": "ChatGPT (OpenAI)",
|
|
28
|
-
"detect_env": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
53
|
+
"detect_env": [
|
|
54
|
+
"OPENAI_API_KEY"
|
|
55
|
+
],
|
|
56
|
+
"frontier": {
|
|
57
|
+
"model": "openai/gpt-5.6-sol",
|
|
58
|
+
"in": 2.5,
|
|
59
|
+
"out": 15,
|
|
60
|
+
"released": "2026-07-09",
|
|
61
|
+
"rank": "AA Intelligence #2 (59) Β· AA Coding Index leader (80) Β· Terminal-Bench 2.1 SOTA Β· ~1/3 Fable 5's cost/task",
|
|
62
|
+
"source": "independent (AA)"
|
|
63
|
+
},
|
|
64
|
+
"mid": {
|
|
65
|
+
"model": "openai/gpt-5.6-terra",
|
|
66
|
+
"in": 2,
|
|
67
|
+
"out": 12,
|
|
68
|
+
"released": "2026-07-09",
|
|
69
|
+
"rank": "AA Coding 77.4 β beats prev-gen flagship GPT-5.5 (76.4) at lower live catalog pricing",
|
|
70
|
+
"source": "independent (AA)"
|
|
71
|
+
},
|
|
72
|
+
"cheap": {
|
|
73
|
+
"model": "openai/gpt-5.6-luna",
|
|
74
|
+
"in": 0.2,
|
|
75
|
+
"out": 1.2,
|
|
76
|
+
"released": "2026-07-09",
|
|
77
|
+
"rank": "AA 51 Β· Coding 74.6 β beats most last-gen mid-tier",
|
|
78
|
+
"source": "independent (AA)"
|
|
79
|
+
}
|
|
32
80
|
},
|
|
33
81
|
"codex": {
|
|
34
82
|
"label": "Codex (OpenAI)",
|
|
@@ -38,17 +86,58 @@
|
|
|
38
86
|
},
|
|
39
87
|
"google": {
|
|
40
88
|
"label": "Gemini (Google)",
|
|
41
|
-
"detect_env": [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
89
|
+
"detect_env": [
|
|
90
|
+
"GEMINI_API_KEY",
|
|
91
|
+
"GOOGLE_API_KEY"
|
|
92
|
+
],
|
|
93
|
+
"frontier": {
|
|
94
|
+
"model": "google/gemini-3.1-pro-preview",
|
|
95
|
+
"in": 2,
|
|
96
|
+
"out": 12,
|
|
97
|
+
"released": "2026-02-19",
|
|
98
|
+
"rank": "AA Intelligence 46 β BELOW the Anthropic/OpenAI/xAI frontier cluster (54β60); still preview-named. Honest: Gemini is not frontier-competitive on independent indices right now.",
|
|
99
|
+
"source": "independent (AA) β vendor's GPQA-D 94.3% / ARC-AGI-2 77.1% are Google's own, uncorroborated"
|
|
100
|
+
},
|
|
101
|
+
"mid": {
|
|
102
|
+
"model": "google/gemini-3.5-flash",
|
|
103
|
+
"in": 1.5,
|
|
104
|
+
"out": 9,
|
|
105
|
+
"released": "2026",
|
|
106
|
+
"rank": "AA 50 β out-scores Google's own 3.1 Pro on this index (real anomaly, not a typo)",
|
|
107
|
+
"source": "independent (AA)"
|
|
108
|
+
},
|
|
109
|
+
"cheap": {
|
|
110
|
+
"model": "google/gemini-3.1-flash-lite",
|
|
111
|
+
"in": 0.25,
|
|
112
|
+
"out": 1.5,
|
|
113
|
+
"released": "2026",
|
|
114
|
+
"rank": "budget",
|
|
115
|
+
"source": "catalog"
|
|
116
|
+
}
|
|
45
117
|
},
|
|
46
118
|
"xai": {
|
|
47
119
|
"label": "Grok (SpaceXAI)",
|
|
48
|
-
"detect_env": [
|
|
120
|
+
"detect_env": [
|
|
121
|
+
"XAI_API_KEY",
|
|
122
|
+
"GROK_API_KEY"
|
|
123
|
+
],
|
|
49
124
|
"note": "xAI's live lineup has no distinct budget SKU below Grok 4.3 as of 2026-07-15 (the 'grok-4.1-fast' I first wrote does not exist in the live catalog β the verify gate caught it). A Grok shop's cheap tasks route to Grok 4.3 or a cross-provider value pick.",
|
|
50
|
-
"frontier": {
|
|
51
|
-
|
|
125
|
+
"frontier": {
|
|
126
|
+
"model": "x-ai/grok-4.5",
|
|
127
|
+
"in": 2,
|
|
128
|
+
"out": 6,
|
|
129
|
+
"released": "2026-07-08",
|
|
130
|
+
"rank": "AA Intelligence 54 (#8) at ~1/3 Opus 4.7's blended price β the frontier VALUE standout; 'Opus-class, faster, cheaper' (Musk)",
|
|
131
|
+
"source": "independent (AA)"
|
|
132
|
+
},
|
|
133
|
+
"mid": {
|
|
134
|
+
"model": "x-ai/grok-4.3",
|
|
135
|
+
"in": 1.25,
|
|
136
|
+
"out": 2.5,
|
|
137
|
+
"released": "2026-04-30",
|
|
138
|
+
"rank": "AA 38 β also xAI's cheapest verified stable tier",
|
|
139
|
+
"source": "independent (AA)"
|
|
140
|
+
}
|
|
52
141
|
}
|
|
53
142
|
}
|
|
54
143
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.90-dev",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain β a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"substitution:check": "node scripts/no-silent-substitution.mjs",
|
|
39
39
|
"catalog:verify": "node scripts/verify-model-catalog.mjs",
|
|
40
40
|
"catalog:refresh": "node scripts/refresh-model-catalog.mjs",
|
|
41
|
+
"orgcount:refresh": "node scripts/org-repo-count.mjs --record",
|
|
41
42
|
"falsify": "node scripts/falsify.mjs",
|
|
42
43
|
"sbom": "npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-file sbom/ruvnet-brain.cdx.json --mc-type application --validate",
|
|
43
44
|
"learning:replay": "node scripts/learning-replay.mjs",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"description": "RuvNet brain transplant for Claude Code β grounds every RuvNet decision in real source across
|
|
4
|
-
"version": "4.0.
|
|
3
|
+
"description": "RuvNet brain transplant for Claude Code β grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.",
|
|
4
|
+
"version": "4.0.90-dev",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|