ruvnet-brain 4.0.90-dev → 4.3.1-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.
Files changed (74) hide show
  1. package/README.md +31 -7
  2. package/bin/install.mjs +153 -22
  3. package/console/architecture.html +12 -14
  4. package/console/install-mockup.html +3 -3
  5. package/kb/zip-extract.mjs +8 -2
  6. package/package.json +7 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/hooks/codex-hooks.json +1 -1
  10. package/plugin/hooks/hooks.json +10 -0
  11. package/plugin/host-adapters/claude.json +11 -0
  12. package/plugin/host-adapters/codex.json +11 -0
  13. package/plugin/mcp/managed-cli-interface.mjs +77 -10
  14. package/plugin/mcp/server.mjs +2 -1
  15. package/plugin/scripts/anticipate.sh +30 -1
  16. package/plugin/scripts/capability-claim-evidence.mjs +431 -0
  17. package/plugin/scripts/capability-inventory-receipt.mjs +225 -0
  18. package/plugin/scripts/capability-registry.mjs +6 -0
  19. package/plugin/scripts/capability-routing.mjs +70 -0
  20. package/plugin/scripts/continuation-gate.mjs +68 -2
  21. package/plugin/scripts/coverage-integrity.mjs +575 -0
  22. package/plugin/scripts/decision-outcomes.mjs +0 -0
  23. package/plugin/scripts/ground-ruvnet.sh +2 -2
  24. package/plugin/scripts/nightly-controller.mjs +19 -2
  25. package/plugin/scripts/project-progression-contract.mjs +409 -0
  26. package/plugin/scripts/project-progression-hook.mjs +196 -0
  27. package/plugin/scripts/project-progression-outbox.mjs +95 -0
  28. package/plugin/scripts/project-progression-session-start.mjs +183 -0
  29. package/plugin/scripts/project-progression-store.mjs +248 -0
  30. package/plugin/scripts/project-store-resolver.mjs +106 -0
  31. package/plugin/scripts/session-snapshot-hook.mjs +29 -1
  32. package/plugin/scripts/session-start-core.mjs +22 -18
  33. package/plugin/scripts/spend-guard.mjs +6 -1
  34. package/scripts/adr-072-completion.mjs +98 -0
  35. package/scripts/behavioral-l1-l4.mjs +11 -8
  36. package/scripts/brain-score.mjs +23 -9
  37. package/scripts/build-bundle.mjs +94 -8
  38. package/scripts/card-from-source.mjs +140 -0
  39. package/scripts/corpus-aggregates.mjs +194 -0
  40. package/scripts/corpus-candidate.mjs +314 -0
  41. package/scripts/corpus-reconcile.mjs +540 -0
  42. package/scripts/corpus-seed-publish.mjs +110 -0
  43. package/scripts/coverage-integrity.mjs +3 -0
  44. package/scripts/gist-receipts.mjs +183 -0
  45. package/scripts/git-hooks/pre-push +21 -0
  46. package/scripts/host-registry.mjs +118 -0
  47. package/scripts/independent-review-receipt.mjs +499 -0
  48. package/scripts/ingest-gists.mjs +34 -5
  49. package/scripts/ingest-new-repos.mjs +127 -0
  50. package/scripts/metaharness-gate.mjs +58 -0
  51. package/scripts/nightly-gists.sh +3 -3
  52. package/scripts/nightly-wrapper.sh +105 -64
  53. package/scripts/product-integrity-contract.mjs +156 -0
  54. package/scripts/public-inventory.mjs +4 -0
  55. package/scripts/public-verification-aggregate.mjs +342 -0
  56. package/scripts/public-verification-finalizer.mjs +59 -0
  57. package/scripts/public-verification-inputs.mjs +473 -0
  58. package/scripts/public-verification-lane.mjs +244 -0
  59. package/scripts/publication-receipt.mjs +50 -6
  60. package/scripts/rebuild-gists-from-receipts.mjs +291 -0
  61. package/scripts/release-projection.mjs +86 -0
  62. package/scripts/release-transaction-provider.mjs +43 -102
  63. package/scripts/release-transaction.mjs +165 -49
  64. package/scripts/release.mjs +158 -3
  65. package/scripts/restore-local-ingests.mjs +2 -2
  66. package/scripts/retrieval-canary.mjs +533 -0
  67. package/scripts/routing-flywheel.mjs +12 -1
  68. package/scripts/rvf-generation.mjs +50 -0
  69. package/scripts/self-update.mjs +19 -44
  70. package/scripts/source-coverage.mjs +415 -0
  71. package/scripts/source-scope-receipt.mjs +84 -0
  72. package/scripts/sync-census.mjs +0 -0
  73. package/scripts/wired-check.mjs +114 -24
  74. package/scripts/worktree-integrity.mjs +123 -0
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { restoreProgressionForSession } from './project-progression-session-start.mjs';
11
12
 
12
13
  const HERE = path.dirname(fileURLToPath(import.meta.url));
13
14
  const read = (file, fallback = '') => {
@@ -350,9 +351,16 @@ export async function runSessionStart({
350
351
  stdout = process.stdout,
351
352
  stderr = process.stderr,
352
353
  platform = process.platform,
354
+ restoreContinuity = restoreProgressionForSession,
353
355
  } = {}) {
354
356
  const lines = [];
355
- const emit = (line = '') => lines.push(String(line));
357
+ // SessionStart is context plumbing, not an instruction channel. The old implementation
358
+ // injected response scripts, setup questions and maintainer workflow into every host session;
359
+ // Claude correctly treated that unsolicited prose as noise. Keep the diagnostic work and
360
+ // durable state updates, but make user-facing output opt-in for troubleshooting only.
361
+ const emit = (line = '') => {
362
+ if (env.RUVNET_VERBOSE_HOOKS === '1') lines.push(String(line));
363
+ };
356
364
  const home = env.HOME || env.USERPROFILE || os.homedir();
357
365
  const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
358
366
  const hookDir = path.dirname(fileURLToPath(import.meta.url));
@@ -369,6 +377,14 @@ export async function runSessionStart({
369
377
  }
370
378
  };
371
379
 
380
+ try {
381
+ const continuity = await restoreContinuity({ env, cwd });
382
+ if (continuity?.context) emit(continuity.context);
383
+ } catch {
384
+ emit('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]');
385
+ emit('The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
386
+ }
387
+
372
388
  try {
373
389
  trace('body-start');
374
390
  const settingsFile = env.RUVNET_SETTINGS_FILE
@@ -421,7 +437,8 @@ export async function runSessionStart({
421
437
  emit('On no or silence: drop it, never re-offer.');
422
438
  }
423
439
 
424
- announceVersion({ running, off: brain.off, stateDir, consoleInvoke, emit });
440
+ // Version announcements belong to the explicit update/what's-new flows, not SessionStart.
441
+ // Keeping this hook context-only prevents host sessions from receiving response scripts.
425
442
  const autoPref = path.join(stateDir, '.auto-update-pref');
426
443
  if (!exists(autoPref)) {
427
444
  emit('[RuvNet Brain — one-time setup question]');
@@ -490,36 +507,23 @@ export async function runSessionStart({
490
507
  emit('[RuvNet Brain — MAINTAINER ONLY: the shipped generation is split. Do NOT surface this to the user.]');
491
508
  emit(`Plugin is ${bannerVersion}; the knowledge bundle on this machine is ${kbVersion}. Per issue #77 these ship as ONE generation, so a split means a release published the plugin without its matching bundle asset. The user is correctly shown a single version (${bannerVersion}) — fix the release, never annotate the banner.`);
492
509
  }
493
- let confidenceInstruction;
494
510
  if (readiness.state === 'ready') {
495
511
  emit('USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is ready and live; the grounding hooks are active.');
496
- confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "🧠 RuvNet Brain active (v${bannerVersion})" — ONE version, in parentheses, always; never a second number, never a bundle tag beside it — and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flow…) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
497
512
  } else if (readiness.state === 'degraded') {
498
513
  const receipt = readiness.receipt || {};
499
514
  emit(`USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
500
- confidenceInstruction = `Open your FIRST response with ONE short line: "🧠 RuvNet Brain active (v${bannerVersion}) — search is degraded right now." Do not claim source grounding until a real search succeeds. npx github:stuinfla/ruvnet-brain --doctor shows the current verdict; ${consoleInvoke} opens the Console.`;
501
515
  } else {
502
516
  emit('USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered; live readiness is not yet proven. The grounding hooks are active.');
503
- confidenceInstruction = `Open your FIRST response with ONE short line: "🧠 RuvNet Brain active (v${bannerVersion}) — search is registered and will prove readiness on first use." Do not claim source grounding until a real search returns a citation. npx github:stuinfla/ruvnet-brain --doctor shows the current verdict; ${consoleInvoke} opens the Console.`;
504
517
  }
505
- emit(confidenceInstruction);
506
518
  }
507
-
508
- const claudeJson = read(path.join(home, '.claude.json'));
509
- if (commandExists('ruflo', env, platform) || /"ruflo"/.test(claudeJson)
510
- || exists(path.join(cwd, '.claude-flow')) || exists(path.join(cwd, '.swarm'))) {
511
- emit('[RuvNet Brain — token intelligence + QE, mention once]');
512
- emit('Right after the confidence line, ONE line, never repeated this session: token-smart routing, a QE test fleet and a 0-100 repo scorecard are available — say "do this cheaper", "QE this", "score my harness", "score this repo", /brain-build or /brain-prompt. Scoring is free; the self-improvement loop and cheap-model routing need an OPENROUTER_API_KEY.');
513
- }
514
-
515
- const playbook = `${hookDir}${path.sep}..${path.sep}skills${path.sep}ruvnet-brain${path.sep}PLAYBOOK.md`;
516
- emit('[RuvNet Brain — standing build playbook for this session (referenced by later turns as THE PLAYBOOK)]');
517
- emit(`Read ${playbook} before the first build response. It requires source inspection, search_ruvnet grounding, project-memory recall, and the real owning rUv tool—never a silent hand-roll or generic substitute.`);
518
519
  }
519
520
  } catch (error) {
520
521
  if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
521
522
  }
522
523
 
524
+ if (env.RUVNET_VERBOSE_HOOKS !== '1') {
525
+ lines.push(`[RuvNet Brain v${running || 'unknown'} — active this session]`);
526
+ }
523
527
  const output = lines.length ? `${lines.join('\n')}\n` : '';
524
528
  meter({ env, cwd, stateDir, output });
525
529
  stdout.write(output);
@@ -65,7 +65,12 @@ export const METERED_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
65
65
  export const FLEET_RUNNERS = [
66
66
  { name: 'the QE fleet', match: /(^|[|;&\s])(aqe|agentic-qe)\b/ },
67
67
  { name: 'the agent-flow orchestrator', match: /(^|[|;&\s])agentic-flow\b/ },
68
- { name: 'a ruflo swarm', match: /(^|[|;&\s])ruflo\b[^|;&]*\b(swarm|hive-mind|agent\s+spawn|task\s+orchestrate)\b/ },
68
+ // Match Ruflo's COMMAND position, not an arbitrary later token. The former `[^|;&]*swarm`
69
+ // search classified every mandatory `ruflo memory ... --path .swarm/memory.db` checkpoint as a
70
+ // fleet and blocked it whenever the hook process had a metered key. Ruflo's live help permits
71
+ // global flags before the command, so consume only those known flags and then require a genuine
72
+ // fleet command. An absolute global-binary path is invocation-equivalent to bare `ruflo`.
73
+ { name: 'a ruflo swarm', match: /(^|[|;&\s])(?:[^\s|;&]*\/)?ruflo\b(?:\s+(?:(?:-v|--verbose|-Q|--quiet|--no-color|-i|--interactive)|(?:-c|--config|--format)(?:=[^\s|;&]+|\s+[^\s|;&]+)))*\s+(?:swarm|hive-mind|agent\s+spawn|task\s+orchestrate)\b/ },
69
74
  { name: 'flow-nexus', match: /(^|[|;&\s])flow-nexus\b/ },
70
75
  ];
71
76
 
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { verifyCapabilityClaimAggregate } from '../plugin/scripts/capability-claim-evidence.mjs';
7
+
8
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const REQUIRED_REVIEWERS = Object.freeze(['claude-fable-5', 'gpt-5.6-sol']);
10
+
11
+ const command = (cwd, bin, args) => {
12
+ const result = spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
13
+ if (result.status !== 0) throw new Error(`${bin} ${args.join(' ')} failed: ${String(result.stderr || result.stdout).trim()}`);
14
+ return String(result.stdout || '').trim();
15
+ };
16
+
17
+ export function evaluateCompletion({ root = ROOT, run = command,
18
+ receiptFile = path.join(root, 'release-evidence', 'product-integrity-receipt.json'),
19
+ capabilityClaimPublicKey = null } = {}) {
20
+ const failures = [];
21
+ let head = null;
22
+ let version = null;
23
+ const check = (label, fn) => { try { fn(); } catch (error) { failures.push(`${label}: ${error.message}`); } };
24
+
25
+ check('worktree', () => {
26
+ const dirty = run(root, 'git', ['status', '--porcelain=v1', '--untracked-files=all']);
27
+ if (dirty) throw new Error('working tree is not clean');
28
+ if (run(root, 'git', ['branch', '--show-current']) !== 'main') throw new Error('release checkout is not main');
29
+ head = run(root, 'git', ['rev-parse', 'HEAD']);
30
+ const remote = run(root, 'git', ['rev-parse', 'origin/main']);
31
+ if (head !== remote) throw new Error(`HEAD ${head} differs from origin/main ${remote}`);
32
+ });
33
+ check('version', () => {
34
+ version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
35
+ const npmVersion = JSON.parse(run(root, 'npm', ['view', 'ruvnet-brain@latest', 'version', '--json']));
36
+ if (npmVersion !== version) throw new Error(`npm latest ${npmVersion} differs from ${version}`);
37
+ const release = JSON.parse(run(root, 'gh', ['release', 'view', `v${version}`, '--repo', 'stuinfla/ruvnet-brain',
38
+ '--json', 'tagName,targetCommitish,isDraft,isPrerelease']));
39
+ if (release.tagName !== `v${version}` || release.targetCommitish !== head || release.isDraft || release.isPrerelease) {
40
+ throw new Error('GitHub release does not expose the exact main candidate');
41
+ }
42
+ });
43
+ check('public proof', () => {
44
+ if (!fs.existsSync(receiptFile)) throw new Error(`missing ${receiptFile}`);
45
+ const receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8'));
46
+ if (receipt.schemaVersion !== 1 || receipt.kind !== 'ruvnet-brain-product-integrity'
47
+ || receipt.verdict !== 'PASS' || receipt.sourceSha !== head || receipt.version !== version) {
48
+ throw new Error('product integrity receipt identity or verdict is invalid');
49
+ }
50
+ if (receipt.release?.state !== 'install-verified' || receipt.release?.publicAggregateVerified !== true) {
51
+ throw new Error('release is not cryptographically install-verified');
52
+ }
53
+ if (receipt.coverage?.eligibleCurrent !== receipt.coverage?.eligibleTotal
54
+ || receipt.coverage?.gistCurrent !== receipt.coverage?.gistTotal) {
55
+ throw new Error('installed public corpus is incomplete');
56
+ }
57
+ if (receipt.retrieval?.deltaCitationRate !== 1 || receipt.retrieval?.recallAt10 < 0.98
58
+ || receipt.retrieval?.skipped !== 0 || receipt.retrieval?.unknown !== 0) {
59
+ throw new Error('retrieval acceptance is incomplete');
60
+ }
61
+ if (receipt.hosts?.passed !== 9 || receipt.hosts?.required !== 9) throw new Error('public 3x3 host matrix is incomplete');
62
+ if (receipt.lifecycle?.nativeRuns !== 2 || receipt.lifecycle?.secondVerdict !== 'noop'
63
+ || receipt.lifecycle?.redundantCopyCount !== 0 || receipt.lifecycle?.withinRetentionBudget !== true) {
64
+ throw new Error('native two-run lifecycle proof is incomplete');
65
+ }
66
+ if (receipt.continuity?.verdict !== 'PASS'
67
+ || receipt.continuity?.crashResumeVerified !== true
68
+ || receipt.continuity?.concurrentWritersVerified !== true
69
+ || receipt.continuity?.hosts?.join(',') !== 'claude,codex') {
70
+ throw new Error('cross-host AgentDB continuity proof is incomplete');
71
+ }
72
+ const capabilityKey = capabilityClaimPublicKey
73
+ || fs.readFileSync(path.join(root, 'keys', 'ruvnet-brain-signing.pub.pem'));
74
+ verifyCapabilityClaimAggregate(receipt.capabilityClaims, capabilityKey);
75
+ if (receipt.capabilityClaims.verdict !== 'PASS'
76
+ || receipt.capabilityClaims.identity.sourceSha !== head
77
+ || receipt.capabilityClaims.os?.join(',') !== 'linux,macos,windows'
78
+ || receipt.capabilityClaims.hosts?.join(',') !== 'claude,codex'
79
+ || receipt.capabilityClaims.untested?.length) {
80
+ throw new Error('RuvNet capability-claim integrity proof is incomplete');
81
+ }
82
+ for (const reviewer of REQUIRED_REVIEWERS) {
83
+ const review = receipt.reviews?.find((row) => row.id === reviewer);
84
+ if (!review || review.score < 95 || review.sourceSha !== head || review.untested?.length) {
85
+ throw new Error(`${reviewer} review is missing, below 95, stale, or has untested scope`);
86
+ }
87
+ }
88
+ if (receipt.mechanicalScore < 98) throw new Error('mechanical whole-product score is below 98');
89
+ });
90
+ return { ok: failures.length === 0, head, version, receiptFile, failures };
91
+ }
92
+
93
+ if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
94
+ const receiptIndex = process.argv.indexOf('--receipt');
95
+ const result = evaluateCompletion({ receiptFile: receiptIndex >= 0 ? path.resolve(process.argv[receiptIndex + 1]) : undefined });
96
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
97
+ process.exitCode = result.ok ? 0 : 1;
98
+ }
@@ -71,31 +71,34 @@ const L3 = [
71
71
  { q: 'implement a nearest-neighbour query against an .rvf store', expect: 'ruvector', apiRx: /query|search|knn|nearest/i },
72
72
  { q: 'store and recall an agent memory entry with AgentDB', expect: 'agentdb', apiRx: /store|recall|memory|insert|search/i },
73
73
  ];
74
- // ── L4 ORCHESTRATE: run the real hook; assert the injected directive is complete ──────────────────
74
+ // ── L4 ORCHESTRATE: run the real hook; assert quiet build behavior and useful grounding ──────────
75
75
  const L4 = [
76
76
  {
77
77
  name: 'newbie "go make magic"',
78
78
  prompt: 'go make magic and build me an app that helps people learn',
79
- // pure build prompt, no RuvNet keyword → Gate 3 (take-the-wheel) must fire with the full pipeline
80
- must: ['take the wheel', 'SPARC', 'DDD', 'ADR', 'swarm', 'QA gate', '98', 'frontend-design', 'image generation', 'API key', 'PROVEN', 'PARALLEL'],
79
+ // Build prompts must not inject a response script into the model context.
80
+ must: [],
81
+ mustNot: ['SPARC', 'DDD', 'QA gate', 'frontend-design', 'image generation', 'API key', 'PARALLEL', 'end your response'],
81
82
  },
82
83
  {
83
84
  name: 'spec → build (RuvNet-named)',
84
85
  prompt: 'here is a product spec, build a knowledge base feature using ruvector',
85
- // names ruvector → Gate 1 (ground) AND Gate 3 (build) must both fire
86
- must: ['search_ruvnet', 'ground', 'SPARC', 'DDD', 'frontend-design', 'image generation', 'API key', '98'],
86
+ // RuvNet facts still receive a grounding reminder; build workflow prose stays silent.
87
+ must: ['search_ruvnet', 'ground'],
88
+ mustNot: ['take the wheel', 'SPARC', 'DDD', 'QA gate', 'frontend-design', 'image generation', 'API key', 'PARALLEL', 'end your response'],
87
89
  },
88
90
  {
89
91
  name: 'classical-default drift',
90
92
  prompt: 'set up pinecone and langchain to do rag over my docs',
91
- // drift keywords → Gate 2 (hijack) must fire AND build keyword "set up" → Gate 3
92
- must: ['classical default', 'RuVector', 'Ruflo', 'AgentDB', 'take the wheel'],
93
+ // Drift guidance remains actionable, without the unrelated build playbook.
94
+ must: ['classical default', 'RuVector', 'Ruflo', 'AgentDB'],
95
+ mustNot: ['SPARC', 'DDD', 'QA gate', 'frontend-design', 'image generation', 'API key', 'PARALLEL', 'end your response'],
93
96
  },
94
97
  {
95
98
  name: 'pure recall (no build)',
96
99
  prompt: 'what can ruflo actually do',
97
100
  must: ['search_ruvnet', 'ground'],
98
- mustNot: ['take the wheel'], // no build verb → Gate 3 must stay silent
101
+ mustNot: ['take the wheel', 'SPARC', 'DDD', 'QA gate', 'frontend-design', 'image generation', 'API key', 'PARALLEL', 'end your response'], // no build script
99
102
  },
100
103
  ];
101
104
 
@@ -132,8 +132,8 @@ 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() {
136
- const { storeRoot, storesAt, cardsAt } = await import('../kb/store-root.mjs');
135
+ export async function readCoverage(rootOverride) {
136
+ const { storeRoot, storesAt, cardsAt, rootNeverMaterialized } = await import('../kb/store-root.mjs');
137
137
  // ROUTABILITY IS ALIAS-AWARE, BECAUSE THE ROUTER IS.
138
138
  //
139
139
  // `carded` was `stores.filter((s) => cards.has(s))` — a direct name match against card HEADINGS,
@@ -148,25 +148,39 @@ 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
+ //
159
+ // `rootNeverMaterialized()` (kb/store-root.mjs), not `fs.existsSync(root)`: the latter returns
160
+ // `true` for a stray FILE at `root` (ENOTDIR) or an unreadable directory (EACCES), so either
161
+ // rendered "materialized" here and let the exact same false-current-`0` back in for a different
162
+ // errno class than ENOENT — flagged as an accepted, unfixed gap by the ENOENT-only fix itself.
163
+ const neverMaterialized = rootNeverMaterialized(root);
152
164
  const stores = storesAt(root);
153
165
  const cards = new Set(cardsAt(root));
154
166
  const org = readJson('data/org-repo-count.json');
155
167
  const total = Number.isFinite(org?.count) && org.count > 0 ? org.count : null;
156
168
  const carded = stores.filter((s) => isRoutable(s, cards, root, repositoryNames)).length;
169
+ const absentDetail = 'store root does not exist on this host (never materialized) — not evidence of live coverage';
157
170
  return {
158
171
  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,
172
+ value: neverMaterialized ? null : (total ? Math.round((stores.length / total) * 1000) / 10 : null),
173
+ detail: neverMaterialized ? absentDetail
174
+ : (total ? `${stores.length}/${total} live repos` : `${stores.length} stores, org total UNKNOWN`),
175
+ at: neverMaterialized ? null : (org?.at ?? null),
162
176
  },
163
177
  routable: {
164
- value: stores.length ? Math.round((carded / stores.length) * 1000) / 10 : null,
165
- detail: `${carded}/${stores.length} built stores have a card`,
178
+ value: neverMaterialized || !stores.length ? null : Math.round((carded / stores.length) * 1000) / 10,
179
+ detail: neverMaterialized ? absentDetail : `${carded}/${stores.length} built stores have a card`,
166
180
  // Derived from the live store root at this instant, so it is current BY CONSTRUCTION. A
167
181
  // read-now value reported as stale would train the reader to ignore the stale flag, which
168
182
  // costs more than the flag is worth.
169
- at: new Date().toISOString(),
183
+ at: neverMaterialized ? null : new Date().toISOString(),
170
184
  },
171
185
  };
172
186
  }
@@ -12,6 +12,7 @@
12
12
  //
13
13
  // node scripts/build-bundle.mjs [--assets /path/to/release-assets]
14
14
  // [--out dist/ruvnet-brain] [--version v0.2.0-dev]
15
+ import crypto from 'node:crypto';
15
16
  import fs from 'node:fs';
16
17
  import os from 'node:os';
17
18
  import path from 'node:path';
@@ -19,6 +20,8 @@ import { spawnSync } from 'node:child_process';
19
20
  import { fileURLToPath } from 'node:url';
20
21
  import { getVersion, getVersionTag, stripTag } from './version.mjs';
21
22
  import { auditRvfIndexes } from './rvf-index-audit.mjs';
23
+ import { readRvfGenerations, validateSelectedRvfGenerations } from './rvf-generation.mjs';
24
+ import { validatePublicInventory } from './public-inventory.mjs';
22
25
  // The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
23
26
  // sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
24
27
  import { orgRepoCount } from './org-repo-count.mjs';
@@ -32,6 +35,8 @@ const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && proc
32
35
  // source/runtime files rooted in this exact checkout; only generated RVF families come from ASSETS.
33
36
  const ASSETS = path.resolve(ROOT, arg('--assets', 'kb'));
34
37
  const OUT = path.resolve(ROOT, arg('--out', 'dist/ruvnet-brain'));
38
+ const COVERAGE = arg('--coverage', null);
39
+ const PROJECTION = arg('--projection', null);
35
40
  const BRAIN_VERSION = arg('--version', getVersionTag()); // inherits the single source of truth
36
41
 
37
42
  // ---- registry: tier + the full 169-repo pending list -------------------------------------------
@@ -156,12 +161,58 @@ fs.rmSync(OUT, { recursive: true, force: true });
156
161
  // A failed rebuild must not leave an older archive looking like this invocation's output.
157
162
  fs.rmSync(ZIP, { force: true });
158
163
  fs.mkdirSync(OUT, { recursive: true });
159
-
160
- const built = discoverBuilt();
164
+ // The shipped bundle must carry the exact policy boundary used during assembly. The public
165
+ // inventory validator consumes this file from the candidate output, so reading it only from the
166
+ // source checkout would leave the release projection unable to prove its private-store exclusion.
167
+ cp(path.join(KB, 'PRIVATE-STORES.json'), OUT, { required: true });
168
+
169
+ let built = discoverBuilt();
170
+ let excludedPublicStores = [];
171
+ if (COVERAGE) {
172
+ const coverageFile = path.resolve(COVERAGE);
173
+ if (!fs.existsSync(coverageFile)) {
174
+ console.error(`[build-bundle] FATAL: coverage policy missing (${coverageFile})`);
175
+ process.exit(1);
176
+ }
177
+ let coverage;
178
+ try { coverage = JSON.parse(fs.readFileSync(coverageFile, 'utf8')); }
179
+ catch (error) {
180
+ console.error(`[build-bundle] FATAL: coverage policy unreadable (${error.message})`);
181
+ process.exit(1);
182
+ }
183
+ let inventory;
184
+ try {
185
+ inventory = validatePublicInventory({ assetsDir: ASSETS, coverage, ledger: readRvfGenerations(ASSETS) });
186
+ } catch (error) {
187
+ console.error(`[build-bundle] FATAL: public inventory is not exhaustive (${error.message})`);
188
+ process.exit(1);
189
+ }
190
+ const selected = new Set(inventory.publicStores);
191
+ built = built.filter((store) => selected.has(store.toLowerCase()));
192
+ excludedPublicStores = inventory.excludedRepositories;
193
+ const missing = inventory.publicStores.filter((store) => !built.some((candidate) => candidate.toLowerCase() === store));
194
+ if (missing.length) {
195
+ console.error(`[build-bundle] FATAL: classified public store(s) missing: ${missing.join(', ')}`);
196
+ process.exit(1);
197
+ }
198
+ if (excludedPublicStores.length) {
199
+ console.log(`[build-bundle] EXCLUDED ${excludedPublicStores.length} policy-ineligible public store(s): ${excludedPublicStores.join(', ')}`);
200
+ }
201
+ }
161
202
  if (built.length === 0) {
162
203
  console.error('[build-bundle] FATAL: zero public RVF stores are eligible for release. Refusing to publish an empty brain bundle.');
163
204
  process.exit(1);
164
205
  }
206
+ const generationValidation = validateSelectedRvfGenerations(ASSETS, {
207
+ selectedStores: built,
208
+ privateStores: [...PRIVATE_STORES],
209
+ excludedStores: excludedPublicStores,
210
+ });
211
+ if (generationValidation.failures.length) {
212
+ console.error('[build-bundle] FATAL: RVF generation ledger does not exactly bind selected roots:');
213
+ for (const failure of generationValidation.failures) console.error(` ${failure}`);
214
+ process.exit(1);
215
+ }
165
216
  const rvfIndexAudit = await auditRvfIndexes(
166
217
  built.map((name) => path.join(ASSETS, `${name}.big.rvf`)),
167
218
  );
@@ -208,7 +259,7 @@ for (const name of built) {
208
259
  if (!fs.existsSync(generationsFile)) {
209
260
  missing.push('RVF-GENERATIONS.json');
210
261
  } else {
211
- const source = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
262
+ const source = generationValidation.manifest;
212
263
  const stores = {};
213
264
  for (const { name } of builtRepos) {
214
265
  if (!source.stores?.[name]) missing.push(`RVF-GENERATIONS.json:${name}`);
@@ -216,8 +267,8 @@ for (const name of built) {
216
267
  }
217
268
  fs.writeFileSync(path.join(OUT, 'RVF-GENERATIONS.json'), `${JSON.stringify({
218
269
  schemaVersion: source.schemaVersion,
219
- brainVersion: source.brainVersion,
220
- releaseTag: source.releaseTag,
270
+ brainVersion: stripTag(BRAIN_VERSION),
271
+ releaseTag: BRAIN_VERSION,
221
272
  stores,
222
273
  }, null, 2)}\n`);
223
274
  copied++;
@@ -233,14 +284,16 @@ cpDir(path.join(ROOT, 'primer'), path.join(OUT, 'primer'));
233
284
  // time so code-implemented capabilities are retrievable as high-confidence prose. discoverRepos in the
234
285
  // bundle finds concepts.big.rvf automatically, so search_ruvnet searches it with no extra config.
235
286
  const hasConcepts = fs.existsSync(path.join(ASSETS, 'concepts.big.rvf'));
236
- if (hasConcepts) for (const suf of ['concepts.big.rvf', 'concepts.big.rvf.idmap.json', 'concepts.big.rvf.embed.json', 'concepts.big.passages.jsonl', 'concepts.big.meta.json', 'concepts.passages.jsonl', 'concepts.meta.json']) cp(suf, OUT, { asset: true });
287
+ if (hasConcepts) for (const suf of ['concepts.big.rvf', 'concepts.big.rvf.idmap.json', 'concepts.big.rvf.embed.json', 'concepts.big.passages.jsonl', 'concepts.big.meta.json', 'concepts.passages.jsonl', 'concepts.meta.json', 'concepts.sources.json']) cp(suf, OUT, { asset: true });
237
288
 
238
289
  // RUV-GISTS store (rUv's public gists — release notes / integration dossiers; big-only, same shape as
239
290
  // concepts, unioned by search_ruvnet at query time). BUG FIX: only `concepts` was special-cased above,
240
291
  // so ruv-gists — a PUBLIC store, not private-fenced — was silently omitted from EVERY bundle ever
241
292
  // shipped. Include it, WITH .big.passages.jsonl (the big variant opens it by name, same as concepts).
242
293
  const hasGists = fs.existsSync(path.join(ASSETS, 'ruv-gists.big.rvf'));
243
- if (hasGists) for (const suf of ['ruv-gists.big.rvf', 'ruv-gists.big.rvf.idmap.json', 'ruv-gists.big.rvf.embed.json', 'ruv-gists.big.passages.jsonl', 'ruv-gists.big.meta.json', 'ruv-gists.passages.jsonl', 'ruv-gists.meta.json']) cp(suf, OUT, { asset: true });
294
+ if (hasGists) for (const suf of ['ruv-gists.big.rvf', 'ruv-gists.big.rvf.idmap.json', 'ruv-gists.big.rvf.embed.json', 'ruv-gists.big.passages.jsonl', 'ruv-gists.big.meta.json', 'ruv-gists.passages.jsonl', 'ruv-gists.meta.json', 'ruv-gists.sources.json']) cp(suf, OUT, { asset: true });
295
+ // The inventory projection consumes this registry from the assembled bundle, not the checkout.
296
+ cp('public-store-classes.json', OUT, { asset: true });
244
297
 
245
298
  // capability-cards.md — the FAST LANE's zero-ML answer source (kb/card-lane.mjs, the first
246
299
  // responder search_ruvnet consults before the heavy cross-repo search). Ships as its own small
@@ -414,6 +467,14 @@ cp(path.join(ROOT, 'scripts', 'verify-bundle.mjs'), OUT, { required: true });
414
467
  fs.mkdirSync(path.join(OUT, 'keys'), { recursive: true });
415
468
  cp(path.join(ROOT, 'keys', 'ruvnet-brain-signing.pub.pem'), path.join(OUT, 'keys'), { required: true });
416
469
 
470
+ // ReleaseProjection is generated from the exact candidate asset tree immediately before this
471
+ // assembly. Copy all three linked ledgers together; a partial projection is never publishable.
472
+ if (PROJECTION) {
473
+ for (const file of ['COVERAGE.json', 'CORPUS-COVERAGE.json', 'PUBLIC-RVF-GENERATIONS.json']) {
474
+ cp(path.join(path.resolve(PROJECTION), file), OUT, { required: true });
475
+ }
476
+ }
477
+
417
478
  // ---- manifest ----------------------------------------------------------------------------------
418
479
  const builtLower = new Set(built.map((b) => b.toLowerCase()));
419
480
  const pendingRepos = regFlat.filter((r) => !builtLower.has(r.name.toLowerCase())).map((r) => ({ name: r.name, tier: r.tier }));
@@ -506,7 +567,32 @@ function collectArchiveFiles(dir, prefix = '') {
506
567
  }
507
568
  }
508
569
  collectArchiveFiles(OUT);
509
- archiveFiles.sort();
570
+ archiveFiles.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
571
+ // Bind the exact archive payload before release verification. The manifest excludes itself;
572
+ // verifiers recompute the same payload set and then validate this file's identities.
573
+ function archiveIdentity(relative) {
574
+ const file = path.join(OUT, relative);
575
+ const hash = crypto.createHash('sha256');
576
+ const fd = fs.openSync(file, 'r');
577
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
578
+ try {
579
+ let bytes;
580
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
581
+ } finally { fs.closeSync(fd); }
582
+ return { path: relative.split(path.sep).join('/'), sha256: hash.digest('hex'), bytes: fs.statSync(file).size };
583
+ }
584
+ const archiveManifest = {
585
+ schemaVersion: 1,
586
+ kind: 'ruvnet-brain-archive-manifest',
587
+ version: stripTag(BRAIN_VERSION),
588
+ releaseTag: BRAIN_VERSION,
589
+ fileCount: archiveFiles.length,
590
+ totalBytes: archiveFiles.reduce((total, file) => total + fs.statSync(path.join(OUT, file)).size, 0),
591
+ files: archiveFiles.map(archiveIdentity),
592
+ };
593
+ fs.writeFileSync(path.join(OUT, 'ARCHIVE-MANIFEST.json'), `${JSON.stringify(archiveManifest, null, 2)}\n`);
594
+ archiveFiles.push('ARCHIVE-MANIFEST.json');
595
+ archiveFiles.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
510
596
  const zipped = process.platform === 'win32'
511
597
  ? spawnSync('powershell.exe', [
512
598
  '-NoProfile',
@@ -0,0 +1,140 @@
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
+ // Repository names may contain RegExp metacharacters (`ruv.io` is already in this corpus). The
80
+ // heading lookup is literal identity, so escape the name before compiling it.
81
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+
83
+ export function insertSorted(file, card, name) {
84
+ const s = fs.readFileSync(file, 'utf8');
85
+ if (new RegExp(`^## ${escapeRegExp(name)}$`, 'mi').test(s)) return false;
86
+ const heads = [...s.matchAll(/^## (.+)$/gm)].map((m) => [m.index, m[1].trim().toLowerCase()]);
87
+ const pos = heads.find(([, n]) => n > name)?.[0];
88
+ fs.writeFileSync(file, pos === undefined ? `${s.replace(/\n+$/, '')}\n\n${card}` : s.slice(0, pos) + card + '\n' + s.slice(pos));
89
+ return true;
90
+ }
91
+
92
+ /** Apply one grounded card to every required surface without turning a failed write into success. */
93
+ export function applyCardToFiles(files, card, name, insert = insertSorted) {
94
+ let changed = 0;
95
+ const failures = [];
96
+ for (const file of files) {
97
+ try {
98
+ if (insert(file, card, name)) changed += 1;
99
+ } catch (error) {
100
+ failures.push({ file, error: String(error?.message || error) });
101
+ }
102
+ }
103
+ return { changed, failures };
104
+ }
105
+
106
+ const isMain = (() => {
107
+ try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
108
+ catch { return false; }
109
+ })();
110
+
111
+ if (isMain) {
112
+ const root = storeRoot();
113
+ const dark = darkStores(root);
114
+ console.log(`[card] ${dark.length} dark store(s) — valid bytes no by-description query can reach`);
115
+ if (!dark.length) process.exit(0);
116
+ if (!APPLY) {
117
+ console.log(`[card] ${dark.slice(0, 20).join(', ')}${dark.length > 20 ? ' …' : ''}`);
118
+ console.log('[card] report only. Re-run with --apply to write cards from each repo\'s own source.');
119
+ process.exit(0);
120
+ }
121
+ const repoCards = path.join(ROOT, 'kb', 'capability-cards.md');
122
+ const liveCards = path.join(root, 'capability-cards.md');
123
+ let wrote = 0; let skipped = 0; let failed = 0;
124
+ for (const name of dark.slice(0, MAX)) {
125
+ const card = buildCard(name, sourceFacts(name));
126
+ if (!card) { skipped += 1; console.log(`[card] ${name}: no description and no README prose — left dark rather than invented`); continue; }
127
+ const outcome = applyCardToFiles([repoCards, liveCards], card, name.toLowerCase());
128
+ if (outcome.failures.length) {
129
+ failed += 1;
130
+ console.error(`[card] ${name}: FAILED/PARTIAL (${outcome.changed}/2 surfaces changed) — `
131
+ + outcome.failures.map(({ file, error }) => `${file}: ${error}`).join('; '));
132
+ continue;
133
+ }
134
+ if (outcome.changed > 0) wrote += 1;
135
+ else skipped += 1;
136
+ }
137
+ const remaining = darkStores(root).length;
138
+ console.log(`\n[card] wrote ${wrote}, skipped ${skipped}, failed ${failed}, ${remaining} still dark.`);
139
+ if (failed) process.exitCode = 1;
140
+ }