ruvnet-brain 4.0.90-dev β†’ 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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # 🧠 RuvNet Brain
6
6
 
7
- ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 4.0.90-dev β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.90--dev-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
7
+ ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 4.2.2-dev β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.2.2--dev-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](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.0 β€” it anticipates, and it learns whether it was right
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
@@ -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** β€” 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.
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
@@ -547,7 +547,7 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
547
547
  }
548
548
 
549
549
  /**
550
- * ISSUE #128 β€” STALE PLUGIN GENERATIONS ARE STILL DISCOVERED.
550
+ * ISSUE #128 / #153 β€” STALE PLUGIN GENERATIONS MAY STILL BACK LIVE SESSIONS.
551
551
  *
552
552
  * `pruneUnlistedStores` below states the rule this repo already believes: "an update is a
553
553
  * replacement, not an overlay." That rule was applied to KB stores and never to the plugin cache.
@@ -563,6 +563,12 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
563
563
  * restart, one says an update is landing, one mandates a third version), so whichever the model
564
564
  * obeys, the user is misinformed by a source that sounds definitive.
565
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
+ *
566
572
  * WHAT MAKES DELETION SAFE HERE, since this removes directories from someone's machine:
567
573
  * β€’ The registry is the ONLY authority. Unreadable, missing, or naming no ruvnet-brain install β†’
568
574
  * nothing is removed and a reason is returned. Never guess what to delete β€” the same discipline
@@ -574,17 +580,26 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
574
580
  * here, and deleting through that link would delete the working tree.
575
581
  * β€’ EVERY registered installPath is protected, not just the first β€” the plugin may legitimately be
576
582
  * 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.
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.
578
586
  * β€’ Report-only unless `apply` is set, and every removal is returned by name, so a silent sweep is
579
587
  * impossible.
580
588
  */
581
589
  export function prunePluginGenerations({
582
590
  registryPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'),
583
591
  apply = false,
592
+ now = () => Date.now(),
593
+ graceMs = 14 * 24 * 60 * 60 * 1000,
594
+ processIdentity = pluginProcessIdentity,
584
595
  } = {}) {
585
- const nothing = (why) => ({ active: [], stale: [], removed: [], bytes: 0, why });
596
+ const nothing = (why) => ({ active: [], stale: [], marked: [], removed: [], bytes: 0, staleBytes: 0, cleanupBlocked: [], why });
597
+ let registryRaw;
586
598
  let registry;
587
- try { registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch (e) {
599
+ try {
600
+ registryRaw = fs.readFileSync(registryPath, 'utf8');
601
+ registry = JSON.parse(registryRaw);
602
+ } catch (e) {
588
603
  return nothing(`plugin registry unreadable (${e.code || e.message}) β€” refusing to remove anything`);
589
604
  }
590
605
  const active = Object.entries(registry?.plugins || {})
@@ -609,17 +624,126 @@ export function prunePluginGenerations({
609
624
  stale.push({ path: full, version: name, bytes: dirBytes(full) });
610
625
  }
611
626
  }
612
- const bytes = stale.reduce((n, s) => n + s.bytes, 0);
613
- if (!apply) return { active, stale, removed: [], bytes, why: null };
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 };
614
629
 
630
+ const marked = [];
615
631
  const removed = [];
632
+ const cleanupBlocked = [];
616
633
  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` */ }
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 */ }
621
723
  }
622
- return { active, stale, removed, bytes: freed, why: null };
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';
623
747
  }
624
748
 
625
749
  /** Size of a directory tree, best-effort β€” used only to report what was or would be freed. */
@@ -2563,18 +2687,16 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
2563
2687
  const applied = runStableSpine(apply);
2564
2688
  const okApplied = !applied.error && applied.status === 0;
2565
2689
  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.
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.
2573
2692
  try {
2574
2693
  const gens = prunePluginGenerations({ apply: true });
2575
2694
  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) {
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) {
2578
2700
  warn(`stale plugin generations were not pruned: ${gens.why}`);
2579
2701
  }
2580
2702
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.0.90-dev",
3
+ "version": "4.2.2-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": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
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",
4
+ "version": "4.2.2-dev",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.0.90-dev",
3
+ "version": "4.2.2-dev",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -132,7 +132,7 @@ function readPanel() {
132
132
  * than a guess when the denominator is unknown β€” a wrong denominator still renders, which is worse
133
133
  * than an absent one.
134
134
  */
135
- async function readCoverage() {
135
+ export async function readCoverage(rootOverride) {
136
136
  const { storeRoot, storesAt, cardsAt } = await import('../kb/store-root.mjs');
137
137
  // ROUTABILITY IS ALIAS-AWARE, BECAUSE THE ROUTER IS.
138
138
  //
@@ -148,25 +148,34 @@ async function readCoverage() {
148
148
  // broke alias resolution outright. A metric that cannot see aliases manufactures work that
149
149
  // damages the thing it measures.
150
150
  const { repositoryNames } = await import('../kb/card-lane.mjs');
151
- const root = storeRoot();
151
+ const root = rootOverride ?? storeRoot();
152
+ // NEVER-MATERIALIZED, THE SAME AMBIGUITY `restore-local-ingests.mjs`'s `classify()` WAS FIXED
153
+ // FOR (PR #143, Night 1) β€” but never carried here. `storesAt()`/`cardsAt()` silently return
154
+ // `[]` on ENOENT, so a host whose store root was never restored (a fresh checkout, this very
155
+ // nightly agent's own ephemeral container) reads IDENTICALLY to a materialized root that
156
+ // genuinely has zero coverage. Checked once, here, before either derived number is computed β€”
157
+ // a wrong `0` still renders, which is worse than an honestly unmeasured one.
158
+ const neverMaterialized = !fs.existsSync(root);
152
159
  const stores = storesAt(root);
153
160
  const cards = new Set(cardsAt(root));
154
161
  const org = readJson('data/org-repo-count.json');
155
162
  const total = Number.isFinite(org?.count) && org.count > 0 ? org.count : null;
156
163
  const carded = stores.filter((s) => isRoutable(s, cards, root, repositoryNames)).length;
164
+ const absentDetail = 'store root does not exist on this host (never materialized) β€” not evidence of live coverage';
157
165
  return {
158
166
  catalogue: {
159
- value: total ? Math.round((stores.length / total) * 1000) / 10 : null,
160
- detail: total ? `${stores.length}/${total} live repos` : `${stores.length} stores, org total UNKNOWN`,
161
- at: org?.at ?? null,
167
+ value: neverMaterialized ? null : (total ? Math.round((stores.length / total) * 1000) / 10 : null),
168
+ detail: neverMaterialized ? absentDetail
169
+ : (total ? `${stores.length}/${total} live repos` : `${stores.length} stores, org total UNKNOWN`),
170
+ at: neverMaterialized ? null : (org?.at ?? null),
162
171
  },
163
172
  routable: {
164
- value: stores.length ? Math.round((carded / stores.length) * 1000) / 10 : null,
165
- detail: `${carded}/${stores.length} built stores have a card`,
173
+ value: neverMaterialized || !stores.length ? null : Math.round((carded / stores.length) * 1000) / 10,
174
+ detail: neverMaterialized ? absentDetail : `${carded}/${stores.length} built stores have a card`,
166
175
  // Derived from the live store root at this instant, so it is current BY CONSTRUCTION. A
167
176
  // read-now value reported as stale would train the reader to ignore the stale flag, which
168
177
  // costs more than the flag is worth.
169
- at: new Date().toISOString(),
178
+ at: neverMaterialized ? null : new Date().toISOString(),
170
179
  },
171
180
  };
172
181
  }
@@ -19,6 +19,7 @@ import { spawnSync } from 'node:child_process';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { getVersion, getVersionTag, stripTag } from './version.mjs';
21
21
  import { auditRvfIndexes } from './rvf-index-audit.mjs';
22
+ import { validateSelectedRvfGenerations } from './rvf-generation.mjs';
22
23
  // The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
23
24
  // sibling while the account actually had 200 β€” one stale fact, restated twice (2026-08-12).
24
25
  import { orgRepoCount } from './org-repo-count.mjs';
@@ -162,6 +163,15 @@ if (built.length === 0) {
162
163
  console.error('[build-bundle] FATAL: zero public RVF stores are eligible for release. Refusing to publish an empty brain bundle.');
163
164
  process.exit(1);
164
165
  }
166
+ const generationValidation = validateSelectedRvfGenerations(ASSETS, {
167
+ selectedStores: built,
168
+ privateStores: [...PRIVATE_STORES],
169
+ });
170
+ if (generationValidation.failures.length) {
171
+ console.error('[build-bundle] FATAL: RVF generation ledger does not exactly bind selected roots:');
172
+ for (const failure of generationValidation.failures) console.error(` ${failure}`);
173
+ process.exit(1);
174
+ }
165
175
  const rvfIndexAudit = await auditRvfIndexes(
166
176
  built.map((name) => path.join(ASSETS, `${name}.big.rvf`)),
167
177
  );
@@ -208,7 +218,7 @@ for (const name of built) {
208
218
  if (!fs.existsSync(generationsFile)) {
209
219
  missing.push('RVF-GENERATIONS.json');
210
220
  } else {
211
- const source = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
221
+ const source = generationValidation.manifest;
212
222
  const stores = {};
213
223
  for (const { name } of builtRepos) {
214
224
  if (!source.stores?.[name]) missing.push(`RVF-GENERATIONS.json:${name}`);
@@ -216,8 +226,8 @@ for (const name of built) {
216
226
  }
217
227
  fs.writeFileSync(path.join(OUT, 'RVF-GENERATIONS.json'), `${JSON.stringify({
218
228
  schemaVersion: source.schemaVersion,
219
- brainVersion: source.brainVersion,
220
- releaseTag: source.releaseTag,
229
+ brainVersion: stripTag(BRAIN_VERSION),
230
+ releaseTag: BRAIN_VERSION,
221
231
  stores,
222
232
  }, null, 2)}\n`);
223
233
  copied++;
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * card-from-source.mjs β€” give a DARK store a card written from the repo's own words.
4
+ *
5
+ * A store with no capability card is DARK: the bytes are valid and no by-description query can
6
+ * reach them. `ingest-repo.mjs` says so on every run. After the 2026-08-20 bulk ingest the brain
7
+ * held ~111 stores and ~71 of them were dark β€” loaded, and unusable for anything except a query
8
+ * that already knew the repo's name, which is precisely the query a knowledge base is least needed
9
+ * for.
10
+ *
11
+ * THE LINE THIS DOES NOT CROSS. `ingest-new-repos.mjs` deliberately refuses to write cards, because
12
+ * a card invented from a repo NAME is a confident claim in the routing layer that nobody grounded β€”
13
+ * it routes real questions to a corpus that cannot answer them, which is worse than an honest gap.
14
+ * That refusal stands. This is a different act: it copies the repo's OWN description and the
15
+ * opening of its OWN README. Every content word in the card came from the repository. Nothing here
16
+ * infers what a project is "probably" for.
17
+ *
18
+ * AND IT SAYS WHAT IT IS. Each generated card carries a marker so a reader can tell it apart from
19
+ * the hand-written ones, which are richer β€” they say when to REACH for a tool and what it is NOT.
20
+ * An auto-derived card is a floor, not a substitute: it makes a store reachable and invites a
21
+ * better card later. Claiming otherwise would be the overselling this file exists to avoid.
22
+ *
23
+ * node scripts/card-from-source.mjs # report which stores are dark
24
+ * node scripts/card-from-source.mjs --apply # write cards for them
25
+ * node scripts/card-from-source.mjs --apply --max 20
26
+ */
27
+ import { execFileSync } from 'node:child_process';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { storeRoot, darkStores } from '../kb/store-root.mjs';
32
+
33
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
34
+ const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
35
+ const APPLY = process.argv.includes('--apply');
36
+ const arg = (n, d) => { const i = process.argv.indexOf(n); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
37
+ const MAX = Number(arg('--max', '500'));
38
+ export const MARKER = 'Auto-derived from the repository\'s own description and README';
39
+
40
+ /** The repo's own description + README opening. Never a guess about what the name implies. */
41
+ export function sourceFacts(repo) {
42
+ let description = '';
43
+ let readme = '';
44
+ try {
45
+ const meta = JSON.parse(execFileSync('gh', ['api', `repos/${OWNER}/${repo}`], { encoding: 'utf8', maxBuffer: 1 << 24 }));
46
+ description = String(meta?.description || '').trim();
47
+ } catch { /* a repo can legitimately have no description */ }
48
+ try {
49
+ const raw = execFileSync('gh', ['api', `repos/${OWNER}/${repo}/readme`, '-H', 'Accept: application/vnd.github.raw'],
50
+ { encoding: 'utf8', maxBuffer: 1 << 24 });
51
+ readme = raw;
52
+ } catch { /* and no README */ }
53
+ return { description, readme };
54
+ }
55
+
56
+ /** First real prose of a README: skip badges, HTML, headings, and link-only lines. */
57
+ export function firstProse(readme, limit = 600) {
58
+ const lines = String(readme || '').split('\n');
59
+ const out = [];
60
+ for (const line of lines) {
61
+ const t = line.trim();
62
+ if (!t) { if (out.length) break; continue; }
63
+ if (t.startsWith('#') || t.startsWith('<') || t.startsWith('|') || t.startsWith('---')) continue;
64
+ if (/^!?\[[^\]]*\]\([^)]*\)$/.test(t)) continue; // a lone badge or image
65
+ if (/^\[!\[/.test(t)) continue; // badge-wrapped link
66
+ out.push(t);
67
+ if (out.join(' ').length > limit) break;
68
+ }
69
+ return out.join(' ').replace(/\s+/g, ' ').slice(0, limit).trim();
70
+ }
71
+
72
+ export function buildCard(repo, { description, readme }) {
73
+ const prose = firstProse(readme);
74
+ if (!description && !prose) return null; // nothing grounded to say β€” leave it dark, honestly
75
+ const body = [description, prose].filter(Boolean).join(' β€” ');
76
+ return `## ${repo.toLowerCase()}\n${body}\n(${MARKER}; a hand-written card saying when to reach for it, and when not to, would be better.)\n`;
77
+ }
78
+
79
+ function insertSorted(file, card, name) {
80
+ const s = fs.readFileSync(file, 'utf8');
81
+ if (new RegExp(`^## ${name}$`, 'mi').test(s)) return false;
82
+ const heads = [...s.matchAll(/^## (.+)$/gm)].map((m) => [m.index, m[1].trim().toLowerCase()]);
83
+ const pos = heads.find(([, n]) => n > name)?.[0];
84
+ fs.writeFileSync(file, pos === undefined ? `${s.replace(/\n+$/, '')}\n\n${card}` : s.slice(0, pos) + card + '\n' + s.slice(pos));
85
+ return true;
86
+ }
87
+
88
+ const isMain = (() => {
89
+ try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
90
+ catch { return false; }
91
+ })();
92
+
93
+ if (isMain) {
94
+ const root = storeRoot();
95
+ const dark = darkStores(root);
96
+ console.log(`[card] ${dark.length} dark store(s) β€” valid bytes no by-description query can reach`);
97
+ if (!dark.length) process.exit(0);
98
+ if (!APPLY) {
99
+ console.log(`[card] ${dark.slice(0, 20).join(', ')}${dark.length > 20 ? ' …' : ''}`);
100
+ console.log('[card] report only. Re-run with --apply to write cards from each repo\'s own source.');
101
+ process.exit(0);
102
+ }
103
+ const repoCards = path.join(ROOT, 'kb', 'capability-cards.md');
104
+ const liveCards = path.join(root, 'capability-cards.md');
105
+ let wrote = 0; let skipped = 0;
106
+ for (const name of dark.slice(0, MAX)) {
107
+ const card = buildCard(name, sourceFacts(name));
108
+ if (!card) { skipped += 1; console.log(`[card] ${name}: no description and no README prose β€” left dark rather than invented`); continue; }
109
+ for (const f of [repoCards, liveCards]) { try { insertSorted(f, card, name.toLowerCase()); } catch { /* keep going */ } }
110
+ wrote += 1;
111
+ }
112
+ const remaining = darkStores(root).length;
113
+ console.log(`\n[card] wrote ${wrote}, left ${skipped} dark for lack of grounded source text, ${remaining} still dark.`);
114
+ }