ruvnet-brain 4.0.1 → 4.0.4

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 (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -0
  2. package/README.md +4 -4
  3. package/bin/install.mjs +303 -24
  4. package/console/CONTRACT.md +172 -0
  5. package/console/activity.js +753 -0
  6. package/console/app.js +4189 -0
  7. package/console/architecture.html +1221 -0
  8. package/console/assets/depth-1.webp +0 -0
  9. package/console/assets/depth-2.webp +0 -0
  10. package/console/assets/depth-3.webp +0 -0
  11. package/console/assets/harness-vs-plain.svg +259 -0
  12. package/console/assets/hero.webp +0 -0
  13. package/console/assets/memory.webp +0 -0
  14. package/console/assets/metaharness.svg +247 -0
  15. package/console/index.html +777 -0
  16. package/console/install-architecture.html +162 -0
  17. package/console/install-mockup.html +543 -0
  18. package/console/style.css +2144 -0
  19. package/console/tips.css +926 -0
  20. package/console/tips.html +858 -0
  21. package/console/tips.js +128 -0
  22. package/docs/RELEASE-NOTES-4.0.md +88 -0
  23. package/kb/model-requirements.mjs +37 -6
  24. package/keys/ruvnet-brain-signing.pub.pem +3 -0
  25. package/package.json +8 -22
  26. package/plugin/.claude-plugin/marketplace.json +1 -0
  27. package/plugin/.claude-plugin/plugin.json +2 -3
  28. package/plugin/.codex-plugin/plugin.json +1 -1
  29. package/plugin/commands/brain-console.md +2 -2
  30. package/plugin/commands/configure.md +3 -2
  31. package/plugin/commands/rvbc.md +4 -3
  32. package/plugin/commands/rvcb.md +2 -2
  33. package/plugin/commands/whats-new.md +6 -6
  34. package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
  35. package/plugin/hooks/hooks.json +1 -2
  36. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  37. package/plugin/mcp/server.mjs +90 -32
  38. package/plugin/scripts/detach.mjs +14 -0
  39. package/plugin/scripts/first-session-worker.mjs +38 -0
  40. package/plugin/scripts/ground-ruvnet.sh +16 -6
  41. package/plugin/scripts/hook-shim.mjs +34 -29
  42. package/plugin/scripts/learn-capture.sh +22 -3
  43. package/plugin/scripts/learn-flush.mjs +21 -4
  44. package/plugin/scripts/runtime-preferences.mjs +269 -0
  45. package/plugin/scripts/session-start-core.mjs +503 -0
  46. package/plugin/scripts/session-start.sh +3 -858
  47. package/plugin/scripts/whats-new.mjs +42 -0
  48. package/plugin/skills/brain-console/SKILL.md +4 -2
  49. package/plugin/skills/release-proof/SKILL.md +98 -0
  50. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  51. package/plugin/skills/release-proof/references/receipt-contract.md +44 -0
  52. package/plugin/skills/release-proof/scripts/release-proof.mjs +286 -0
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
  54. package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
  55. package/plugin/skills/rvbc/SKILL.md +9 -6
  56. package/plugin/skills/whats-new/SKILL.md +4 -4
  57. package/scripts/adr-backfill.mjs +107 -0
  58. package/scripts/advocacy-outcomes.mjs +808 -0
  59. package/scripts/agentdb-context.mjs +216 -0
  60. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  61. package/scripts/ascii-drift.mjs +236 -0
  62. package/scripts/behavioral-l1-l4.mjs +210 -0
  63. package/scripts/brain-capability-check.mjs +72 -0
  64. package/scripts/brain-grade-groundtruth.mjs +100 -0
  65. package/scripts/brain-latency-50.mjs +227 -0
  66. package/scripts/brain-novice-50.mjs +189 -0
  67. package/scripts/brain-stamp.mjs +94 -0
  68. package/scripts/brain-state.mjs +212 -0
  69. package/scripts/build-bundle.mjs +531 -0
  70. package/scripts/build-concepts.mjs +132 -0
  71. package/scripts/build-l2.mjs +71 -0
  72. package/scripts/build-primer.mjs +73 -0
  73. package/scripts/build-symbols.mjs +68 -0
  74. package/scripts/calibrate-router.mjs +97 -0
  75. package/scripts/capability-audit.mjs +321 -0
  76. package/scripts/capability-registry.mjs +876 -0
  77. package/scripts/check-indexation.mjs +108 -0
  78. package/scripts/check-legibility.mjs +189 -0
  79. package/scripts/ci/build-fixture-kb.mjs +67 -0
  80. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  81. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  82. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  83. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  84. package/scripts/ci/stranger-scenario.mjs +228 -0
  85. package/scripts/ci/stranger-timeout.mjs +25 -0
  86. package/scripts/ci-verdict.mjs +29 -0
  87. package/scripts/claims-verify.mjs +710 -0
  88. package/scripts/clear-claude-tmp.sh +31 -0
  89. package/scripts/console-engine.mjs +434 -0
  90. package/scripts/console-engine.test.mjs +125 -0
  91. package/scripts/corpus-qa.mjs +250 -0
  92. package/scripts/correction-detect-embed.mjs +346 -0
  93. package/scripts/correction-detect-measure.mjs +270 -0
  94. package/scripts/correction-detect.mjs +686 -0
  95. package/scripts/count-chunks.mjs +54 -0
  96. package/scripts/described-questions.json +30 -0
  97. package/scripts/design-grade.mjs +58 -0
  98. package/scripts/dev-plugin-link.sh +105 -0
  99. package/scripts/distill-project.mjs +200 -0
  100. package/scripts/doc-currency.mjs +801 -0
  101. package/scripts/eval-brain.mjs +244 -0
  102. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  103. package/scripts/fix-workstream.mjs +291 -0
  104. package/scripts/full-hints.mjs +87 -0
  105. package/scripts/gate.sh +39 -0
  106. package/scripts/gates.mjs +146 -0
  107. package/scripts/gen-console-images.mjs +54 -0
  108. package/scripts/gen-images.mjs +47 -0
  109. package/scripts/git-clone-refresh.mjs +52 -0
  110. package/scripts/git-hooks/pre-push +126 -0
  111. package/scripts/goal-match.mjs +398 -0
  112. package/scripts/goldie-research.mjs +223 -0
  113. package/scripts/goldie-weekly.sh +67 -0
  114. package/scripts/health-repair.mjs +237 -0
  115. package/scripts/helix-scenario-questions.json +10 -0
  116. package/scripts/ingest-gists.mjs +230 -0
  117. package/scripts/ingest-meeting.mjs +115 -0
  118. package/scripts/ingest-repo.mjs +79 -0
  119. package/scripts/install-npx-witness.sh +49 -0
  120. package/scripts/issue-fix.mjs +558 -0
  121. package/scripts/issue-watch.mjs +276 -0
  122. package/scripts/issue4-close-note.md +31 -0
  123. package/scripts/key-canary.mjs +91 -0
  124. package/scripts/latency-to-surface.mjs +233 -0
  125. package/scripts/learning-enable.mjs +380 -0
  126. package/scripts/learning-replay.mjs +1570 -0
  127. package/scripts/learnings.mjs +62 -0
  128. package/scripts/lesson-gate.mjs +680 -0
  129. package/scripts/lesson-lifecycle.mjs +449 -0
  130. package/scripts/lesson-promote.mjs +262 -0
  131. package/scripts/lesson-ratify.mjs +98 -0
  132. package/scripts/lesson-seed.mjs +252 -0
  133. package/scripts/lesson-store.mjs +447 -0
  134. package/scripts/loop-checkpoint.mjs +86 -0
  135. package/scripts/memdb-health.sh +14 -0
  136. package/scripts/memory-doctor.mjs +326 -0
  137. package/scripts/model-catalog.mjs +79 -0
  138. package/scripts/nightly-controller.mjs +66 -0
  139. package/scripts/nightly-gists.sh +72 -0
  140. package/scripts/nightly-wrapper.sh +172 -0
  141. package/scripts/notify.sh +12 -0
  142. package/scripts/npx-witness.sh +56 -0
  143. package/scripts/onboarding-console.mjs +2922 -0
  144. package/scripts/private-fence.mjs +69 -0
  145. package/scripts/proactivity-metrics.mjs +118 -0
  146. package/scripts/proof-questions.json +56 -0
  147. package/scripts/protected-release-invocation.mjs +76 -0
  148. package/scripts/prove.mjs +95 -0
  149. package/scripts/proxy/claude-proxied.sh +57 -0
  150. package/scripts/proxy/proxy-revert.sh +59 -0
  151. package/scripts/proxy/proxy-up.sh +60 -0
  152. package/scripts/proxy/proxy-verify.mjs +142 -0
  153. package/scripts/publication-receipt.mjs +307 -0
  154. package/scripts/published-surface-probe.mjs +241 -0
  155. package/scripts/qe/card-lane-gate.mjs +162 -0
  156. package/scripts/qe/session-start-gate.mjs +229 -0
  157. package/scripts/qe/ux-suite.mjs +323 -0
  158. package/scripts/reconcile-project.mjs +0 -0
  159. package/scripts/record-lesson.mjs +113 -0
  160. package/scripts/refresh-model-catalog.mjs +99 -0
  161. package/scripts/release-authority.mjs +93 -0
  162. package/scripts/release-proof.mjs +9 -0
  163. package/scripts/release-vector.mjs +281 -0
  164. package/scripts/release.mjs +439 -0
  165. package/scripts/remedy-registry.mjs +247 -0
  166. package/scripts/rerank-cap-eval.mjs +265 -0
  167. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  168. package/scripts/route-cheap.mjs +20 -15
  169. package/scripts/router-utilization.mjs +182 -0
  170. package/scripts/routing-flywheel.mjs +596 -0
  171. package/scripts/rvf-generation.mjs +104 -0
  172. package/scripts/rvf-index-audit.mjs +138 -0
  173. package/scripts/self-update.mjs +296 -0
  174. package/scripts/selfcheck.mjs +7 -1
  175. package/scripts/sign-bundle.mjs +69 -0
  176. package/scripts/signal-watch.mjs +171 -0
  177. package/scripts/stabilization-receipt.mjs +108 -0
  178. package/scripts/stack-sync.mjs +469 -0
  179. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  180. package/scripts/stamp-sweep.mjs +144 -0
  181. package/scripts/status-honesty.mjs +102 -0
  182. package/scripts/sync-version.mjs +217 -0
  183. package/scripts/token-report.mjs +102 -0
  184. package/scripts/top100-benchmark.mjs +479 -0
  185. package/scripts/top100-corpus.mjs +112 -0
  186. package/scripts/top100-semantic-assertions.mjs +449 -0
  187. package/scripts/update-apply.mjs +9 -0
  188. package/scripts/upgrade-notice.mjs +14 -0
  189. package/scripts/verify-bundle.mjs +51 -0
  190. package/scripts/verify-channels.mjs +184 -0
  191. package/scripts/verify-model-catalog.mjs +104 -0
  192. package/scripts/verify-nightly-close-issue4.sh +31 -0
  193. package/scripts/version.mjs +40 -0
  194. package/scripts/wired-check.mjs +867 -0
  195. package/plugin/scripts/finalize-token-meter.mjs +0 -25
@@ -0,0 +1,104 @@
1
+ // rvf-generation.mjs — bind each canonical RVF byte generation to the Brain product version.
2
+ //
3
+ // @ruvector/rvf's Node API does not currently expose a supported custom-manifest writer for an
4
+ // existing store. This checksum-bound companion is therefore the fail-closed identity record:
5
+ // changing even one RVF byte changes sha256, while brainVersion/releaseTag identify the npm and
6
+ // GitHub release that owns those exact bytes.
7
+
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { getVersion, getVersionTag } from './version.mjs';
12
+
13
+ export const RVF_GENERATIONS_FILE = 'RVF-GENERATIONS.json';
14
+
15
+ export function canonicalRvfStores(dir) {
16
+ return fs.readdirSync(dir)
17
+ .map((file) => file.match(/^(.+)\.big\.rvf$/)?.[1])
18
+ .filter(Boolean)
19
+ .sort();
20
+ }
21
+
22
+ export function hasCanonicalRvfStore(dir, name) {
23
+ const wanted = String(name).toLowerCase();
24
+ return canonicalRvfStores(dir).some((store) => store.toLowerCase() === wanted);
25
+ }
26
+
27
+ export function sha256File(file) {
28
+ const hash = crypto.createHash('sha256');
29
+ const fd = fs.openSync(file, 'r');
30
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
31
+ try {
32
+ let bytes;
33
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
34
+ hash.update(buffer.subarray(0, bytes));
35
+ }
36
+ } finally {
37
+ fs.closeSync(fd);
38
+ }
39
+ return hash.digest('hex');
40
+ }
41
+
42
+ export function readRvfGenerations(dir) {
43
+ const file = path.join(dir, RVF_GENERATIONS_FILE);
44
+ if (!fs.existsSync(file)) {
45
+ return { schemaVersion: 1, brainVersion: getVersion(), releaseTag: getVersionTag(), stores: {} };
46
+ }
47
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
48
+ parsed.stores ||= {};
49
+ return parsed;
50
+ }
51
+
52
+ export function writeRvfGeneration({
53
+ dir,
54
+ store,
55
+ rvfFile = `${store}.big.rvf`,
56
+ model,
57
+ dimensions,
58
+ sourceCommit = null,
59
+ builtUtc = new Date().toISOString(),
60
+ previousDir = dir,
61
+ }) {
62
+ const rvfPath = path.join(dir, rvfFile);
63
+ if (!fs.existsSync(rvfPath)) throw new Error(`cannot stamp missing RVF: ${rvfPath}`);
64
+ const manifest = readRvfGenerations(previousDir);
65
+ manifest.schemaVersion = 1;
66
+ manifest.brainVersion = getVersion();
67
+ manifest.releaseTag = getVersionTag();
68
+ manifest.stores[store] = {
69
+ file: rvfFile,
70
+ sha256: sha256File(rvfPath),
71
+ bytes: fs.statSync(rvfPath).size,
72
+ model,
73
+ dimensions,
74
+ sourceCommit,
75
+ builtUtc,
76
+ };
77
+ fs.writeFileSync(path.join(dir, RVF_GENERATIONS_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
78
+ return manifest.stores[store];
79
+ }
80
+
81
+ export function verifyRvfGenerations(dir, {
82
+ version = getVersion(),
83
+ releaseTag = getVersionTag(),
84
+ requiredStores = [],
85
+ allowMissingFiles = false,
86
+ } = {}) {
87
+ const manifest = readRvfGenerations(dir);
88
+ const failures = [];
89
+ if (manifest.brainVersion !== version) failures.push(`brainVersion=${manifest.brainVersion}, expected ${version}`);
90
+ if (manifest.releaseTag !== releaseTag) failures.push(`releaseTag=${manifest.releaseTag}, expected ${releaseTag}`);
91
+ for (const store of requiredStores) {
92
+ if (!manifest.stores[store]) failures.push(`${store}: no generation record`);
93
+ }
94
+ for (const [store, generation] of Object.entries(manifest.stores)) {
95
+ const file = path.join(dir, generation.file || `${store}.big.rvf`);
96
+ if (!fs.existsSync(file)) {
97
+ if (!allowMissingFiles) failures.push(`${store}: missing ${path.basename(file)}`);
98
+ continue;
99
+ }
100
+ const actual = sha256File(file);
101
+ if (actual !== generation.sha256) failures.push(`${store}: sha256=${actual}, recorded ${generation.sha256}`);
102
+ }
103
+ return { manifest, failures };
104
+ }
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadRvf, closeReadonlyRvf } from '../kb/resolve-deps.mjs';
6
+ import { persistAndVerifyRvfIndex } from '../kb/rvf-index.mjs';
7
+ import {
8
+ readRvfGenerations,
9
+ sha256File,
10
+ writeRvfGeneration,
11
+ } from './rvf-generation.mjs';
12
+
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ export const RVF_HNSW_THRESHOLD = 1024;
15
+
16
+ function dimensionsFor(rvfPath) {
17
+ const embedPath = `${rvfPath}.embed.json`;
18
+ const config = JSON.parse(fs.readFileSync(embedPath, 'utf8'));
19
+ const dimensions = Number(config.dimensions);
20
+ if (!Number.isInteger(dimensions) || dimensions < 1) {
21
+ throw new Error(`invalid dimensions in ${embedPath}`);
22
+ }
23
+ return dimensions;
24
+ }
25
+
26
+ export async function inspectRvfIndex(rvfPath, RvfDatabase) {
27
+ const db = await RvfDatabase.openReadonly(rvfPath);
28
+ try {
29
+ const [status, segments] = await Promise.all([db.status(), db.segments()]);
30
+ const hasIndex = segments.some(({ segType }) => segType === 'index');
31
+ return {
32
+ path: rvfPath,
33
+ totalVectors: status.totalVectors,
34
+ indexRequired: status.totalVectors >= RVF_HNSW_THRESHOLD,
35
+ hasIndex,
36
+ state: status.totalVectors >= RVF_HNSW_THRESHOLD && !hasIndex ? 'FAIL' : 'PASS',
37
+ };
38
+ } finally {
39
+ await closeReadonlyRvf(db);
40
+ }
41
+ }
42
+
43
+ export async function auditRvfIndexes(rvfPaths) {
44
+ const { mod: { RvfDatabase } } = loadRvf();
45
+ return Promise.all(rvfPaths.map((rvfPath) => inspectRvfIndex(rvfPath, RvfDatabase)));
46
+ }
47
+
48
+ export async function repairRvfIndex(rvfPath, RvfDatabase) {
49
+ const before = await inspectRvfIndex(rvfPath, RvfDatabase);
50
+ if (before.state === 'PASS') return { ...before, repaired: false, elapsedMs: 0 };
51
+
52
+ const started = Date.now();
53
+ const db = await RvfDatabase.open(rvfPath);
54
+ const proof = await persistAndVerifyRvfIndex({
55
+ db,
56
+ dimensions: dimensionsFor(rvfPath),
57
+ rvfPath,
58
+ RvfDatabase,
59
+ });
60
+ return {
61
+ ...before,
62
+ hasIndex: proof.hasIndex,
63
+ state: proof.hasIndex ? 'PASS' : 'FAIL',
64
+ repaired: proof.hasIndex,
65
+ elapsedMs: Date.now() - started,
66
+ };
67
+ }
68
+
69
+ export function restampChangedRvfGenerations(kbDir, results) {
70
+ const ledger = readRvfGenerations(kbDir);
71
+ let stamped = 0;
72
+ for (const result of results) {
73
+ if (result.state !== 'PASS' || !result.path.endsWith('.big.rvf')) continue;
74
+ const store = path.basename(result.path, '.big.rvf');
75
+ const prior = ledger.stores?.[store];
76
+ if (!prior || prior.sha256 === sha256File(result.path)) continue;
77
+ const embed = JSON.parse(fs.readFileSync(`${result.path}.embed.json`, 'utf8'));
78
+ writeRvfGeneration({
79
+ dir: kbDir,
80
+ store,
81
+ rvfFile: path.basename(result.path),
82
+ model: prior.model || embed.model || null,
83
+ dimensions: prior.dimensions || Number(embed.dimensions),
84
+ sourceCommit: prior.sourceCommit ?? null,
85
+ });
86
+ stamped++;
87
+ }
88
+ return stamped;
89
+ }
90
+
91
+ async function main() {
92
+ const repair = process.argv.includes('--repair');
93
+ const concurrencyArg = process.argv.indexOf('--concurrency');
94
+ const concurrency = Math.max(1, Number(concurrencyArg >= 0 ? process.argv[concurrencyArg + 1] : 3));
95
+ const namesAt = process.argv.indexOf('--names');
96
+ const names = namesAt >= 0
97
+ ? new Set(String(process.argv[namesAt + 1] || '').split(',').filter(Boolean))
98
+ : null;
99
+ const kbDirAt = process.argv.indexOf('--dir');
100
+ const kbDir = path.resolve(ROOT, kbDirAt >= 0 ? process.argv[kbDirAt + 1] : 'kb');
101
+ const rvfPaths = fs.readdirSync(kbDir)
102
+ .filter((name) => name.endsWith('.rvf') && (!names || names.has(name.replace(/\.rvf$/, ''))))
103
+ .sort()
104
+ .map((name) => path.join(kbDir, name));
105
+ const { mod: { RvfDatabase } } = loadRvf();
106
+ const results = new Array(rvfPaths.length);
107
+ let next = 0;
108
+
109
+ await Promise.all(Array.from({ length: Math.min(concurrency, rvfPaths.length || 1) }, async () => {
110
+ for (let index = next++; index < rvfPaths.length; index = next++) {
111
+ const rvfPath = rvfPaths[index];
112
+ try {
113
+ results[index] = repair
114
+ ? await repairRvfIndex(rvfPath, RvfDatabase)
115
+ : await inspectRvfIndex(rvfPath, RvfDatabase);
116
+ } catch (error) {
117
+ results[index] = { path: rvfPath, state: 'FAIL', error: error.message };
118
+ }
119
+ const row = results[index];
120
+ console.log(JSON.stringify({ ...row, path: path.relative(ROOT, row.path) }));
121
+ }
122
+ }));
123
+
124
+ const failed = results.filter(({ state }) => state !== 'PASS');
125
+ const repaired = results.filter(({ repaired }) => repaired).length;
126
+ const stamped = repair && !failed.length
127
+ ? restampChangedRvfGenerations(kbDir, results)
128
+ : 0;
129
+ console.log(`SUMMARY checked=${results.length} repaired=${repaired} stamped=${stamped} failed=${failed.length}`);
130
+ if (failed.length) process.exitCode = 1;
131
+ }
132
+
133
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
134
+ main().catch((error) => {
135
+ console.error(error.stack || error.message);
136
+ process.exitCode = 1;
137
+ });
138
+ }
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ // self-update.mjs — nightly evergreen driver.
3
+ // Compares each in-scope repo's live HEAD against the stamped manifest, then (re)builds the segments
4
+ // that changed and re-stamps. Dry-run by default; pass --apply to actually (re)build.
5
+ //
6
+ // node scripts/self-update.mjs # dry-run: print the rebuild plan
7
+ // node scripts/self-update.mjs --apply # rebuild stale/pending repos (serial; embedding is CPU-bound)
8
+ // node scripts/self-update.mjs --apply --tier T0 # limit scope
9
+ // node scripts/self-update.mjs --apply --repo ruflo
10
+ //
11
+ // Publication is intentionally NOT supported here. A scheduled or local rebuild may prepare
12
+ // candidate bytes, but only the protected release workflow may publish them after release-proof
13
+ // seals the exact clean SHA and packed-artifact digest.
14
+ //
15
+ // Designed to be invoked by deploy/com.ruvnet.brain-nightly.plist (LaunchAgent — NOT auto-installed).
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { execFileSync } from 'node:child_process';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
22
+ import { withSubmoduleSymlinksDetached } from './git-clone-refresh.mjs';
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+
26
+ // launchd delivers a minimal PATH, and the plist-level export has now failed across TWO separate
27
+ // reloads for TWO separate reasons: (2026-07-10) `gh release create` ENOENT, pid 0, while the
28
+ // loaded plist showed the export; (2026-07-12) launchd's WorkingDirectory was unset entirely so
29
+ // the job never even reached this file. Both are now fixed at the plist level too, but the script
30
+ // guarantees its own environment regardless: /usr/local/bin FIRST (that's the node every build
31
+ // tonight was tested and gated against — 22.13.1; homebrew independently upgraded to 25.9.0,
32
+ // which cannot load kb/'s native @ruvector/rvf module) and every spawned tool resolved to an
33
+ // absolute path, never bare-name PATH lookup — a bare `node`/`gh` silently picks up whichever
34
+ // version macOS or homebrew put first, and that has now broken this pipeline twice.
35
+ process.env.PATH = ['/usr/local/bin', '/opt/homebrew/bin', process.env.PATH || ''].join(':');
36
+ const GH = ['/usr/local/bin/gh', '/opt/homebrew/bin/gh'].find((p) => fs.existsSync(p)) || 'gh';
37
+ const NODE = ['/usr/local/bin/node', '/opt/homebrew/bin/node'].find((p) => fs.existsSync(p)) || process.execPath;
38
+ // Fourth instance of the same disease: kb/resolve-deps.mjs's loadRvf()/loadTransformers() fall
39
+ // back to NODE_PATH / bare require() resolution, which only works when the invoking shell's rc
40
+ // files happened to export it — true for an interactive login shell, silently false for launchd's
41
+ // bash -lc (non-interactive, so any `[[ $- != *i* ]] && return`-style guard in .bash_profile skips
42
+ // the rest of the file). Never depend on ambient shell state for a scheduled job again: pin both
43
+ // deps to their real, verified absolute locations via resolve-deps.mjs's own explicit overrides.
44
+ if (!process.env.RVF_MODULE_PATH && fs.existsSync('/Users/stuartkerr/.npm-global/lib/node_modules/@ruvector/rvf')) {
45
+ process.env.RVF_MODULE_PATH = '/Users/stuartkerr/.npm-global/lib/node_modules';
46
+ }
47
+ if (!process.env.XENOVA_PATH && fs.existsSync('/Users/stuartkerr/.npm-global/node_modules/@xenova/transformers')) {
48
+ process.env.XENOVA_PATH = '/Users/stuartkerr/.npm-global/node_modules/@xenova/transformers';
49
+ }
50
+ const has = (f) => process.argv.includes(f);
51
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
52
+ const APPLY = has('--apply');
53
+ const TIER = arg('--tier', null);
54
+ const ONLY = arg('--repo', null);
55
+ if (has('--publish')) {
56
+ console.error('[release-authority] DENIED: self-update is rebuild-only; --publish cannot create a GitHub Release, publish npm, move a dist-tag, push a release commit, or label anything shipped.');
57
+ console.error('[release-authority] Use the protected release workflow after release-proof seals the exact clean SHA and packed-artifact digest.');
58
+ process.exit(2);
59
+ }
60
+ // --publish's whole job is to end this run with `git commit` + `git push origin main` (see the
61
+ // PUBLISH block far below) — and those two commands operate on whatever branch is CURRENTLY
62
+ // CHECKED OUT in this working tree, not necessarily main. Real failure (2026-07-19): the nightly
63
+ // fired at 03:15 while a developer had feat/meta-proxy-passthrough checked out, and commit 4a10833
64
+ // "Nightly brain refresh v3.4.21-dev" landed silently on THAT branch — a version-bump commit no
65
+ // release will ever be cut from, stranded on top of someone's unrelated in-progress work.
66
+ // Guard here, before ANY of the (hours-long, CPU-bound) rebuild work below even starts, and before
67
+ // the GitHub Release / npm publish steps run — checking branch only at commit-time would still
68
+ // burn a full night's rebuild, or worse, leave a Release cut with no matching version-bump commit
69
+ // to go with it. We deliberately do NOT `git checkout main` to self-correct: this machine is
70
+ // routinely mid-task on a feature branch at 03:15 (that is exactly how this bug was found), and
71
+ // switching branches out from under someone's uncommitted work is precisely the "clever and
72
+ // destructive" move this repo has already been burned by once (see the -readonly/timeout lesson in
73
+ // CLAUDE.md Rule 19). An aborted nightly that logs clearly and reruns tomorrow costs nothing; a
74
+ // checkout that clobbers a developer's working tree cannot be undone.
75
+ // ── THE BRANCH GUARD IS GONE, AND THAT IS THE FIX (2026-07-24) ──────────────────────────────────
76
+ //
77
+ // It used to FATAL here when ROOT was not on main. That guard was CORRECT for what it knew: these
78
+ // git commands act on whatever branch is checked out, and on 2026-07-19 that stranded commit 4a10833
79
+ // on feat/meta-proxy-passthrough. Refusing beat clobbering a developer's working tree.
80
+ //
81
+ // But refusing had a cost nobody was watching. This file is the ONLY thing that cuts GitHub Releases
82
+ // (`release.mjs --publish` does npm and contains zero `gh release` calls). So on 2026-07-23 the
83
+ // nightly hit this guard — the repo was on branch 4.0 — aborted correctly, and the release channel
84
+ // froze at v3.9.18-dev while npm advanced to 3.9.50. Thirty-two versions of drift produced by a guard
85
+ // working exactly as designed, invisible because the channel verifier only checked the bundle was
86
+ // REACHABLE, never that it was CURRENT.
87
+ //
88
+ // The dilemma was false. It only existed because the version commit ran in ROOT. It now runs in a
89
+ // worktree pinned to origin/main (see the PUBLISH block), so the developer's tree is never touched
90
+ // AND the release is never blocked by what they have checked out. Neither horn, rather than a better
91
+ // choice between them.
92
+ //
93
+ // Nothing replaces this check because nothing needs to: ROOT's branch is now irrelevant to publishing.
94
+ // If the worktree cannot be prepared, THAT fails loudly and refuses to fall back to committing in
95
+ // ROOT — the guarantee this guard existed to provide, kept, without the outage it caused.
96
+ // --fresh-window <days>: LIVE-scan the org(s) and take every non-fork/non-archived repo pushed within
97
+ // N days, bypassing the static registry.tiers.json AND the nightly T3 skip. This is THE fix for
98
+ // "new repos rUv shipped never got ingested" — a brand-new repo is discovered and built the same night
99
+ // it appears, but only within the rolling window, so the old "days of compute for all 173" risk that the
100
+ // tier gate guarded against still cannot happen. Absent this flag, scoping is byte-for-byte unchanged.
101
+ const FRESH_WINDOW = arg('--fresh-window', null);
102
+ const MODEL_CACHE = process.env.KB_MODEL_CACHE || path.join(ROOT, 'kb', 'models-cache');
103
+
104
+ // Optional author-side clone overrides via env (JSON map); default empty → uses CLONE_DIR/<name>.
105
+ const KNOWN_CLONES = JSON.parse(process.env.RUVNET_KNOWN_CLONES || '{}');
106
+ const CLONE_DIR = path.join(ROOT, 'clones');
107
+ // --full / --keep depth config per repo: SHARED map in scripts/full-hints.mjs (also used by
108
+ // ingest-repo.mjs, which previously had NO hints and silently zeroed full-body indexing on any
109
+ // rebuild routed through it). History + derivation rationale preserved in that file.
110
+
111
+ const tiers = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/registry.tiers.json'), 'utf8'));
112
+ const manifest = fs.existsSync(path.join(ROOT, 'data/manifest.json'))
113
+ ? JSON.parse(fs.readFileSync(path.join(ROOT, 'data/manifest.json'), 'utf8')) : { builtRepos: [] };
114
+ // case-insensitive: registry names are capitalized (RuVector) but built artifacts are lowercase (ruvector)
115
+ const builtSha = Object.fromEntries(manifest.builtRepos.map((r) => [r.name.toLowerCase(), r.builtFromSha]));
116
+
117
+ // owner/repo default to 'ruvnet'/<name> so existing registry entries (no owner/repo field) are
118
+ // unaffected; a repo living outside the ruvnet org (e.g. a different GitHub org) sets both.
119
+ const remoteHead = (owner, slug) => {
120
+ try { return execFileSync('git', ['ls-remote', `https://github.com/${owner}/${slug}`, 'HEAD'], { timeout: 30000 }).toString().split(/\s/)[0] || null; }
121
+ catch { return null; }
122
+ };
123
+ const clonePath = (name) => KNOWN_CLONES[name] || path.join(CLONE_DIR, name);
124
+
125
+ let inScope = [];
126
+ if (FRESH_WINDOW) {
127
+ const days = parseInt(FRESH_WINDOW, 10);
128
+ if (!Number.isFinite(days) || days <= 0) { console.error(`--fresh-window needs a positive day count, got "${FRESH_WINDOW}"`); process.exit(2); }
129
+ const cutoff = Date.now() - days * 86400 * 1000;
130
+ const orgs = (process.env.RUVNET_FRESH_ORGS || 'ruvnet').split(',').map((s) => s.trim()).filter(Boolean);
131
+ let orgFailures = 0;
132
+ for (const org of orgs) {
133
+ let rows;
134
+ try {
135
+ rows = JSON.parse(execFileSync(GH, ['repo', 'list', org, '--limit', '1000', '--json', 'name,pushedAt,isFork,isArchived'], { timeout: 60000 }).toString());
136
+ } catch (e) { orgFailures++; console.error(`[fresh-window] gh repo list ${org} FAILED: ${e.message} — skipping org (nothing from it this run)`); continue; }
137
+ for (const r of rows) {
138
+ if (r.isFork || r.isArchived) continue;
139
+ if (new Date(r.pushedAt).getTime() < cutoff) continue;
140
+ inScope.push({ name: r.name, owner: org, repo: r.name, tier: `${days}d`, pushedAt: r.pushedAt });
141
+ }
142
+ }
143
+ if (ONLY) inScope = inScope.filter((r) => r.name === ONLY);
144
+ // FENCE: deliberately-removed (helix — a downstream consumer app) + empty repos must NOT be silently
145
+ // re-added by the rolling window. data/nightly-exclude.json is the one editable list; every drop logged.
146
+ const exPath = path.join(ROOT, 'data/nightly-exclude.json');
147
+ if (fs.existsSync(exPath)) {
148
+ try {
149
+ const exSet = new Set((JSON.parse(fs.readFileSync(exPath, 'utf8')).exclude || []).map((s) => String(s).toLowerCase()));
150
+ const dropped = inScope.filter((r) => exSet.has(r.name.toLowerCase())).map((r) => r.name);
151
+ inScope = inScope.filter((r) => !exSet.has(r.name.toLowerCase()));
152
+ if (dropped.length) console.log(`[fresh-window] fenced out ${dropped.length}: ${dropped.join(', ')} (data/nightly-exclude.json)`);
153
+ } catch (e) { console.error(`[fresh-window] nightly-exclude.json unreadable (${e.message}) — proceeding WITHOUT fence`); }
154
+ }
155
+ console.log(`[fresh-window] live scan of [${orgs.join(', ')}] → ${inScope.length} repos in scope after fence, pushed since ${new Date(cutoff).toISOString().slice(0, 10)} (${days}d window)`);
156
+ } else {
157
+ for (const t of ['T0', 'T1', 'T2', 'T3']) {
158
+ if (TIER && t !== TIER) continue;
159
+ for (const r of tiers.tiers[t].repos) {
160
+ if (ONLY && r.name !== ONLY) continue;
161
+ if (t === 'T3' && !ONLY) continue; // T3 is deep-walked on demand, not nightly
162
+ inScope.push({ ...r, tier: t });
163
+ }
164
+ }
165
+ }
166
+
167
+ const plan = [];
168
+ // DERIVED, not asserted (F4, 2026-07-18): remoteHead() returns null on ANY probe failure, and the
169
+ // old planner labeled a null-probe repo "up-to-date" — so a network-dark nightly (GitHub unreachable,
170
+ // DNS down, token dead) produced an empty todo, exit 0, and the wrapper logged "CLEAN NO-OP" while
171
+ // having measured NOTHING. That is the exact "blind tool reports health" bug stack-sync.mjs kills
172
+ // with its registry guard. Now: an individually-failed probe is labeled 'unverified (probe failed)' —
173
+ // never "up-to-date", never triggering a rebuild — and if HALF OR MORE of the probes failed the run
174
+ // REFUSES to claim anything (exit 1), letting the wrapper's retry + escalation do their job.
175
+ let probeFailures = 0;
176
+ for (const r of inScope) {
177
+ // `r.org` is read as well as `r.owner`, because the registry uses BOTH spellings and reading only
178
+ // one silently mis-resolves the other. MEASURED 2026-07-24: agentic-qe carries org:
179
+ // "proffesor-for-testing", the code read r.owner (undefined), fell through to the 'ruvnet' default,
180
+ // and probed github.com/ruvnet/agentic-qe — which 404s. The repo was reported "unverified (probe
181
+ // failed)" on every single run, which reads as "the remote is down" when the truth was that we
182
+ // asked about a repository that does not exist. Live check: ruvnet/agentic-qe -> 404,
183
+ // proffesor-for-testing/agentic-qe -> 200.
184
+ const live = remoteHead(r.owner || r.org || 'ruvnet', r.repo || r.name);
185
+ if (live === null) probeFailures++;
186
+ const built = builtSha[r.name.toLowerCase()] || null;
187
+ let action = 'up-to-date';
188
+ if (!built) action = 'build (new)';
189
+ // 'unknown' stamped SHA cannot prove freshness — treat as changed whenever upstream is reachable.
190
+ // (Real bug: ruflo + agentic-flow were stamped unknown and therefore NEVER auto-rebuilt — the two
191
+ // most-central repos were invisible to the freshness loop until a 3.24 release exposed it.)
192
+ else if (built === 'unknown') action = live ? 'rebuild (changed)' : 'unverified (probe failed)';
193
+ else if (live === null) action = 'unverified (probe failed)';
194
+ else if (live !== built) action = 'rebuild (changed)';
195
+ plan.push({ name: r.name, owner: r.owner, repo: r.repo, tier: r.tier, built: built?.slice(0, 12) || '—', live: live?.slice(0, 12) || '?', action });
196
+ }
197
+ if (inScope.length > 0 && probeFailures * 2 >= inScope.length) {
198
+ console.error(`\n[blind-guard] ${probeFailures}/${inScope.length} freshness probes FAILED — refusing to claim "up-to-date" for anything. This run measured nothing; that is a failure, not a no-op.`);
199
+ process.exit(1);
200
+ }
201
+ if (typeof orgFailures !== 'undefined' && orgFailures > 0 && inScope.length === 0) {
202
+ console.error(`\n[blind-guard] every org listing failed (${orgFailures}) and nothing is in scope — cannot distinguish "nothing fresh" from "completely blind". Failing loudly.`);
203
+ process.exit(1);
204
+ }
205
+
206
+ console.log(`self-update ${APPLY ? '(APPLY)' : '(dry-run)'} — ${plan.length} repos in scope\n`);
207
+ for (const p of plan) console.log(` ${p.action.padEnd(20)} ${p.tier} ${p.name.padEnd(24)} built:${p.built} live:${p.live}`);
208
+ // SAFE NIGHTLY SCOPE: by default only REBUILD already-built repos whose upstream changed (keeps the
209
+ // shipped bundle current). Building brand-new repos is a supervised, multi-hour scaling effort, NOT an
210
+ // unattended nightly job — gate it behind --include-new so the cron can't silently try to deep-walk 40+
211
+ // repos (days of compute) on its first run.
212
+ // Fresh-window mode exists precisely to discover + build brand-new repos, so default INCLUDE_NEW on
213
+ // there (still bounded to the N-day set). Static-tier mode keeps the original opt-in gate untouched.
214
+ const INCLUDE_NEW = has('--include-new') || !!FRESH_WINDOW;
215
+ const changed = plan.filter((p) => p.action === 'rebuild (changed)');
216
+ const newRepos = plan.filter((p) => p.action === 'build (new)');
217
+ const todo = INCLUDE_NEW ? [...changed, ...newRepos] : changed;
218
+ console.log(`\nrebuild(changed): ${changed.map((p) => p.name).join(', ') || 'none'}`);
219
+ console.log(`new(not built): ${newRepos.length} repos${INCLUDE_NEW ? ' — INCLUDED (--include-new)' : ' — SKIPPED (supervised; pass --include-new to build)'}`);
220
+ console.log(`→ this run will (re)build ${todo.length}: ${todo.map((p) => p.name).join(', ') || 'none'}`);
221
+
222
+ if (!APPLY) {
223
+ console.log('\n(dry-run — pass --apply to (re)build; runs serially since embedding is CPU-bound)');
224
+ // Sol amendment to F4: even a dry-run may not report clean (exit 0) when probes failed — the
225
+ // "CLEAN NO-OP" the wrapper logs on exit 0 must mean "measured everything, nothing due".
226
+ if (probeFailures > 0) { console.error(`[blind-guard] ${probeFailures} probe(s) failed — dry-run cannot certify freshness; exiting 1`); process.exit(1); }
227
+ process.exit(0);
228
+ }
229
+
230
+ const NOTIFY = (t, m, p) => { try { execFileSync('sh', [path.join(ROOT, 'scripts/notify.sh'), t, m, p || 'default']); } catch { /* alerting never breaks the pipeline */ } };
231
+ const failures = []; // per-repo build failures collected here; ANY failure aborts before publish (see below)
232
+ for (const p of todo) {
233
+ const dir = clonePath(p.name);
234
+ try {
235
+ if (!fs.existsSync(path.join(dir, '.git'))) {
236
+ console.log(`[clone] ${p.name}`);
237
+ fs.mkdirSync(CLONE_DIR, { recursive: true });
238
+ execFileSync('git', ['clone', '--depth', '1', `https://github.com/${p.owner || p.org || 'ruvnet'}/${p.repo || p.name}`, dir], { stdio: 'inherit' });
239
+ } else {
240
+ // Some cached clones deduplicate large submodules with symlinks to another clone. Git refuses
241
+ // even a fetch when a gitlink path is a symlink ("expected submodule path ... not to be a
242
+ // symbolic link"). Detach only those declared submodule symlinks for fetch/reset, then restore
243
+ // them in finally so a network/reset failure cannot strand the cache in a half-repaired state.
244
+ withSubmoduleSymlinksDetached(dir, () => {
245
+ execFileSync('git', ['-C', dir, 'fetch', '--depth', '1', 'origin'], { stdio: 'inherit' });
246
+ execFileSync('git', ['-C', dir, 'reset', '--hard', 'origin/HEAD'], { stdio: 'inherit' });
247
+ });
248
+ }
249
+ const env = { ...process.env, KB_MODEL_CACHE: MODEL_CACHE };
250
+ const kb = p.name.toLowerCase(); // artifacts are lowercase (ruvector.rvf), registry name is RuVector
251
+ const full = FULL_HINTS[kb];
252
+ const keep = KEEP_DIRS[kb];
253
+ const buildArgs = ['forge-refresh.mjs', '--repo', dir, '--out', '.', '--name', kb,
254
+ '--canonical-url', process.env.RUVNET_CANONICAL_URL || 'https://raw.githubusercontent.com/stuinfla/ruvnet-brain/main/kb'];
255
+ if (full) buildArgs.push('--full', full);
256
+ if (keep) buildArgs.push('--keep', keep);
257
+ console.log(`[refresh] ${kb}`);
258
+ execFileSync(NODE, buildArgs, { cwd: path.join(ROOT, 'kb'), env, stdio: 'inherit' });
259
+ console.log(`[symbols] ${kb}`);
260
+ execFileSync(NODE, ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env, stdio: 'inherit' });
261
+ // Corpus QA gate (scripts/corpus-qa.mjs): structural (passages>0, full-bodies>0 where
262
+ // FULL_HINTS demands them, vectors==passages, embed.json present) + deterministic
263
+ // self-retrieval round trip on every canonical store. Non-zero exit throws -> failures[] ->
264
+ // the run aborts before stamp/bundle/publish. This is the machine version of the
265
+ // 2026-07-10 hand-verification: a store that embeds wrong or reads wrong cannot ship.
266
+ console.log(`[qa] ${kb}`);
267
+ execFileSync(NODE, ['scripts/corpus-qa.mjs', '--store', kb], { cwd: ROOT, env, stdio: 'inherit' });
268
+ } catch (e) { console.error(`[FAIL] ${p.name}: ${e.message}`); failures.push({ name: p.name, error: e.message }); }
269
+ }
270
+
271
+ // A per-repo build failure used to be logged and swallowed before the run re-stamped and re-bundled
272
+ // partial data. Fail loud: if ANY repo failed, abort before stamp/bundle. This rebuild process has no
273
+ // publication authority; a later protected release may consume only a fully prepared candidate.
274
+ if (failures.length) {
275
+ NOTIFY('🔴 Nightly brain rebuild ABORTED', `${failures.length} repo build(s) failed — no candidate prepared. See logs/nightly.log`, 'urgent');
276
+ console.error(`\n[FATAL] ${failures.length} repo build(s) failed this run — aborting before stamp/bundle. No candidate prepared:`);
277
+ for (const f of failures) console.error(` - ${f.name}: ${f.error}`);
278
+ console.error('Fix the failing repo(s) and re-run.');
279
+ process.exit(1);
280
+ }
281
+
282
+ console.log('\n[stamp] re-stamping bundle');
283
+ execFileSync(NODE, ['scripts/brain-stamp.mjs'], { cwd: ROOT, stdio: 'inherit' });
284
+ // refresh the shipped bundle so the rebuilt deep-source ships (concepts/primers/L2 are supervised — not regenerated here)
285
+ console.log('[bundle] re-assembling dist/ruvnet-brain');
286
+ execFileSync(NODE, ['scripts/build-bundle.mjs'], { cwd: ROOT, env: { ...process.env, KB_MODEL_CACHE: MODEL_CACHE }, stdio: 'inherit' });
287
+ console.log('self-update done. (Deep-source refreshed + bundle re-assembled. Primer/L2/concepts + grading are supervised steps — re-run them when a repo materially changes.)');
288
+
289
+ // Sol amendment to F4 (end of run): the verified rebuilds above are real work and were NOT
290
+ // skipped — but if any freshness probe failed, this run still may not report clean. Exit 1 AFTER the
291
+ // work so the wrapper's retry re-probes (rebuilds are change-gated, so the retry is cheap) and a
292
+ // persistent blind spot escalates instead of hiding inside a green "no-op".
293
+ if (typeof probeFailures !== 'undefined' && probeFailures > 0) {
294
+ console.error(`\n[blind-guard] run completed its verified work, but ${probeFailures} freshness probe(s) FAILED — those repos are 'unverified', not 'up-to-date'. Exiting 1 so the failure is visible.`);
295
+ process.exit(1);
296
+ }
@@ -424,7 +424,13 @@ export function assertContract({ rec, measurement, mode, timeoutSec }) {
424
424
  if (measurement.timedOut) {
425
425
  v.push({ kind: 'hang', where, regime: measurement.regime, detail: `${tag} did not exit within its declared timeout of ${timeoutSec}s — the watchdog had to kill the process group` });
426
426
  } else if (measurement.elapsedMs > budgetMs * TIMEOUT_MARGIN) {
427
- v.push({ kind: 'slow', where, regime: measurement.regime, detail: `${tag} took ${measurement.elapsedMs}ms — over ${Math.round(TIMEOUT_MARGIN * 100)}% of its ${timeoutSec}s timeout (no margin left)` });
427
+ const stageTrace = String(measurement.stderr || '')
428
+ .split('\n')
429
+ .filter((line) => line.includes('SESSION_TRACE'))
430
+ .map((line) => line.trim())
431
+ .join(' | ');
432
+ const traceDetail = stageTrace ? `; stage trace: ${stageTrace}` : '';
433
+ v.push({ kind: 'slow', where, regime: measurement.regime, detail: `${tag} took ${measurement.elapsedMs}ms — over ${Math.round(TIMEOUT_MARGIN * 100)}% of its ${timeoutSec}s timeout (no margin left)${traceDetail}` });
428
434
  }
429
435
 
430
436
  // 2. EXIT CODE within the declared contract for this mode.
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // scripts/sign-bundle.mjs — Ed25519 signing for the shipped brain bundle (SEC-0010 #6).
3
+ //
4
+ // WHY: the installer downloads a ~512 MB zip over the network and extracts it into the user's
5
+ // Claude Code config. Unsigned, a MITM or a compromised release host could swap the bundle for a
6
+ // poisoned one and the installer would extract it blindly. Signing closes that: publish emits a
7
+ // detached signature over the bundle's SHA-256; the installer verifies it against a PUBLIC key that
8
+ // ships INSIDE the npm package (so the trust root travels with the installer, not the download).
9
+ //
10
+ // Design rules (match the rest of the repo): dependency-free (node:crypto only), and the private key
11
+ // NEVER lives in the repo — it's read from $RUVNET_SIGNING_KEY (PEM) or a gitignored key file.
12
+ //
13
+ // Usage:
14
+ // node scripts/sign-bundle.mjs --gen-key # one-time: make a keypair (pub → repo, priv → gitignored)
15
+ // node scripts/sign-bundle.mjs [--bundle <zip>] # sign the bundle → <zip>.sha256 + <zip>.sig
16
+
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
24
+ const has = (f) => process.argv.includes(f);
25
+
26
+ // The PUBLIC key ships in the package (committed) — it is the installer's trust root.
27
+ const PUB_PATH = path.join(ROOT, 'keys', 'ruvnet-brain-signing.pub.pem');
28
+ // The PRIVATE key is a secret: env first, then a gitignored file. NEVER committed.
29
+ const PRIV_ENV = 'RUVNET_SIGNING_KEY';
30
+ const PRIV_FILE = process.env.RUVNET_SIGNING_KEY_FILE || path.join(ROOT, '.secrets', 'ruvnet-brain-signing.key.pem');
31
+
32
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
33
+
34
+ function genKey() {
35
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
36
+ const pub = publicKey.export({ type: 'spki', format: 'pem' });
37
+ const priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
38
+ fs.mkdirSync(path.dirname(PUB_PATH), { recursive: true });
39
+ fs.mkdirSync(path.dirname(PRIV_FILE), { recursive: true });
40
+ fs.writeFileSync(PUB_PATH, pub);
41
+ fs.writeFileSync(PRIV_FILE, priv, { mode: 0o600 });
42
+ console.log(`✓ public key → ${path.relative(ROOT, PUB_PATH)} (COMMIT this — it's the installer's trust root)`);
43
+ console.log(`✓ private key → ${path.relative(ROOT, PRIV_FILE)} (gitignored; keep secret, or set $${PRIV_ENV})`);
44
+ console.log(` Rotate by re-running --gen-key and re-signing; the old public key stops verifying.`);
45
+ }
46
+
47
+ function loadPrivateKey() {
48
+ if (process.env[PRIV_ENV]) return crypto.createPrivateKey(process.env[PRIV_ENV]);
49
+ if (fs.existsSync(PRIV_FILE)) return crypto.createPrivateKey(fs.readFileSync(PRIV_FILE, 'utf8'));
50
+ console.error(`✗ no signing key: set $${PRIV_ENV} (PEM) or create ${path.relative(ROOT, PRIV_FILE)} (run --gen-key).`);
51
+ process.exit(2);
52
+ }
53
+
54
+ function signBundle() {
55
+ const bundle = arg('--bundle', path.join(ROOT, 'dist', 'ruvnet-brain.zip'));
56
+ if (!fs.existsSync(bundle)) { console.error(`✗ bundle not found: ${bundle}`); process.exit(2); }
57
+ const buf = fs.readFileSync(bundle);
58
+ const digest = sha256(buf);
59
+ fs.writeFileSync(`${bundle}.sha256`, `${digest} ${path.basename(bundle)}\n`);
60
+ // Sign the RAW digest bytes (Ed25519 takes the message directly; no pre-hash algo arg).
61
+ const sig = crypto.sign(null, Buffer.from(digest, 'hex'), loadPrivateKey());
62
+ fs.writeFileSync(`${bundle}.sig`, sig);
63
+ console.log(`✓ signed ${path.basename(bundle)}`);
64
+ console.log(` sha256: ${digest}`);
65
+ console.log(` → ${path.basename(bundle)}.sha256 + ${path.basename(bundle)}.sig (publish these alongside the zip)`);
66
+ }
67
+
68
+ if (has('--gen-key')) genKey();
69
+ else signBundle();