ruvnet-brain 4.2.2-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 (73) hide show
  1. package/README.md +15 -14
  2. package/bin/install.mjs +11 -2
  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/ground-ruvnet.sh +2 -2
  23. package/plugin/scripts/nightly-controller.mjs +19 -2
  24. package/plugin/scripts/project-progression-contract.mjs +409 -0
  25. package/plugin/scripts/project-progression-hook.mjs +196 -0
  26. package/plugin/scripts/project-progression-outbox.mjs +95 -0
  27. package/plugin/scripts/project-progression-session-start.mjs +183 -0
  28. package/plugin/scripts/project-progression-store.mjs +248 -0
  29. package/plugin/scripts/project-store-resolver.mjs +106 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +29 -1
  31. package/plugin/scripts/session-start-core.mjs +22 -18
  32. package/plugin/scripts/spend-guard.mjs +6 -1
  33. package/scripts/adr-072-completion.mjs +98 -0
  34. package/scripts/behavioral-l1-l4.mjs +11 -8
  35. package/scripts/brain-score.mjs +7 -2
  36. package/scripts/build-bundle.mjs +82 -6
  37. package/scripts/card-from-source.mjs +32 -6
  38. package/scripts/corpus-aggregates.mjs +194 -0
  39. package/scripts/corpus-candidate.mjs +56 -36
  40. package/scripts/corpus-reconcile.mjs +295 -28
  41. package/scripts/corpus-seed-publish.mjs +3 -3
  42. package/scripts/coverage-integrity.mjs +3 -0
  43. package/scripts/gist-receipts.mjs +183 -0
  44. package/scripts/git-hooks/pre-push +21 -0
  45. package/scripts/host-registry.mjs +118 -0
  46. package/scripts/independent-review-receipt.mjs +499 -0
  47. package/scripts/ingest-gists.mjs +34 -5
  48. package/scripts/ingest-new-repos.mjs +5 -0
  49. package/scripts/metaharness-gate.mjs +58 -0
  50. package/scripts/nightly-gists.sh +3 -3
  51. package/scripts/nightly-wrapper.sh +75 -69
  52. package/scripts/product-integrity-contract.mjs +156 -0
  53. package/scripts/public-inventory.mjs +4 -0
  54. package/scripts/public-verification-aggregate.mjs +342 -0
  55. package/scripts/public-verification-finalizer.mjs +59 -0
  56. package/scripts/public-verification-inputs.mjs +473 -0
  57. package/scripts/public-verification-lane.mjs +244 -0
  58. package/scripts/publication-receipt.mjs +50 -6
  59. package/scripts/rebuild-gists-from-receipts.mjs +50 -5
  60. package/scripts/release-projection.mjs +86 -0
  61. package/scripts/release-transaction-provider.mjs +43 -102
  62. package/scripts/release-transaction.mjs +139 -51
  63. package/scripts/release.mjs +15 -7
  64. package/scripts/restore-local-ingests.mjs +2 -2
  65. package/scripts/retrieval-canary.mjs +533 -0
  66. package/scripts/routing-flywheel.mjs +12 -1
  67. package/scripts/rvf-generation.mjs +9 -3
  68. package/scripts/self-update.mjs +19 -44
  69. package/scripts/source-coverage.mjs +415 -0
  70. package/scripts/source-scope-receipt.mjs +84 -0
  71. package/scripts/sync-census.mjs +0 -0
  72. package/scripts/wired-check.mjs +114 -24
  73. 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
 
@@ -133,7 +133,7 @@ function readPanel() {
133
133
  * than an absent one.
134
134
  */
135
135
  export async function readCoverage(rootOverride) {
136
- const { storeRoot, storesAt, cardsAt } = await import('../kb/store-root.mjs');
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,
@@ -155,7 +155,12 @@ export async function readCoverage(rootOverride) {
155
155
  // nightly agent's own ephemeral container) reads IDENTICALLY to a materialized root that
156
156
  // genuinely has zero coverage. Checked once, here, before either derived number is computed —
157
157
  // a wrong `0` still renders, which is worse than an honestly unmeasured one.
158
- const neverMaterialized = !fs.existsSync(root);
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);
159
164
  const stores = storesAt(root);
160
165
  const cards = new Set(cardsAt(root));
161
166
  const org = readJson('data/org-repo-count.json');
@@ -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,7 +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';
22
- import { validateSelectedRvfGenerations } from './rvf-generation.mjs';
23
+ import { readRvfGenerations, validateSelectedRvfGenerations } from './rvf-generation.mjs';
24
+ import { validatePublicInventory } from './public-inventory.mjs';
23
25
  // The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
24
26
  // sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
25
27
  import { orgRepoCount } from './org-repo-count.mjs';
@@ -33,6 +35,8 @@ const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && proc
33
35
  // source/runtime files rooted in this exact checkout; only generated RVF families come from ASSETS.
34
36
  const ASSETS = path.resolve(ROOT, arg('--assets', 'kb'));
35
37
  const OUT = path.resolve(ROOT, arg('--out', 'dist/ruvnet-brain'));
38
+ const COVERAGE = arg('--coverage', null);
39
+ const PROJECTION = arg('--projection', null);
36
40
  const BRAIN_VERSION = arg('--version', getVersionTag()); // inherits the single source of truth
37
41
 
38
42
  // ---- registry: tier + the full 169-repo pending list -------------------------------------------
@@ -157,8 +161,44 @@ fs.rmSync(OUT, { recursive: true, force: true });
157
161
  // A failed rebuild must not leave an older archive looking like this invocation's output.
158
162
  fs.rmSync(ZIP, { force: true });
159
163
  fs.mkdirSync(OUT, { recursive: true });
160
-
161
- 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
+ }
162
202
  if (built.length === 0) {
163
203
  console.error('[build-bundle] FATAL: zero public RVF stores are eligible for release. Refusing to publish an empty brain bundle.');
164
204
  process.exit(1);
@@ -166,6 +206,7 @@ if (built.length === 0) {
166
206
  const generationValidation = validateSelectedRvfGenerations(ASSETS, {
167
207
  selectedStores: built,
168
208
  privateStores: [...PRIVATE_STORES],
209
+ excludedStores: excludedPublicStores,
169
210
  });
170
211
  if (generationValidation.failures.length) {
171
212
  console.error('[build-bundle] FATAL: RVF generation ledger does not exactly bind selected roots:');
@@ -243,14 +284,16 @@ cpDir(path.join(ROOT, 'primer'), path.join(OUT, 'primer'));
243
284
  // time so code-implemented capabilities are retrievable as high-confidence prose. discoverRepos in the
244
285
  // bundle finds concepts.big.rvf automatically, so search_ruvnet searches it with no extra config.
245
286
  const hasConcepts = fs.existsSync(path.join(ASSETS, 'concepts.big.rvf'));
246
- 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 });
247
288
 
248
289
  // RUV-GISTS store (rUv's public gists — release notes / integration dossiers; big-only, same shape as
249
290
  // concepts, unioned by search_ruvnet at query time). BUG FIX: only `concepts` was special-cased above,
250
291
  // so ruv-gists — a PUBLIC store, not private-fenced — was silently omitted from EVERY bundle ever
251
292
  // shipped. Include it, WITH .big.passages.jsonl (the big variant opens it by name, same as concepts).
252
293
  const hasGists = fs.existsSync(path.join(ASSETS, 'ruv-gists.big.rvf'));
253
- 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 });
254
297
 
255
298
  // capability-cards.md — the FAST LANE's zero-ML answer source (kb/card-lane.mjs, the first
256
299
  // responder search_ruvnet consults before the heavy cross-repo search). Ships as its own small
@@ -424,6 +467,14 @@ cp(path.join(ROOT, 'scripts', 'verify-bundle.mjs'), OUT, { required: true });
424
467
  fs.mkdirSync(path.join(OUT, 'keys'), { recursive: true });
425
468
  cp(path.join(ROOT, 'keys', 'ruvnet-brain-signing.pub.pem'), path.join(OUT, 'keys'), { required: true });
426
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
+
427
478
  // ---- manifest ----------------------------------------------------------------------------------
428
479
  const builtLower = new Set(built.map((b) => b.toLowerCase()));
429
480
  const pendingRepos = regFlat.filter((r) => !builtLower.has(r.name.toLowerCase())).map((r) => ({ name: r.name, tier: r.tier }));
@@ -516,7 +567,32 @@ function collectArchiveFiles(dir, prefix = '') {
516
567
  }
517
568
  }
518
569
  collectArchiveFiles(OUT);
519
- 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);
520
596
  const zipped = process.platform === 'win32'
521
597
  ? spawnSync('powershell.exe', [
522
598
  '-NoProfile',
@@ -76,15 +76,33 @@ export function buildCard(repo, { description, readme }) {
76
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
77
  }
78
78
 
79
- function insertSorted(file, card, name) {
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) {
80
84
  const s = fs.readFileSync(file, 'utf8');
81
- if (new RegExp(`^## ${name}$`, 'mi').test(s)) return false;
85
+ if (new RegExp(`^## ${escapeRegExp(name)}$`, 'mi').test(s)) return false;
82
86
  const heads = [...s.matchAll(/^## (.+)$/gm)].map((m) => [m.index, m[1].trim().toLowerCase()]);
83
87
  const pos = heads.find(([, n]) => n > name)?.[0];
84
88
  fs.writeFileSync(file, pos === undefined ? `${s.replace(/\n+$/, '')}\n\n${card}` : s.slice(0, pos) + card + '\n' + s.slice(pos));
85
89
  return true;
86
90
  }
87
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
+
88
106
  const isMain = (() => {
89
107
  try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
90
108
  catch { return false; }
@@ -102,13 +120,21 @@ if (isMain) {
102
120
  }
103
121
  const repoCards = path.join(ROOT, 'kb', 'capability-cards.md');
104
122
  const liveCards = path.join(root, 'capability-cards.md');
105
- let wrote = 0; let skipped = 0;
123
+ let wrote = 0; let skipped = 0; let failed = 0;
106
124
  for (const name of dark.slice(0, MAX)) {
107
125
  const card = buildCard(name, sourceFacts(name));
108
126
  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;
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;
111
136
  }
112
137
  const remaining = darkStores(root).length;
113
- console.log(`\n[card] wrote ${wrote}, left ${skipped} dark for lack of grounded source text, ${remaining} still dark.`);
138
+ console.log(`\n[card] wrote ${wrote}, skipped ${skipped}, failed ${failed}, ${remaining} still dark.`);
139
+ if (failed) process.exitCode = 1;
114
140
  }