ruvnet-brain 4.0.36 → 4.2.2-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 +28 -5
- package/bin/install.mjs +405 -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 +231 -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 +261 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +38 -4
- package/scripts/card-from-source.mjs +114 -0
- package/scripts/console-engine.mjs +1 -1
- package/scripts/corpus-candidate.mjs +294 -0
- package/scripts/corpus-reconcile.mjs +273 -0
- package/scripts/corpus-seed-publish.mjs +110 -0
- package/scripts/health-repair.mjs +11 -2
- package/scripts/ingest-new-repos.mjs +122 -0
- 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 +55 -7
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/rebuild-gists-from-receipts.mjs +246 -0
- package/scripts/release-transaction.mjs +30 -2
- package/scripts/release.mjs +147 -0
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/rvf-generation.mjs +44 -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
|
|
|
@@ -56,7 +56,30 @@
|
|
|
56
56
|
|
|
57
57
|
---
|
|
58
58
|
|
|
59
|
-
## What's new in 4.
|
|
59
|
+
## What's new in 4.2 — it loads what rUv ships, without being asked
|
|
60
|
+
|
|
61
|
+
**The corpus stopped drifting behind the org.** Until 4.2 nothing ever ingested a new repo: the
|
|
62
|
+
nightly refreshed lessons, health and proofs and contained *zero* ingestion, so a repo entered the
|
|
63
|
+
brain only when a human typed the command. `brain-stamp.mjs` had been measuring that gap every
|
|
64
|
+
night, but nothing consumed it until the ingestion loop shipped.
|
|
65
|
+
|
|
66
|
+
- **187 stores, up from 69.** Everything rUv ships that has content, pulled in and kept level by
|
|
67
|
+
`scripts/ingest-new-repos.mjs` running nightly, newest-first. Empty repos (`size=0KB`) are skipped
|
|
68
|
+
rather than retried forever — a permanent nightly failure that is actually correct behaviour
|
|
69
|
+
trains you to ignore the failure line, which is how a real one would hide inside it.
|
|
70
|
+
- **174 capability cards, up from 39.** Ingesting a repo is not the same as making it reachable: a
|
|
71
|
+
store with no card is *dark* — valid bytes no by-description query can find. Cards are written
|
|
72
|
+
from each repo's **own** description and README, never from its name, and where there is no
|
|
73
|
+
grounded source text the store is **left dark and reported** rather than given an invented
|
|
74
|
+
sentence. 13 remain dark for exactly that reason.
|
|
75
|
+
- **The download barely grew** for 2.4x the corpus — most of rUv's repos are small, so the bulk
|
|
76
|
+
of the bundle was always the large ones. (No size is hand-typed here: a number typed onto a
|
|
77
|
+
public page rots the moment the next bundle is built, and this repo gates against exactly that.)
|
|
78
|
+
- **The nightly stopped discarding its own writes.** It hardcoded a node whose ABI did not match
|
|
79
|
+
agentdb's binding, so `lesson-bridge --apply` and `learning-replay` wrote into a silent
|
|
80
|
+
non-persistent fallback. It now resolves an ABI-matched interpreter and fails loudly instead.
|
|
81
|
+
|
|
82
|
+
## What's new in 4.2 — it anticipates, and it learns whether it was right
|
|
60
83
|
|
|
61
84
|
**Building toward L4/L5 (3.9.x, dev).** The mechanisms for the top two rungs of the proactivity
|
|
62
85
|
ladder are built and wired — but they are **not yet verified to 4.0's bar**, which requires all five
|
|
@@ -445,7 +468,7 @@ The brain answers **both** kinds of questions. **Name the repo or ask something
|
|
|
445
468
|
|
|
446
469
|
## What it covers
|
|
447
470
|
|
|
448
|
-
|
|
471
|
+
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
472
|
|
|
450
473
|

|
|
451
474
|
|
|
@@ -504,7 +527,7 @@ Most of the numbers above were tuned against. [`evals/held-out.json`](evals/held
|
|
|
504
527
|
|
|
505
528
|
**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
529
|
|
|
507
|
-
|
|
530
|
+
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
531
|
|
|
509
532
|
**Query it directly (CLI):**
|
|
510
533
|
|
|
@@ -522,7 +545,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
|
|
|
522
545
|
|
|
523
546
|
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
547
|
|
|
525
|
-
- ✅ **The grounding brain is real and proven** —
|
|
548
|
+
- ✅ **The grounding brain is real and proven** — 70 public stores · 140,520 public source chunks (77 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
|
|
526
549
|
- ✅ **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
550
|
- ✅ **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
551
|
- ⚠️ **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,250 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
545
546
|
ok(`brain unpacked to ${cacheDir}`);
|
|
546
547
|
}
|
|
547
548
|
|
|
549
|
+
/**
|
|
550
|
+
* ISSUE #128 / #153 — STALE PLUGIN GENERATIONS MAY STILL BACK LIVE SESSIONS.
|
|
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
|
+
* Registry reachability identifies candidates; it is not deletion proof. Claude freezes
|
|
567
|
+
* CLAUDE_PLUGIN_ROOT at session start, so an unregistered generation can remain live until that
|
|
568
|
+
* session exits. Modern roots expose `.in_use` PID-incarnation leases. Live or ambiguous leases
|
|
569
|
+
* retain the complete root; dead leases are collected. Legacy roots without the protocol receive
|
|
570
|
+
* a 14-day compatibility grace.
|
|
571
|
+
*
|
|
572
|
+
* WHAT MAKES DELETION SAFE HERE, since this removes directories from someone's machine:
|
|
573
|
+
* • The registry is the ONLY authority. Unreadable, missing, or naming no ruvnet-brain install →
|
|
574
|
+
* nothing is removed and a reason is returned. Never guess what to delete — the same discipline
|
|
575
|
+
* as pruneUnlistedStores' catch, for a far larger blast radius.
|
|
576
|
+
* • Only siblings of a registered installPath are considered, so the scan cannot wander.
|
|
577
|
+
* • A candidate must LOOK like a generation (its own .claude-plugin/plugin.json). Anything else is
|
|
578
|
+
* someone else's data and is left alone.
|
|
579
|
+
* • Symlinks are never followed or removed: scripts/dev-plugin-link.sh links a dev checkout in
|
|
580
|
+
* here, and deleting through that link would delete the working tree.
|
|
581
|
+
* • EVERY registered installPath is protected, not just the first — the plugin may legitimately be
|
|
582
|
+
* installed at both user and project scope, and treating one as stale would delete a live install.
|
|
583
|
+
* • Registered generations are excluded by RESOLVED PATH, never by version string.
|
|
584
|
+
* • The registry is re-read immediately before deletion; any byte change retains the candidate.
|
|
585
|
+
* • Malformed, unreadable, symlinked, live, or unverifiable lease state fails closed.
|
|
586
|
+
* • Report-only unless `apply` is set, and every removal is returned by name, so a silent sweep is
|
|
587
|
+
* impossible.
|
|
588
|
+
*/
|
|
589
|
+
export function prunePluginGenerations({
|
|
590
|
+
registryPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'),
|
|
591
|
+
apply = false,
|
|
592
|
+
now = () => Date.now(),
|
|
593
|
+
graceMs = 14 * 24 * 60 * 60 * 1000,
|
|
594
|
+
processIdentity = pluginProcessIdentity,
|
|
595
|
+
} = {}) {
|
|
596
|
+
const nothing = (why) => ({ active: [], stale: [], marked: [], removed: [], bytes: 0, staleBytes: 0, cleanupBlocked: [], why });
|
|
597
|
+
let registryRaw;
|
|
598
|
+
let registry;
|
|
599
|
+
try {
|
|
600
|
+
registryRaw = fs.readFileSync(registryPath, 'utf8');
|
|
601
|
+
registry = JSON.parse(registryRaw);
|
|
602
|
+
} catch (e) {
|
|
603
|
+
return nothing(`plugin registry unreadable (${e.code || e.message}) — refusing to remove anything`);
|
|
604
|
+
}
|
|
605
|
+
const active = Object.entries(registry?.plugins || {})
|
|
606
|
+
.filter(([name]) => /^ruvnet-brain@/.test(name))
|
|
607
|
+
.flatMap(([, v]) => (Array.isArray(v) ? v : []))
|
|
608
|
+
.map((e) => e?.installPath)
|
|
609
|
+
.filter((p) => typeof p === 'string' && p);
|
|
610
|
+
if (!active.length) return nothing('no ruvnet-brain install is registered — refusing to remove anything');
|
|
611
|
+
|
|
612
|
+
const keep = new Set(active.map((p) => path.resolve(p)));
|
|
613
|
+
const stale = [];
|
|
614
|
+
for (const parent of new Set(active.map((p) => path.dirname(path.resolve(p))))) {
|
|
615
|
+
let names = [];
|
|
616
|
+
try { names = fs.readdirSync(parent); } catch { continue; }
|
|
617
|
+
for (const name of names) {
|
|
618
|
+
const full = path.resolve(parent, name);
|
|
619
|
+
if (keep.has(full)) continue;
|
|
620
|
+
let st;
|
|
621
|
+
try { st = fs.lstatSync(full); } catch { continue; }
|
|
622
|
+
if (st.isSymbolicLink() || !st.isDirectory()) continue;
|
|
623
|
+
if (!fs.existsSync(path.join(full, '.claude-plugin', 'plugin.json'))) continue;
|
|
624
|
+
stale.push({ path: full, version: name, bytes: dirBytes(full) });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const staleBytes = stale.reduce((n, s) => n + s.bytes, 0);
|
|
628
|
+
if (!apply) return { active, stale, marked: [], removed: [], bytes: 0, staleBytes, cleanupBlocked: [], why: null };
|
|
629
|
+
|
|
630
|
+
const marked = [];
|
|
631
|
+
const removed = [];
|
|
632
|
+
const cleanupBlocked = [];
|
|
633
|
+
let freed = 0;
|
|
634
|
+
for (const candidate of stale) {
|
|
635
|
+
const marker = path.join(candidate.path, '.orphaned_at');
|
|
636
|
+
if (!fs.existsSync(marker)) {
|
|
637
|
+
const temporary = `${marker}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
|
638
|
+
try {
|
|
639
|
+
fs.writeFileSync(temporary, String(now()), { flag: 'wx', mode: 0o600 });
|
|
640
|
+
fs.renameSync(temporary, marker);
|
|
641
|
+
marked.push(candidate.version);
|
|
642
|
+
} catch {
|
|
643
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* retain on ambiguity */ }
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let orphanedAt;
|
|
648
|
+
try {
|
|
649
|
+
const markerStat = fs.lstatSync(marker);
|
|
650
|
+
if (!markerStat.isFile() || markerStat.isSymbolicLink()) {
|
|
651
|
+
cleanupBlocked.push({ version: candidate.version, reason: '.orphaned_at is not a trusted file' });
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
orphanedAt = Number(fs.readFileSync(marker, 'utf8').trim());
|
|
655
|
+
if (!Number.isFinite(orphanedAt) || orphanedAt <= 0) {
|
|
656
|
+
cleanupBlocked.push({ version: candidate.version, reason: '.orphaned_at is malformed' });
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
} catch {
|
|
660
|
+
cleanupBlocked.push({ version: candidate.version, reason: '.orphaned_at could not be inspected' });
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const leaseDir = path.join(candidate.path, '.in_use');
|
|
665
|
+
let leaseCapable = false;
|
|
666
|
+
let leaseNames = [];
|
|
667
|
+
try {
|
|
668
|
+
if (fs.existsSync(leaseDir)) {
|
|
669
|
+
const leaseDirStat = fs.lstatSync(leaseDir);
|
|
670
|
+
if (leaseDirStat.isSymbolicLink() || !leaseDirStat.isDirectory()) {
|
|
671
|
+
cleanupBlocked.push({ version: candidate.version, reason: '.in_use is not a trusted directory' });
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
leaseCapable = true;
|
|
675
|
+
leaseNames = fs.readdirSync(leaseDir);
|
|
676
|
+
}
|
|
677
|
+
} catch {
|
|
678
|
+
cleanupBlocked.push({ version: candidate.version, reason: '.in_use could not be inspected' });
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (!leaseCapable && now() - orphanedAt < graceMs) continue;
|
|
683
|
+
let safeToReap = true;
|
|
684
|
+
for (const leaseName of leaseNames) {
|
|
685
|
+
const leasePath = path.join(leaseDir, leaseName);
|
|
686
|
+
let lease;
|
|
687
|
+
try {
|
|
688
|
+
const leaseStat = fs.lstatSync(leasePath);
|
|
689
|
+
if (!leaseStat.isFile() || leaseStat.isSymbolicLink()) throw new Error('not a trusted file');
|
|
690
|
+
lease = JSON.parse(fs.readFileSync(leasePath, 'utf8'));
|
|
691
|
+
if (!Number.isSafeInteger(lease?.pid) || lease.pid <= 0 || typeof lease?.procStart !== 'string' || !lease.procStart.trim()) {
|
|
692
|
+
throw new Error('malformed');
|
|
693
|
+
}
|
|
694
|
+
} catch {
|
|
695
|
+
cleanupBlocked.push({ version: candidate.version, reason: `lease ${leaseName} is malformed or unreadable` });
|
|
696
|
+
safeToReap = false;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
const state = processIdentity(lease);
|
|
700
|
+
if (state !== 'dead') {
|
|
701
|
+
cleanupBlocked.push({ version: candidate.version, reason: `lease ${leaseName} is ${state === 'live' ? 'live' : 'ambiguous'}` });
|
|
702
|
+
safeToReap = false;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
try { fs.rmSync(leasePath); } catch {
|
|
706
|
+
cleanupBlocked.push({ version: candidate.version, reason: `dead lease ${leaseName} could not be removed` });
|
|
707
|
+
safeToReap = false;
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (!safeToReap) continue;
|
|
712
|
+
|
|
713
|
+
// The host registry can advance while the collector scans. Any byte change invalidates the
|
|
714
|
+
// original authority snapshot; retain every candidate rather than deleting on stale evidence.
|
|
715
|
+
let currentRaw;
|
|
716
|
+
try { currentRaw = fs.readFileSync(registryPath, 'utf8'); } catch { continue; }
|
|
717
|
+
if (currentRaw !== registryRaw) continue;
|
|
718
|
+
try {
|
|
719
|
+
fs.rmSync(candidate.path, { recursive: true });
|
|
720
|
+
removed.push(candidate.version);
|
|
721
|
+
freed += candidate.bytes;
|
|
722
|
+
} catch { /* retained for a later pass */ }
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
active, stale, marked, removed, bytes: freed, staleBytes, cleanupBlocked,
|
|
726
|
+
why: stale.length
|
|
727
|
+
? 'lease-proven orphan reconciliation; legacy generations receive a 14-day grace and live or ambiguous generations are retained'
|
|
728
|
+
: null,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Verify the exact PID incarnation recorded by a plugin generation lease. */
|
|
733
|
+
function pluginProcessIdentity({ pid, procStart }) {
|
|
734
|
+
try { process.kill(pid, 0); } catch (error) {
|
|
735
|
+
return error?.code === 'ESRCH' ? 'dead' : 'unknown';
|
|
736
|
+
}
|
|
737
|
+
const result = process.platform === 'win32'
|
|
738
|
+
? spawnSync('powershell.exe', [
|
|
739
|
+
'-NoProfile', '-NonInteractive', '-Command',
|
|
740
|
+
`$p=Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().ToString('ddd MMM dd HH:mm:ss yyyy',[Globalization.CultureInfo]::InvariantCulture)`,
|
|
741
|
+
], { encoding: 'utf8' })
|
|
742
|
+
: spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], {
|
|
743
|
+
encoding: 'utf8', env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' },
|
|
744
|
+
});
|
|
745
|
+
if (result.status !== 0 || !String(result.stdout || '').trim()) return 'unknown';
|
|
746
|
+
return String(result.stdout).trim() === procStart.trim() ? 'live' : 'dead';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Size of a directory tree, best-effort — used only to report what was or would be freed. */
|
|
750
|
+
function dirBytes(dir) {
|
|
751
|
+
let total = 0;
|
|
752
|
+
const walk = (d) => {
|
|
753
|
+
let items = [];
|
|
754
|
+
try { items = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
755
|
+
for (const it of items) {
|
|
756
|
+
const p = path.join(d, it.name);
|
|
757
|
+
if (it.isSymbolicLink()) continue;
|
|
758
|
+
if (it.isDirectory()) { walk(p); continue; }
|
|
759
|
+
try { total += fs.statSync(p).size; } catch { /* vanished mid-walk */ }
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
walk(dir);
|
|
763
|
+
return total;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Every relative module `server.mjs` needs, transitively — read from the source, never restated.
|
|
769
|
+
*
|
|
770
|
+
* Bounded and cycle-safe. Returns `{ spec, from }` with the spec EXACTLY as written, because the
|
|
771
|
+
* copy target is resolved against the destination using that same spec: preserving the specifier is
|
|
772
|
+
* what makes `./sibling.mjs` and `../scripts/sibling.mjs` both land where the server will look.
|
|
773
|
+
*/
|
|
774
|
+
export function serverDependencies(source, seen = new Set()) {
|
|
775
|
+
const out = [];
|
|
776
|
+
const walk = (file, specPrefix) => {
|
|
777
|
+
if (seen.has(file)) return;
|
|
778
|
+
seen.add(file);
|
|
779
|
+
let src = '';
|
|
780
|
+
try { src = fs.readFileSync(file, 'utf8'); } catch { return; }
|
|
781
|
+
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*from\s*['"](\.[^'"]+)['"]/gm)) {
|
|
782
|
+
const spec = specPrefix ? path.join(specPrefix, m[1]) : m[1];
|
|
783
|
+
const from = path.resolve(path.dirname(file), m[1]);
|
|
784
|
+
if (out.some((d) => d.from === from)) continue;
|
|
785
|
+
out.push({ spec: spec.startsWith('.') ? spec : `./${spec}`, from });
|
|
786
|
+
walk(from, path.dirname(spec));
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
walk(source, '');
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
|
|
548
793
|
export function pruneUnlistedStores(cacheDir) {
|
|
549
794
|
try {
|
|
550
795
|
const manifest = JSON.parse(fs.readFileSync(path.join(cacheDir, 'manifest.json'), 'utf8'));
|
|
@@ -1002,7 +1247,23 @@ export function codexStatus({
|
|
|
1002
1247
|
const section = m ? text.slice(m.index).split(/\n[ \t]*\[/, 1)[0] : '';
|
|
1003
1248
|
const timeoutMatch = /(?:^|\n)[ \t]*startup_timeout_sec\s*=\s*(\d+)/.exec(section);
|
|
1004
1249
|
const startupTimeoutSec = timeoutMatch ? Number(timeoutMatch[1]) : null;
|
|
1250
|
+
// ISSUE #133: read the DERIVED machine state, not one contested file. Every MCP shell used to
|
|
1251
|
+
// overwrite `mcp-readiness.json`, so this read the state of whichever process wrote last and
|
|
1252
|
+
// presented it as the state of the machine. Per-process records are aggregated (degraded wins over
|
|
1253
|
+
// ready — a machine with one broken shell HAS a broken shell) with dead pids pruned on read. The
|
|
1254
|
+
// legacy file is still the fallback, so a machine mid-update, whose server predates the split,
|
|
1255
|
+
// reports something true rather than nothing.
|
|
1005
1256
|
const readinessReceipt = (() => {
|
|
1257
|
+
try {
|
|
1258
|
+
const live = readAllReadiness(brainHome);
|
|
1259
|
+
if (live.length) {
|
|
1260
|
+
const agg = aggregateReadiness(live);
|
|
1261
|
+
// Carry the winning record forward so every downstream field (pid, workerPid, phase) still
|
|
1262
|
+
// describes ONE real process rather than a blend of several.
|
|
1263
|
+
const chosen = live.find((r) => r.state === agg.state) || live[0];
|
|
1264
|
+
return { ...chosen, ...agg, shells: live.length };
|
|
1265
|
+
}
|
|
1266
|
+
} catch { /* fall through to the legacy single file */ }
|
|
1006
1267
|
try { return JSON.parse(fs.readFileSync(path.join(brainHome, 'mcp-readiness.json'), 'utf8')); }
|
|
1007
1268
|
catch { return null; }
|
|
1008
1269
|
})();
|
|
@@ -1106,27 +1367,46 @@ export function wireCodexHost({
|
|
|
1106
1367
|
if (announce) warn('MCP server missing from this bundle — Codex left untouched (non-fatal)');
|
|
1107
1368
|
return { host: true, action: 'no-source' };
|
|
1108
1369
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1370
|
+
// THE DEPENDENCY LIST IS DERIVED FROM server.mjs ITSELF, never hand-maintained.
|
|
1371
|
+
//
|
|
1372
|
+
// It used to name `managed-cli-interface.mjs` and `runtime-preferences.mjs` as literals — a second
|
|
1373
|
+
// copy of the server's own import graph, and therefore a fact in two places with no producer. It
|
|
1374
|
+
// failed exactly the way that always fails: ADR-067 added ONE import to server.mjs, this list did
|
|
1375
|
+
// not know, and the Codex host shipped a server whose sibling was absent. The npm-tarball suite
|
|
1376
|
+
// caught it as "no reply to initialize in 15s" — a dead server, on the packaging boundary, from a
|
|
1377
|
+
// change that looked local.
|
|
1378
|
+
//
|
|
1379
|
+
// Relative specifiers are preserved verbatim on copy, so `./x.mjs` and `../scripts/y.mjs` both
|
|
1380
|
+
// land where the server will look for them, and the walk is transitive because a dependency's own
|
|
1381
|
+
// dependency is no less required.
|
|
1382
|
+
const deps = serverDependencies(source);
|
|
1383
|
+
const missing = deps.filter((d) => !fs.existsSync(d.from));
|
|
1384
|
+
if (missing.length) {
|
|
1385
|
+
if (announce) warn(`MCP server dependency missing from this bundle (${missing.map((d) => d.spec).join(', ')}) — Codex left untouched (non-fatal)`);
|
|
1116
1386
|
return { host: true, action: 'no-source' };
|
|
1117
1387
|
}
|
|
1118
1388
|
const serverPath = path.join(serverDir, 'server.mjs');
|
|
1119
|
-
|
|
1120
|
-
|
|
1389
|
+
// Named for the return contract that callers and tests already depend on — RESOLVED from the
|
|
1390
|
+
// derived list rather than re-stated, so they cannot drift from what is actually copied.
|
|
1391
|
+
const depTarget = (suffix) => {
|
|
1392
|
+
const hit = deps.find((d) => d.spec.endsWith(suffix));
|
|
1393
|
+
return hit ? path.resolve(serverDir, hit.spec) : null;
|
|
1394
|
+
};
|
|
1395
|
+
const managedCliPath = depTarget('managed-cli-interface.mjs');
|
|
1396
|
+
const runtimePreferencesPath = depTarget('runtime-preferences.mjs');
|
|
1121
1397
|
fs.mkdirSync(serverDir, { recursive: true });
|
|
1122
1398
|
// Write-beside-then-rename, both here and for the config below (issue #43): an interrupted plain
|
|
1123
1399
|
// copy leaves a TORN server.mjs at the exact path a prior install's config already points at, so
|
|
1124
1400
|
// Codex spawns half a file. rename() over the target is atomic; a failure leaves the old bytes.
|
|
1125
1401
|
// Copy the dependency first. If the later server swap fails, the previously registered server
|
|
1126
1402
|
// remains byte-intact and continues to import a backward-compatible module at the same path.
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1403
|
+
// Dependencies FIRST, server last: if the server swap fails, the previously registered server
|
|
1404
|
+
// remains byte-intact and continues to import backward-compatible modules at the same paths.
|
|
1405
|
+
for (const dep of deps) {
|
|
1406
|
+
const target = path.resolve(serverDir, dep.spec);
|
|
1407
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1408
|
+
atomicReplace(target, (tmp) => fs.copyFileSync(dep.from, tmp));
|
|
1409
|
+
}
|
|
1130
1410
|
atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
|
|
1131
1411
|
if (fs.existsSync(hookWrapperSource)) {
|
|
1132
1412
|
fs.mkdirSync(path.dirname(hookWrapperPath), { recursive: true });
|
|
@@ -2254,10 +2534,73 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
|
|
|
2254
2534
|
const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
|
|
2255
2535
|
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2256
2536
|
|
|
2257
|
-
|
|
2258
|
-
|
|
2537
|
+
/**
|
|
2538
|
+
* THE ONE SCHEDULED-UPDATE COMMAND (issue #129). Every scheduler — cron, systemd, LaunchAgent —
|
|
2539
|
+
* runs THIS, and it is byte-for-byte the entrypoint plugin/scripts/host-update.mjs already uses on
|
|
2540
|
+
* SessionStart.
|
|
2541
|
+
*
|
|
2542
|
+
* It used to be `forge-update.mjs --apply`, which advances the KB BYTES ONLY. That path never
|
|
2543
|
+
* reaches host convergence, so a scheduled run could move the corpus forward while the Stable Spine,
|
|
2544
|
+
* the Claude and Codex plugin payloads, the Console runtime and `host-convergence.json` all stayed
|
|
2545
|
+
* behind — silently, on a schedule, with the update log reporting success. A machine updating itself
|
|
2546
|
+
* into a split state overnight is worse than one that never updates, because nothing looks wrong.
|
|
2547
|
+
*
|
|
2548
|
+
* Two entrypoints for one job is the defect; naming it once here is the fix. 03:47 on purpose: an
|
|
2549
|
+
* off-hour minute, so it never piles onto the :00 cron rush.
|
|
2550
|
+
*/
|
|
2551
|
+
const NIGHTLY_ARGV = ['--yes', 'ruvnet-brain@latest', '--update', '--host-sync-only', '--no-nightly-prompt'];
|
|
2552
|
+
const nightlyCommand = () => `npx ${NIGHTLY_ARGV.join(' ')}`;
|
|
2553
|
+
|
|
2554
|
+
/**
|
|
2555
|
+
* Absolute path to npx, resolved at install time — a scheduler has no shell profile, so a bare name
|
|
2556
|
+
* would resolve only on installs whose npx happens to sit in launchd's minimal PATH. Falls back to
|
|
2557
|
+
* the bare name rather than refusing: a wrong-but-present command is fixable by the user, whereas a
|
|
2558
|
+
* refusal to install any schedule leaves them with nothing.
|
|
2559
|
+
*/
|
|
2560
|
+
function npxPath() {
|
|
2561
|
+
const local = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npx.cmd' : 'npx');
|
|
2562
|
+
if (fs.existsSync(local)) return local;
|
|
2563
|
+
try {
|
|
2564
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['npx'], { encoding: 'utf8' });
|
|
2565
|
+
const found = String(r.stdout || '').split('\n')[0].trim();
|
|
2566
|
+
if (found && fs.existsSync(found)) return found;
|
|
2567
|
+
} catch { /* not on PATH here either — fall through */ }
|
|
2568
|
+
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Add the nightly line to the user's crontab, idempotently (issue #129).
|
|
2572
|
+
*
|
|
2573
|
+
* Read-modify-write through `crontab -l` / `crontab -` because that is the only portable interface;
|
|
2574
|
+
* writing /var/spool/cron directly needs root and bypasses the daemon's reload. Every failure mode
|
|
2575
|
+
* returns a REASON rather than a boolean: "no crontab binary" and "the write was rejected" need
|
|
2576
|
+
* different things from the user, and collapsing them into "failed" is how a person ends up
|
|
2577
|
+
* re-running a command that can never work.
|
|
2578
|
+
*/
|
|
2579
|
+
export function installCronEntry(line, { run = spawnSync } = {}) {
|
|
2580
|
+
const marker = 'ruvnet-brain@latest';
|
|
2581
|
+
const list = run('crontab', ['-l'], { encoding: 'utf8' });
|
|
2582
|
+
if (list.error) return { ok: false, why: 'no crontab command was found on this system' };
|
|
2583
|
+
// Exit 1 with no output is the documented "this user has no crontab yet" case, not an error.
|
|
2584
|
+
const current = String(list.stdout || '');
|
|
2585
|
+
if (current.split('\n').some((l) => l.includes(marker) && !l.trim().startsWith('#'))) {
|
|
2586
|
+
return { ok: true, already: true };
|
|
2587
|
+
}
|
|
2588
|
+
const next = `${current.replace(/\n*$/, '')}\n${line}\n`.replace(/^\n+/, '');
|
|
2589
|
+
const write = run('crontab', ['-'], { input: next, encoding: 'utf8' });
|
|
2590
|
+
if (write.error) return { ok: false, why: `crontab could not be written (${write.error.message})` };
|
|
2591
|
+
if (write.status !== 0) {
|
|
2592
|
+
return { ok: false, why: `crontab rejected the entry (exit ${write.status})${String(write.stderr || '').trim() ? `: ${String(write.stderr).trim()}` : ''}` };
|
|
2593
|
+
}
|
|
2594
|
+
return { ok: true, already: false };
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/** launchd's own minimal PATH, plus the directory node/npx actually live in on this machine. */
|
|
2598
|
+
const launchdPath = () => [...new Set([
|
|
2599
|
+
path.dirname(process.execPath), path.dirname(npxPath()),
|
|
2600
|
+
'/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin',
|
|
2601
|
+
])].filter(Boolean).join(':');
|
|
2259
2602
|
const cronExample = (kbDir) =>
|
|
2260
|
-
`47 3 * * *
|
|
2603
|
+
`47 3 * * * ${nightlyCommand()} >> ${kbDir}/update.log 2>&1`;
|
|
2261
2604
|
|
|
2262
2605
|
/**
|
|
2263
2606
|
* kb/forge-update.mjs exit 11 = "the download completed but the bundle on disk did NOT change"
|
|
@@ -2344,6 +2687,21 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
2344
2687
|
const applied = runStableSpine(apply);
|
|
2345
2688
|
const okApplied = !applied.error && applied.status === 0;
|
|
2346
2689
|
if (okApplied) {
|
|
2690
|
+
// ISSUE #153 — running Claude sessions freeze their plugin root. Collect only generations whose
|
|
2691
|
+
// leases prove that exact PID incarnation dead, or legacy roots past the compatibility grace.
|
|
2692
|
+
try {
|
|
2693
|
+
const gens = prunePluginGenerations({ apply: true });
|
|
2694
|
+
if (gens.removed.length) {
|
|
2695
|
+
ok(`pruned ${gens.removed.length} stale plugin generation(s), freed ${(gens.bytes / 1048576).toFixed(1)} MB: ${gens.removed.join(', ')}`);
|
|
2696
|
+
}
|
|
2697
|
+
if (gens.cleanupBlocked?.length) {
|
|
2698
|
+
warn(`plugin cleanup retained ${gens.cleanupBlocked.length} generation(s): ${gens.cleanupBlocked.map((item) => `${item.version} (${item.reason})`).join(', ')}`);
|
|
2699
|
+
} else if (!gens.removed.length && gens.why) {
|
|
2700
|
+
warn(`stale plugin generations were not pruned: ${gens.why}`);
|
|
2701
|
+
}
|
|
2702
|
+
} catch (e) {
|
|
2703
|
+
warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
|
|
2704
|
+
}
|
|
2347
2705
|
const receiptPath = path.join(brainHome, 'host-convergence.json');
|
|
2348
2706
|
try {
|
|
2349
2707
|
runtimeTransaction.activate();
|
|
@@ -2496,12 +2854,24 @@ function enableNightly() {
|
|
|
2496
2854
|
const kbDir = resolvedKbDir();
|
|
2497
2855
|
|
|
2498
2856
|
if (process.platform !== 'darwin') {
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2857
|
+
// ISSUE #129 — A COMMAND NAMED `--enable-nightly` MAY NOT EXIT 0 WITHOUT ENABLING ANYTHING.
|
|
2858
|
+
//
|
|
2859
|
+
// This printed a cron recipe and returned success. Every automated check of "is Evergreen on?"
|
|
2860
|
+
// — a provisioning script, a CI step, a user reading the exit code — was told yes on a machine
|
|
2861
|
+
// where no schedule existed. So it now INSTALLS the entry, and when it cannot, it says so and
|
|
2862
|
+
// exits non-zero rather than dressing a manual instruction up as a completed action.
|
|
2863
|
+
const line = cronExample(kbDir);
|
|
2864
|
+
const installed = installCronEntry(line);
|
|
2865
|
+
if (installed.ok) {
|
|
2866
|
+
ok(`installed a nightly crontab entry (03:47 local):\n\n ${c.bold(line)}\n`);
|
|
2867
|
+
info(`(${c.bold('crontab -l')} to see it, ${c.bold('crontab -e')} to remove it.)`);
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
console.error(`\n${c.red('✗ nightly updates were NOT enabled:')} ${installed.why}`);
|
|
2871
|
+
info('\nAdd this line by hand and nightly updates will work exactly as they do on macOS:');
|
|
2872
|
+
console.log(`\n ${c.bold(line)}\n`);
|
|
2503
2873
|
info(`(${c.bold('crontab -e')}, paste the line, save. Remove the line to disable.)`);
|
|
2504
|
-
|
|
2874
|
+
process.exit(1); // the caller asked for a schedule and does not have one
|
|
2505
2875
|
}
|
|
2506
2876
|
|
|
2507
2877
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
@@ -2526,12 +2896,24 @@ function enableNightly() {
|
|
|
2526
2896
|
<dict>
|
|
2527
2897
|
<key>Label</key>
|
|
2528
2898
|
<string>${NIGHTLY_LABEL}</string>
|
|
2899
|
+
<!-- Issue #129: the SAME host-convergent entrypoint the session updater runs, not the KB-only
|
|
2900
|
+
forge-update.mjs it used to schedule. See NIGHTLY_ARGV. Still no `/bin/sh -c` (ADR-038) —
|
|
2901
|
+
this execs npx directly. -->
|
|
2529
2902
|
<key>ProgramArguments</key>
|
|
2530
2903
|
<array>
|
|
2531
|
-
<string>${xmlEscape(
|
|
2532
|
-
<string
|
|
2533
|
-
<string>--apply</string>
|
|
2904
|
+
<string>${xmlEscape(npxPath())}</string>
|
|
2905
|
+
${NIGHTLY_ARGV.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n')}
|
|
2534
2906
|
</array>
|
|
2907
|
+
<!-- launchd starts agents with a MINIMAL PATH (/usr/bin:/bin:/usr/sbin:/sbin) - it does not read
|
|
2908
|
+
the user's shell profile. npx resolved by bare name would therefore fail to launch on every
|
|
2909
|
+
Homebrew/nvm/Volta install, which is most of them, and the only symptom would be a nightly
|
|
2910
|
+
that silently never ran. The absolute path above is resolved at install time; PATH is also set
|
|
2911
|
+
so npx can find node and the package's own bin shims once it starts. -->
|
|
2912
|
+
<key>EnvironmentVariables</key>
|
|
2913
|
+
<dict>
|
|
2914
|
+
<key>PATH</key>
|
|
2915
|
+
<string>${xmlEscape(launchdPath())}</string>
|
|
2916
|
+
</dict>
|
|
2535
2917
|
<key>WorkingDirectory</key>
|
|
2536
2918
|
<string>${xmlEscape(kbDir)}</string>
|
|
2537
2919
|
<key>StandardOutPath</key>
|