ruvnet-brain 4.0.1 → 4.0.2

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -0
  2. package/README.md +4 -4
  3. package/bin/install.mjs +100 -5
  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/hooks/hooks.json +1 -2
  34. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  35. package/plugin/mcp/server.mjs +21 -0
  36. package/plugin/scripts/detach.mjs +14 -0
  37. package/plugin/scripts/first-session-worker.mjs +38 -0
  38. package/plugin/scripts/ground-ruvnet.sh +16 -6
  39. package/plugin/scripts/hook-shim.mjs +7 -7
  40. package/plugin/scripts/learn-capture.sh +22 -3
  41. package/plugin/scripts/learn-flush.mjs +21 -4
  42. package/plugin/scripts/runtime-preferences.mjs +269 -0
  43. package/plugin/scripts/session-start-core.mjs +477 -0
  44. package/plugin/scripts/session-start.sh +3 -858
  45. package/plugin/skills/brain-console/SKILL.md +4 -2
  46. package/plugin/skills/release-proof/SKILL.md +81 -0
  47. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  48. package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
  49. package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
  50. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
  51. package/plugin/skills/rvbc/SKILL.md +9 -6
  52. package/scripts/adr-backfill.mjs +107 -0
  53. package/scripts/advocacy-outcomes.mjs +808 -0
  54. package/scripts/agentdb-context.mjs +216 -0
  55. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  56. package/scripts/ascii-drift.mjs +236 -0
  57. package/scripts/behavioral-l1-l4.mjs +210 -0
  58. package/scripts/brain-capability-check.mjs +72 -0
  59. package/scripts/brain-grade-groundtruth.mjs +100 -0
  60. package/scripts/brain-latency-50.mjs +227 -0
  61. package/scripts/brain-novice-50.mjs +189 -0
  62. package/scripts/brain-stamp.mjs +94 -0
  63. package/scripts/brain-state.mjs +212 -0
  64. package/scripts/build-bundle.mjs +522 -0
  65. package/scripts/build-concepts.mjs +132 -0
  66. package/scripts/build-l2.mjs +71 -0
  67. package/scripts/build-primer.mjs +73 -0
  68. package/scripts/build-symbols.mjs +68 -0
  69. package/scripts/calibrate-router.mjs +97 -0
  70. package/scripts/capability-audit.mjs +321 -0
  71. package/scripts/capability-registry.mjs +876 -0
  72. package/scripts/check-indexation.mjs +108 -0
  73. package/scripts/check-legibility.mjs +189 -0
  74. package/scripts/ci/build-fixture-kb.mjs +67 -0
  75. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  76. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  77. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  78. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  79. package/scripts/ci/stranger-scenario.mjs +228 -0
  80. package/scripts/ci/stranger-timeout.mjs +25 -0
  81. package/scripts/ci-verdict.mjs +29 -0
  82. package/scripts/claims-verify.mjs +710 -0
  83. package/scripts/clear-claude-tmp.sh +31 -0
  84. package/scripts/console-engine.mjs +434 -0
  85. package/scripts/console-engine.test.mjs +125 -0
  86. package/scripts/corpus-qa.mjs +250 -0
  87. package/scripts/correction-detect-embed.mjs +346 -0
  88. package/scripts/correction-detect-measure.mjs +270 -0
  89. package/scripts/correction-detect.mjs +686 -0
  90. package/scripts/count-chunks.mjs +54 -0
  91. package/scripts/described-questions.json +30 -0
  92. package/scripts/design-grade.mjs +58 -0
  93. package/scripts/dev-plugin-link.sh +105 -0
  94. package/scripts/distill-project.mjs +200 -0
  95. package/scripts/doc-currency.mjs +801 -0
  96. package/scripts/eval-brain.mjs +244 -0
  97. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  98. package/scripts/full-hints.mjs +87 -0
  99. package/scripts/gate.sh +39 -0
  100. package/scripts/gates.mjs +146 -0
  101. package/scripts/gen-console-images.mjs +54 -0
  102. package/scripts/gen-images.mjs +47 -0
  103. package/scripts/git-clone-refresh.mjs +52 -0
  104. package/scripts/git-hooks/pre-push +126 -0
  105. package/scripts/goal-match.mjs +398 -0
  106. package/scripts/goldie-research.mjs +223 -0
  107. package/scripts/goldie-weekly.sh +67 -0
  108. package/scripts/health-repair.mjs +250 -0
  109. package/scripts/helix-scenario-questions.json +10 -0
  110. package/scripts/ingest-gists.mjs +230 -0
  111. package/scripts/ingest-meeting.mjs +115 -0
  112. package/scripts/ingest-repo.mjs +79 -0
  113. package/scripts/install-npx-witness.sh +49 -0
  114. package/scripts/issue-fix.mjs +639 -0
  115. package/scripts/issue-watch.mjs +276 -0
  116. package/scripts/issue4-close-note.md +31 -0
  117. package/scripts/key-canary.mjs +91 -0
  118. package/scripts/latency-to-surface.mjs +233 -0
  119. package/scripts/learning-enable.mjs +380 -0
  120. package/scripts/learning-replay.mjs +1570 -0
  121. package/scripts/learnings.mjs +62 -0
  122. package/scripts/lesson-gate.mjs +680 -0
  123. package/scripts/lesson-lifecycle.mjs +449 -0
  124. package/scripts/lesson-promote.mjs +262 -0
  125. package/scripts/lesson-ratify.mjs +98 -0
  126. package/scripts/lesson-seed.mjs +252 -0
  127. package/scripts/lesson-store.mjs +447 -0
  128. package/scripts/loop-checkpoint.mjs +86 -0
  129. package/scripts/memdb-health.sh +14 -0
  130. package/scripts/memory-doctor.mjs +271 -0
  131. package/scripts/model-catalog.mjs +79 -0
  132. package/scripts/nightly-controller.mjs +66 -0
  133. package/scripts/nightly-gists.sh +72 -0
  134. package/scripts/nightly-wrapper.sh +180 -0
  135. package/scripts/notify.sh +12 -0
  136. package/scripts/npx-witness.sh +56 -0
  137. package/scripts/onboarding-console.mjs +2749 -0
  138. package/scripts/private-fence.mjs +69 -0
  139. package/scripts/proactivity-metrics.mjs +118 -0
  140. package/scripts/proof-questions.json +56 -0
  141. package/scripts/prove.mjs +95 -0
  142. package/scripts/proxy/claude-proxied.sh +57 -0
  143. package/scripts/proxy/proxy-revert.sh +59 -0
  144. package/scripts/proxy/proxy-up.sh +60 -0
  145. package/scripts/proxy/proxy-verify.mjs +142 -0
  146. package/scripts/published-surface-probe.mjs +241 -0
  147. package/scripts/qe/card-lane-gate.mjs +162 -0
  148. package/scripts/qe/session-start-gate.mjs +229 -0
  149. package/scripts/qe/ux-suite.mjs +323 -0
  150. package/scripts/reconcile-project.mjs +0 -0
  151. package/scripts/record-lesson.mjs +113 -0
  152. package/scripts/refresh-model-catalog.mjs +99 -0
  153. package/scripts/release-proof.mjs +9 -0
  154. package/scripts/release-vector.mjs +281 -0
  155. package/scripts/release.mjs +395 -0
  156. package/scripts/remedy-registry.mjs +247 -0
  157. package/scripts/rerank-cap-eval.mjs +265 -0
  158. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  159. package/scripts/route-cheap.mjs +20 -15
  160. package/scripts/router-utilization.mjs +182 -0
  161. package/scripts/routing-flywheel.mjs +596 -0
  162. package/scripts/rvf-generation.mjs +104 -0
  163. package/scripts/rvf-index-audit.mjs +138 -0
  164. package/scripts/self-update.mjs +508 -0
  165. package/scripts/selfcheck.mjs +7 -1
  166. package/scripts/sign-bundle.mjs +69 -0
  167. package/scripts/signal-watch.mjs +171 -0
  168. package/scripts/stack-sync.mjs +469 -0
  169. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  170. package/scripts/stamp-sweep.mjs +144 -0
  171. package/scripts/status-honesty.mjs +102 -0
  172. package/scripts/sync-version.mjs +217 -0
  173. package/scripts/token-report.mjs +102 -0
  174. package/scripts/top100-benchmark.mjs +479 -0
  175. package/scripts/top100-corpus.mjs +112 -0
  176. package/scripts/top100-semantic-assertions.mjs +449 -0
  177. package/scripts/update-apply.mjs +9 -0
  178. package/scripts/upgrade-notice.mjs +14 -0
  179. package/scripts/verify-bundle.mjs +51 -0
  180. package/scripts/verify-channels.mjs +184 -0
  181. package/scripts/verify-model-catalog.mjs +104 -0
  182. package/scripts/verify-nightly-close-issue4.sh +31 -0
  183. package/scripts/version.mjs +40 -0
  184. package/scripts/wired-check.mjs +864 -0
  185. package/plugin/scripts/finalize-token-meter.mjs +0 -25
@@ -0,0 +1,508 @@
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
+ // Designed to be invoked by deploy/com.ruvnet.brain-nightly.plist (LaunchAgent — NOT auto-installed).
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { execFileSync } from 'node:child_process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
18
+ import { withSubmoduleSymlinksDetached } from './git-clone-refresh.mjs';
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+
22
+ // launchd delivers a minimal PATH, and the plist-level export has now failed across TWO separate
23
+ // reloads for TWO separate reasons: (2026-07-10) `gh release create` ENOENT, pid 0, while the
24
+ // loaded plist showed the export; (2026-07-12) launchd's WorkingDirectory was unset entirely so
25
+ // the job never even reached this file. Both are now fixed at the plist level too, but the script
26
+ // guarantees its own environment regardless: /usr/local/bin FIRST (that's the node every build
27
+ // tonight was tested and gated against — 22.13.1; homebrew independently upgraded to 25.9.0,
28
+ // which cannot load kb/'s native @ruvector/rvf module) and every spawned tool resolved to an
29
+ // absolute path, never bare-name PATH lookup — a bare `node`/`gh` silently picks up whichever
30
+ // version macOS or homebrew put first, and that has now broken this pipeline twice.
31
+ process.env.PATH = ['/usr/local/bin', '/opt/homebrew/bin', process.env.PATH || ''].join(':');
32
+ const GH = ['/usr/local/bin/gh', '/opt/homebrew/bin/gh'].find((p) => fs.existsSync(p)) || 'gh';
33
+ const NODE = ['/usr/local/bin/node', '/opt/homebrew/bin/node'].find((p) => fs.existsSync(p)) || process.execPath;
34
+ // Fourth instance of the same disease: kb/resolve-deps.mjs's loadRvf()/loadTransformers() fall
35
+ // back to NODE_PATH / bare require() resolution, which only works when the invoking shell's rc
36
+ // files happened to export it — true for an interactive login shell, silently false for launchd's
37
+ // bash -lc (non-interactive, so any `[[ $- != *i* ]] && return`-style guard in .bash_profile skips
38
+ // the rest of the file). Never depend on ambient shell state for a scheduled job again: pin both
39
+ // deps to their real, verified absolute locations via resolve-deps.mjs's own explicit overrides.
40
+ if (!process.env.RVF_MODULE_PATH && fs.existsSync('/Users/stuartkerr/.npm-global/lib/node_modules/@ruvector/rvf')) {
41
+ process.env.RVF_MODULE_PATH = '/Users/stuartkerr/.npm-global/lib/node_modules';
42
+ }
43
+ if (!process.env.XENOVA_PATH && fs.existsSync('/Users/stuartkerr/.npm-global/node_modules/@xenova/transformers')) {
44
+ process.env.XENOVA_PATH = '/Users/stuartkerr/.npm-global/node_modules/@xenova/transformers';
45
+ }
46
+ const has = (f) => process.argv.includes(f);
47
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
48
+ const APPLY = has('--apply');
49
+ const TIER = arg('--tier', null);
50
+ const ONLY = arg('--repo', null);
51
+ // --publish's whole job is to end this run with `git commit` + `git push origin main` (see the
52
+ // PUBLISH block far below) — and those two commands operate on whatever branch is CURRENTLY
53
+ // CHECKED OUT in this working tree, not necessarily main. Real failure (2026-07-19): the nightly
54
+ // fired at 03:15 while a developer had feat/meta-proxy-passthrough checked out, and commit 4a10833
55
+ // "Nightly brain refresh v3.4.21-dev" landed silently on THAT branch — a version-bump commit no
56
+ // release will ever be cut from, stranded on top of someone's unrelated in-progress work.
57
+ // Guard here, before ANY of the (hours-long, CPU-bound) rebuild work below even starts, and before
58
+ // the GitHub Release / npm publish steps run — checking branch only at commit-time would still
59
+ // burn a full night's rebuild, or worse, leave a Release cut with no matching version-bump commit
60
+ // to go with it. We deliberately do NOT `git checkout main` to self-correct: this machine is
61
+ // routinely mid-task on a feature branch at 03:15 (that is exactly how this bug was found), and
62
+ // switching branches out from under someone's uncommitted work is precisely the "clever and
63
+ // destructive" move this repo has already been burned by once (see the -readonly/timeout lesson in
64
+ // CLAUDE.md Rule 19). An aborted nightly that logs clearly and reruns tomorrow costs nothing; a
65
+ // checkout that clobbers a developer's working tree cannot be undone.
66
+ // ── THE BRANCH GUARD IS GONE, AND THAT IS THE FIX (2026-07-24) ──────────────────────────────────
67
+ //
68
+ // It used to FATAL here when ROOT was not on main. That guard was CORRECT for what it knew: these
69
+ // git commands act on whatever branch is checked out, and on 2026-07-19 that stranded commit 4a10833
70
+ // on feat/meta-proxy-passthrough. Refusing beat clobbering a developer's working tree.
71
+ //
72
+ // But refusing had a cost nobody was watching. This file is the ONLY thing that cuts GitHub Releases
73
+ // (`release.mjs --publish` does npm and contains zero `gh release` calls). So on 2026-07-23 the
74
+ // nightly hit this guard — the repo was on branch 4.0 — aborted correctly, and the release channel
75
+ // froze at v3.9.18-dev while npm advanced to 3.9.50. Thirty-two versions of drift produced by a guard
76
+ // working exactly as designed, invisible because the channel verifier only checked the bundle was
77
+ // REACHABLE, never that it was CURRENT.
78
+ //
79
+ // The dilemma was false. It only existed because the version commit ran in ROOT. It now runs in a
80
+ // worktree pinned to origin/main (see the PUBLISH block), so the developer's tree is never touched
81
+ // AND the release is never blocked by what they have checked out. Neither horn, rather than a better
82
+ // choice between them.
83
+ //
84
+ // Nothing replaces this check because nothing needs to: ROOT's branch is now irrelevant to publishing.
85
+ // If the worktree cannot be prepared, THAT fails loudly and refuses to fall back to committing in
86
+ // ROOT — the guarantee this guard existed to provide, kept, without the outage it caused.
87
+ // --fresh-window <days>: LIVE-scan the org(s) and take every non-fork/non-archived repo pushed within
88
+ // N days, bypassing the static registry.tiers.json AND the nightly T3 skip. This is THE fix for
89
+ // "new repos rUv shipped never got ingested" — a brand-new repo is discovered and built the same night
90
+ // it appears, but only within the rolling window, so the old "days of compute for all 173" risk that the
91
+ // tier gate guarded against still cannot happen. Absent this flag, scoping is byte-for-byte unchanged.
92
+ const FRESH_WINDOW = arg('--fresh-window', null);
93
+ const MODEL_CACHE = process.env.KB_MODEL_CACHE || path.join(ROOT, 'kb', 'models-cache');
94
+
95
+ // Optional author-side clone overrides via env (JSON map); default empty → uses CLONE_DIR/<name>.
96
+ const KNOWN_CLONES = JSON.parse(process.env.RUVNET_KNOWN_CLONES || '{}');
97
+ const CLONE_DIR = path.join(ROOT, 'clones');
98
+ // --full / --keep depth config per repo: SHARED map in scripts/full-hints.mjs (also used by
99
+ // ingest-repo.mjs, which previously had NO hints and silently zeroed full-body indexing on any
100
+ // rebuild routed through it). History + derivation rationale preserved in that file.
101
+
102
+ const tiers = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/registry.tiers.json'), 'utf8'));
103
+ const manifest = fs.existsSync(path.join(ROOT, 'data/manifest.json'))
104
+ ? JSON.parse(fs.readFileSync(path.join(ROOT, 'data/manifest.json'), 'utf8')) : { builtRepos: [] };
105
+ // case-insensitive: registry names are capitalized (RuVector) but built artifacts are lowercase (ruvector)
106
+ const builtSha = Object.fromEntries(manifest.builtRepos.map((r) => [r.name.toLowerCase(), r.builtFromSha]));
107
+
108
+ // owner/repo default to 'ruvnet'/<name> so existing registry entries (no owner/repo field) are
109
+ // unaffected; a repo living outside the ruvnet org (e.g. a different GitHub org) sets both.
110
+ const remoteHead = (owner, slug) => {
111
+ try { return execFileSync('git', ['ls-remote', `https://github.com/${owner}/${slug}`, 'HEAD'], { timeout: 30000 }).toString().split(/\s/)[0] || null; }
112
+ catch { return null; }
113
+ };
114
+ const clonePath = (name) => KNOWN_CLONES[name] || path.join(CLONE_DIR, name);
115
+
116
+ let inScope = [];
117
+ if (FRESH_WINDOW) {
118
+ const days = parseInt(FRESH_WINDOW, 10);
119
+ if (!Number.isFinite(days) || days <= 0) { console.error(`--fresh-window needs a positive day count, got "${FRESH_WINDOW}"`); process.exit(2); }
120
+ const cutoff = Date.now() - days * 86400 * 1000;
121
+ const orgs = (process.env.RUVNET_FRESH_ORGS || 'ruvnet').split(',').map((s) => s.trim()).filter(Boolean);
122
+ let orgFailures = 0;
123
+ for (const org of orgs) {
124
+ let rows;
125
+ try {
126
+ rows = JSON.parse(execFileSync(GH, ['repo', 'list', org, '--limit', '1000', '--json', 'name,pushedAt,isFork,isArchived'], { timeout: 60000 }).toString());
127
+ } catch (e) { orgFailures++; console.error(`[fresh-window] gh repo list ${org} FAILED: ${e.message} — skipping org (nothing from it this run)`); continue; }
128
+ for (const r of rows) {
129
+ if (r.isFork || r.isArchived) continue;
130
+ if (new Date(r.pushedAt).getTime() < cutoff) continue;
131
+ inScope.push({ name: r.name, owner: org, repo: r.name, tier: `${days}d`, pushedAt: r.pushedAt });
132
+ }
133
+ }
134
+ if (ONLY) inScope = inScope.filter((r) => r.name === ONLY);
135
+ // FENCE: deliberately-removed (helix — a downstream consumer app) + empty repos must NOT be silently
136
+ // re-added by the rolling window. data/nightly-exclude.json is the one editable list; every drop logged.
137
+ const exPath = path.join(ROOT, 'data/nightly-exclude.json');
138
+ if (fs.existsSync(exPath)) {
139
+ try {
140
+ const exSet = new Set((JSON.parse(fs.readFileSync(exPath, 'utf8')).exclude || []).map((s) => String(s).toLowerCase()));
141
+ const dropped = inScope.filter((r) => exSet.has(r.name.toLowerCase())).map((r) => r.name);
142
+ inScope = inScope.filter((r) => !exSet.has(r.name.toLowerCase()));
143
+ if (dropped.length) console.log(`[fresh-window] fenced out ${dropped.length}: ${dropped.join(', ')} (data/nightly-exclude.json)`);
144
+ } catch (e) { console.error(`[fresh-window] nightly-exclude.json unreadable (${e.message}) — proceeding WITHOUT fence`); }
145
+ }
146
+ 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)`);
147
+ } else {
148
+ for (const t of ['T0', 'T1', 'T2', 'T3']) {
149
+ if (TIER && t !== TIER) continue;
150
+ for (const r of tiers.tiers[t].repos) {
151
+ if (ONLY && r.name !== ONLY) continue;
152
+ if (t === 'T3' && !ONLY) continue; // T3 is deep-walked on demand, not nightly
153
+ inScope.push({ ...r, tier: t });
154
+ }
155
+ }
156
+ }
157
+
158
+ const plan = [];
159
+ // DERIVED, not asserted (F4, 2026-07-18): remoteHead() returns null on ANY probe failure, and the
160
+ // old planner labeled a null-probe repo "up-to-date" — so a network-dark nightly (GitHub unreachable,
161
+ // DNS down, token dead) produced an empty todo, exit 0, and the wrapper logged "CLEAN NO-OP" while
162
+ // having measured NOTHING. That is the exact "blind tool reports health" bug stack-sync.mjs kills
163
+ // with its registry guard. Now: an individually-failed probe is labeled 'unverified (probe failed)' —
164
+ // never "up-to-date", never triggering a rebuild — and if HALF OR MORE of the probes failed the run
165
+ // REFUSES to claim anything (exit 1), letting the wrapper's retry + escalation do their job.
166
+ let probeFailures = 0;
167
+ for (const r of inScope) {
168
+ // `r.org` is read as well as `r.owner`, because the registry uses BOTH spellings and reading only
169
+ // one silently mis-resolves the other. MEASURED 2026-07-24: agentic-qe carries org:
170
+ // "proffesor-for-testing", the code read r.owner (undefined), fell through to the 'ruvnet' default,
171
+ // and probed github.com/ruvnet/agentic-qe — which 404s. The repo was reported "unverified (probe
172
+ // failed)" on every single run, which reads as "the remote is down" when the truth was that we
173
+ // asked about a repository that does not exist. Live check: ruvnet/agentic-qe -> 404,
174
+ // proffesor-for-testing/agentic-qe -> 200.
175
+ const live = remoteHead(r.owner || r.org || 'ruvnet', r.repo || r.name);
176
+ if (live === null) probeFailures++;
177
+ const built = builtSha[r.name.toLowerCase()] || null;
178
+ let action = 'up-to-date';
179
+ if (!built) action = 'build (new)';
180
+ // 'unknown' stamped SHA cannot prove freshness — treat as changed whenever upstream is reachable.
181
+ // (Real bug: ruflo + agentic-flow were stamped unknown and therefore NEVER auto-rebuilt — the two
182
+ // most-central repos were invisible to the freshness loop until a 3.24 release exposed it.)
183
+ else if (built === 'unknown') action = live ? 'rebuild (changed)' : 'unverified (probe failed)';
184
+ else if (live === null) action = 'unverified (probe failed)';
185
+ else if (live !== built) action = 'rebuild (changed)';
186
+ 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 });
187
+ }
188
+ if (inScope.length > 0 && probeFailures * 2 >= inScope.length) {
189
+ 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.`);
190
+ process.exit(1);
191
+ }
192
+ if (typeof orgFailures !== 'undefined' && orgFailures > 0 && inScope.length === 0) {
193
+ console.error(`\n[blind-guard] every org listing failed (${orgFailures}) and nothing is in scope — cannot distinguish "nothing fresh" from "completely blind". Failing loudly.`);
194
+ process.exit(1);
195
+ }
196
+
197
+ console.log(`self-update ${APPLY ? '(APPLY)' : '(dry-run)'} — ${plan.length} repos in scope\n`);
198
+ for (const p of plan) console.log(` ${p.action.padEnd(20)} ${p.tier} ${p.name.padEnd(24)} built:${p.built} live:${p.live}`);
199
+ // SAFE NIGHTLY SCOPE: by default only REBUILD already-built repos whose upstream changed (keeps the
200
+ // shipped bundle current). Building brand-new repos is a supervised, multi-hour scaling effort, NOT an
201
+ // unattended nightly job — gate it behind --include-new so the cron can't silently try to deep-walk 40+
202
+ // repos (days of compute) on its first run.
203
+ // Fresh-window mode exists precisely to discover + build brand-new repos, so default INCLUDE_NEW on
204
+ // there (still bounded to the N-day set). Static-tier mode keeps the original opt-in gate untouched.
205
+ const INCLUDE_NEW = has('--include-new') || !!FRESH_WINDOW;
206
+ const changed = plan.filter((p) => p.action === 'rebuild (changed)');
207
+ const newRepos = plan.filter((p) => p.action === 'build (new)');
208
+ const todo = INCLUDE_NEW ? [...changed, ...newRepos] : changed;
209
+ console.log(`\nrebuild(changed): ${changed.map((p) => p.name).join(', ') || 'none'}`);
210
+ console.log(`new(not built): ${newRepos.length} repos${INCLUDE_NEW ? ' — INCLUDED (--include-new)' : ' — SKIPPED (supervised; pass --include-new to build)'}`);
211
+ console.log(`→ this run will (re)build ${todo.length}: ${todo.map((p) => p.name).join(', ') || 'none'}`);
212
+
213
+ if (!APPLY) {
214
+ console.log('\n(dry-run — pass --apply to (re)build; runs serially since embedding is CPU-bound)');
215
+ // Sol amendment to F4: even a dry-run may not report clean (exit 0) when probes failed — the
216
+ // "CLEAN NO-OP" the wrapper logs on exit 0 must mean "measured everything, nothing due".
217
+ if (probeFailures > 0) { console.error(`[blind-guard] ${probeFailures} probe(s) failed — dry-run cannot certify freshness; exiting 1`); process.exit(1); }
218
+ process.exit(0);
219
+ }
220
+
221
+ const NOTIFY = (t, m, p) => { try { execFileSync('sh', [path.join(ROOT, 'scripts/notify.sh'), t, m, p || 'default']); } catch { /* alerting never breaks the pipeline */ } };
222
+ const failures = []; // per-repo build failures collected here; ANY failure aborts before publish (see below)
223
+ for (const p of todo) {
224
+ const dir = clonePath(p.name);
225
+ try {
226
+ if (!fs.existsSync(path.join(dir, '.git'))) {
227
+ console.log(`[clone] ${p.name}`);
228
+ fs.mkdirSync(CLONE_DIR, { recursive: true });
229
+ execFileSync('git', ['clone', '--depth', '1', `https://github.com/${p.owner || p.org || 'ruvnet'}/${p.repo || p.name}`, dir], { stdio: 'inherit' });
230
+ } else {
231
+ // Some cached clones deduplicate large submodules with symlinks to another clone. Git refuses
232
+ // even a fetch when a gitlink path is a symlink ("expected submodule path ... not to be a
233
+ // symbolic link"). Detach only those declared submodule symlinks for fetch/reset, then restore
234
+ // them in finally so a network/reset failure cannot strand the cache in a half-repaired state.
235
+ withSubmoduleSymlinksDetached(dir, () => {
236
+ execFileSync('git', ['-C', dir, 'fetch', '--depth', '1', 'origin'], { stdio: 'inherit' });
237
+ execFileSync('git', ['-C', dir, 'reset', '--hard', 'origin/HEAD'], { stdio: 'inherit' });
238
+ });
239
+ }
240
+ const env = { ...process.env, KB_MODEL_CACHE: MODEL_CACHE };
241
+ const kb = p.name.toLowerCase(); // artifacts are lowercase (ruvector.rvf), registry name is RuVector
242
+ const full = FULL_HINTS[kb];
243
+ const keep = KEEP_DIRS[kb];
244
+ const buildArgs = ['forge-refresh.mjs', '--repo', dir, '--out', '.', '--name', kb,
245
+ '--canonical-url', process.env.RUVNET_CANONICAL_URL || 'https://raw.githubusercontent.com/stuinfla/ruvnet-brain/main/kb'];
246
+ if (full) buildArgs.push('--full', full);
247
+ if (keep) buildArgs.push('--keep', keep);
248
+ console.log(`[refresh] ${kb}`);
249
+ execFileSync(NODE, buildArgs, { cwd: path.join(ROOT, 'kb'), env, stdio: 'inherit' });
250
+ console.log(`[symbols] ${kb}`);
251
+ execFileSync(NODE, ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env, stdio: 'inherit' });
252
+ // Corpus QA gate (scripts/corpus-qa.mjs): structural (passages>0, full-bodies>0 where
253
+ // FULL_HINTS demands them, vectors==passages, embed.json present) + deterministic
254
+ // self-retrieval round trip on every canonical store. Non-zero exit throws -> failures[] ->
255
+ // the run aborts before stamp/bundle/publish. This is the machine version of the
256
+ // 2026-07-10 hand-verification: a store that embeds wrong or reads wrong cannot ship.
257
+ console.log(`[qa] ${kb}`);
258
+ execFileSync(NODE, ['scripts/corpus-qa.mjs', '--store', kb], { cwd: ROOT, env, stdio: 'inherit' });
259
+ } catch (e) { console.error(`[FAIL] ${p.name}: ${e.message}`); failures.push({ name: p.name, error: e.message }); }
260
+ }
261
+
262
+ // A per-repo build failure used to be logged and swallowed — the run then re-stamped, re-bundled and (with
263
+ // --publish) cut a Release + git push based on todo.length alone, shipping a half-rebuilt brain (stale or
264
+ // missing stores) under a fresh version. Fail loud instead: if ANY repo failed, abort BEFORE stamp/bundle
265
+ // and the publish block (gh release create / git push) with a non-zero exit, so the nightly log surfaces it
266
+ // and nothing partial is released.
267
+ if (failures.length) {
268
+ NOTIFY('🔴 Nightly brain publish ABORTED', `${failures.length} repo build(s) failed — nothing released. See logs/nightly.log`, 'urgent');
269
+ console.error(`\n[FATAL] ${failures.length} repo build(s) failed this run — aborting before stamp/bundle/publish. NOTHING released (no re-stamp, no Release, no version bump, no push):`);
270
+ for (const f of failures) console.error(` - ${f.name}: ${f.error}`);
271
+ console.error('Fix the failing repo(s) and re-run.');
272
+ process.exit(1);
273
+ }
274
+
275
+ console.log('\n[stamp] re-stamping bundle');
276
+ execFileSync(NODE, ['scripts/brain-stamp.mjs'], { cwd: ROOT, stdio: 'inherit' });
277
+ // refresh the shipped bundle so the rebuilt deep-source ships (concepts/primers/L2 are supervised — not regenerated here)
278
+ console.log('[bundle] re-assembling dist/ruvnet-brain');
279
+ execFileSync(NODE, ['scripts/build-bundle.mjs'], { cwd: ROOT, env: { ...process.env, KB_MODEL_CACHE: MODEL_CACHE }, stdio: 'inherit' });
280
+ 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.)');
281
+
282
+ // ── PUBLISH (the last mile): local rebuild → users, automatically. ─────────────────────────────
283
+ // Without this, the nightly makes the LOCAL brain smarter while releases/latest never advances —
284
+ // users' auto-updaters correctly report "up to date" against a stale Release forever. Gated on
285
+ // --publish (the LaunchAgent passes it; ad-hoc manual runs don't publish by accident) and on
286
+ // something having actually been rebuilt this run. ONE product version: plugin.json's version is
287
+ // bumped (patch) and shipped in the same Release tag, so brain content and plugin always move
288
+ // under a single user-visible number. Fail-loud: any error here exits non-zero into the nightly
289
+ // log; nothing is half-published silently (release create is atomic per-tag; the version-bump
290
+ // commit only pushes after the Release exists).
291
+ if (has('--publish')) {
292
+ // Packaging gap (issue #29, found by Jan Lafko): the bundle carries EXECUTABLE reader scripts
293
+ // (kb/*.mjs) — so a reader fix could exist in git while releases/latest kept shipping the stale
294
+ // copy FOREVER whenever no corpus rebuild happened to come along (he caught the #27 fix present
295
+ // in source but absent from his installed plugin). A Release is now ALSO cut when any shipped
296
+ // script drifted vs the last release tag, even with zero corpus changes.
297
+ let scriptDrift = [];
298
+ try {
299
+ const lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { cwd: ROOT, encoding: 'utf8' }).trim();
300
+ const out = execFileSync('git', ['diff', '--name-only', `${lastTag}..HEAD`, '--', 'kb/*.mjs', 'kb/package.json', 'bin/install.mjs'], { cwd: ROOT, encoding: 'utf8' }).trim();
301
+ scriptDrift = out ? out.split('\n') : [];
302
+ } catch { scriptDrift = ['(no release tag baseline — treating shipped scripts as drifted)']; }
303
+ if (todo.length === 0 && scriptDrift.length === 0) {
304
+ console.log('[publish] nothing was rebuilt and no shipped script changed since the last Release — no new Release needed. Done.');
305
+ } else {
306
+ if (todo.length === 0) console.log(`[publish] no corpus rebuild, but ${scriptDrift.length} shipped script(s) drifted since the last Release (issue #29 class) — cutting a Release so reader fixes actually reach users:\n${scriptDrift.map((f) => ' · ' + f).join('\n')}`);
307
+ const PLUGIN_JSON = path.join(ROOT, 'plugin', '.claude-plugin', 'plugin.json');
308
+ const pj = JSON.parse(fs.readFileSync(PLUGIN_JSON, 'utf8'));
309
+ const m = pj.version.match(/^(\d+)\.(\d+)\.(\d+)(-dev)?$/);
310
+ if (!m) { console.error(`[publish] FAIL: cannot parse plugin version "${pj.version}"`); process.exit(1); }
311
+ const next = `${m[1]}.${m[2]}.${Number(m[3]) + 1}${m[4] || ''}`;
312
+ const tag = `v${next}`;
313
+ pj.version = next;
314
+ pj.updated = new Date().toISOString().slice(0, 10);
315
+ fs.writeFileSync(PLUGIN_JSON, JSON.stringify(pj, null, 2) + '\n');
316
+ console.log(`[publish] product version → ${next} (${tag})`);
317
+
318
+ // Keep the README's bright-blue version badge in lockstep (Stuart's rule: version + updated
319
+ // timestamp with timezone, bright blue, first thing anyone sees). GitHub strips color from
320
+ // markdown text, so the line is a shields.io for-the-badge image — colors guaranteed.
321
+ const fmt = new Intl.DateTimeFormat('en-CA', {
322
+ timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
323
+ hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short',
324
+ }).formatToParts(new Date());
325
+ const g = (t) => fmt.find((p) => p.type === t)?.value || '';
326
+ const human = `${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')} ${g('timeZoneName')}`;
327
+ const badgeStamp = human.replace(/-/g, '--').replace(/ /g, '_');
328
+ const badgeVer = next.replace(/-/g, '--');
329
+ const badge = `https://img.shields.io/badge/version_${badgeVer}-updated_${badgeStamp}-1E90FF?style=for-the-badge&labelColor=0757BA`;
330
+ const README = path.join(ROOT, 'README.md');
331
+ const readme = fs.readFileSync(README, 'utf8').replace(
332
+ /^### 🧠 RuvNet Brain — \[!\[.*$/m,
333
+ `### 🧠 RuvNet Brain — [![RuvNet Brain version ${next} — updated ${human}](${badge})](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)`,
334
+ );
335
+ fs.writeFileSync(README, readme);
336
+
337
+ // FAN THE BUMP OUT ACROSS ROOT'S OTHER SURFACES — the step whose absence deadlocked publishing.
338
+ //
339
+ // The two writes above bump ROOT's plugin.json and README because the BUNDLE has to be stamped
340
+ // with its own release tag (issue #35). The other five surfaces — package.json, kb/package.json,
341
+ // data/manifest.json, primer/ruvnet-primer.md, explainer/index.html — were left behind, because
342
+ // sync-version.mjs was only ever run inside the publish worktree.
343
+ //
344
+ // That is fine for the COMMIT (the worktree is self-consistent and its version:check passes) and
345
+ // fatal for the PUSH. Git hooks are shared across worktrees and the pre-push gate resolves the
346
+ // repo it checks to ROOT — so it read ROOT, found plugin.json at the new version and five
347
+ // surfaces one behind, and refused: "5 surface(s) drifted from the source of truth".
348
+ //
349
+ // MEASURED 2026-07-24, after a ~3h rebuild: the worktree commit 899ed8d was correct and complete,
350
+ // version:check inside it passed cleanly, and the push was still rejected by a gate reading a
351
+ // directory the commit had nothing to do with. The whole night's work was thrown away at the last
352
+ // step by an inconsistency in a tree that was never going to be pushed.
353
+ //
354
+ // The gate is RIGHT — those five surfaces really were inconsistent, and a user reading ROOT would
355
+ // have seen a version that does not exist. The bug is that we made ROOT inconsistent and never
356
+ // repaired it. One line, in the same place the bump happens, so the two can never drift again.
357
+ execFileSync(NODE, ['scripts/sync-version.mjs'], { cwd: ROOT, stdio: 'inherit' });
358
+
359
+ // ── RE-STAMP THE BUNDLE WITH ITS OWN RELEASE TAG (issue #35, Dr. Mark Allen) ────────────────
360
+ //
361
+ // EVERY nightly release shipped a bundle stamped ONE VERSION BEHIND its own tag. build-bundle
362
+ // runs far above (it assembles dist/ before the publish block), and it stamps
363
+ // SOURCE.json.releaseTag = getVersionTag() — which at that moment is still the PREVIOUS version,
364
+ // because `next` is not computed until line ~284. Proven on the live release: the v3.4.21-dev
365
+ // asset (uploaded 07:21:52Z) carries releaseTag "v3.4.20-dev", one second before the version
366
+ // bump commit at 07:21:53Z.
367
+ //
368
+ // The consequence is not cosmetic. It is exactly the "my version says X but my knowledge base
369
+ // is older" confusion users reported: every installed brain misidentifies itself, --doctor's
370
+ // drift check compares against a wrong number, and forge-update's own behind/current logic
371
+ // reads a tag that never matches the release it came from.
372
+ //
373
+ // build-bundle.mjs already anticipated this fix in its own comment ("a release workflow that
374
+ // knows the exact git tag may overwrite this with the bundle's own Release tag") — it was just
375
+ // never written. Here it is, at the only point in the pipeline where the final tag is known and
376
+ // the bytes have not yet been zipped.
377
+ const bundleSource = path.join(ROOT, 'dist', 'ruvnet-brain', 'SOURCE.json');
378
+ try {
379
+ const doc = JSON.parse(fs.readFileSync(bundleSource, 'utf8'));
380
+ const before = doc.releaseTag;
381
+ doc.releaseTag = tag;
382
+ fs.writeFileSync(bundleSource, JSON.stringify(doc, null, 2) + '\n');
383
+ console.log(`[publish] bundle releaseTag ${before || '(none)'} → ${tag}`);
384
+ } catch (e) {
385
+ // Fail LOUD. Shipping a mis-stamped bundle is precisely the defect this block exists to end,
386
+ // and a silent skip here would restore it invisibly.
387
+ console.error(`[publish] FATAL: could not stamp the bundle's releaseTag (${e.message}). Refusing to publish a bundle that would misidentify its own version.`);
388
+ process.exit(1);
389
+ }
390
+
391
+ const zipPath = path.join(ROOT, 'dist', 'ruvnet-brain.zip');
392
+ console.log('[publish] zipping bundle (private stores already fenced out at assembly)');
393
+ try { fs.unlinkSync(zipPath); } catch {}
394
+ execFileSync('ditto', ['-c', '-k', '--sequesterRsrc', path.join(ROOT, 'dist', 'ruvnet-brain'), zipPath], { stdio: 'inherit' });
395
+
396
+ // Sign the bundle (SEC-0010 #6) so the installer can verify-before-extract. Transitional: if no
397
+ // signing key is available in this env yet (RUVNET_SIGNING_KEY / .secrets/…key.pem), warn and ship
398
+ // unsigned rather than break the nightly — once the key is wired in, every release ships signed and
399
+ // install.mjs's SIGNING_REQUIRED can flip to true. sign-bundle emits <zip>.sig + <zip>.sha256.
400
+ const sigAssets = [];
401
+ try {
402
+ execFileSync(NODE, ['scripts/sign-bundle.mjs', '--bundle', zipPath], { cwd: ROOT, stdio: 'inherit' });
403
+ for (const a of [`${zipPath}.sig`, `${zipPath}.sha256`]) if (fs.existsSync(a)) sigAssets.push(a);
404
+ console.log(`[publish] signed bundle (${sigAssets.length} signature asset(s) will be uploaded)`);
405
+ } catch (e) {
406
+ console.warn(`[publish] WARNING: bundle NOT signed (${e.message.split('\n')[0]}). Shipping unsigned (transitional). Set RUVNET_SIGNING_KEY in the nightly env to enable signing.`);
407
+ }
408
+
409
+ console.log(`[publish] creating GitHub Release ${tag} + uploading bundle (~this can take minutes)`);
410
+ execFileSync(GH, ['release', 'create', tag, zipPath, ...sigAssets,
411
+ '--title', `${tag} — nightly brain refresh`,
412
+ '--notes', `Automated nightly: ${todo.length ? `re-ingested upstream changes in: ${todo.map((p) => p.name).join(', ')}` : `shipped reader-script fixes (no corpus change — issue #29 class)`}. One product version — plugin ${next} + this knowledge bundle ship together; user installs pick both up automatically.`,
413
+ ], { cwd: ROOT, stdio: 'inherit' });
414
+
415
+ console.log('[publish] syncing version to EVERY surface, committing, pushing');
416
+ // Propagate `next` to package.json / kb/package.json / manifest / primer / explainer — NOT just the
417
+ // four files this block used to add. Otherwise those surfaces drift behind plugin.json (the exact
418
+ // 2026-07-19 3.4.19-vs-3.4.20 split that reddened repo-count + version gates).
419
+ //
420
+ // ── THE VERSION COMMIT RUNS IN A MAIN-PINNED WORKTREE, NEVER IN ROOT ────────────────────────────
421
+ //
422
+ // WHY (measured, twice). These four git commands operate on whatever branch ROOT happens to have
423
+ // checked out. On 2026-07-19 that produced commit 4a10833 "Nightly brain refresh v3.4.21-dev"
424
+ // stranded on feat/meta-proxy-passthrough. The guard at the top of this file was added in
425
+ // response and is CORRECT — it aborts rather than checking out main under a developer's
426
+ // uncommitted work, which would be unrecoverable.
427
+ //
428
+ // But an abort has a cost nobody was watching. This file is the ONLY thing that cuts GitHub
429
+ // Releases; `release.mjs --publish` does npm and contains zero `gh release` calls. So on
430
+ // 2026-07-23 the nightly aborted (repo was on branch 4.0), releases froze at v3.9.18-dev, npm kept
431
+ // advancing, and the channel verifier could not see it because it only checked the bundle was
432
+ // REACHABLE, never that it was CURRENT. A correct guard, a real abort, and 32 versions of drift.
433
+ //
434
+ // A worktree dissolves the dilemma instead of trading one failure for the other: a separate
435
+ // directory pinned to origin/main, so the developer's tree is never touched AND the release is
436
+ // never blocked by what they have checked out. Git refuses the same BRANCH in two worktrees, so it
437
+ // is DETACHED at origin/main and pushed as HEAD:main.
438
+ //
439
+ // The build artifacts stay in ROOT — only the version-bump commit moves. Convention follows
440
+ // scripts/issue-fix.mjs's WORKTREE_ROOT rather than inventing a second one.
441
+ const PUBLISH_WT = process.env.RUVNET_PUBLISH_WORKTREE
442
+ || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'publish-worktree');
443
+ const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
444
+
445
+ let wt = null;
446
+ try {
447
+ git(['fetch', 'origin', 'main'], ROOT);
448
+ // Reuse a healthy worktree; rebuild it if a prior run left it wedged. `prune` first so a
449
+ // manually-deleted directory does not leave a stale registration that blocks `add`.
450
+ git(['worktree', 'prune'], ROOT);
451
+ if (!fs.existsSync(path.join(PUBLISH_WT, '.git'))) {
452
+ fs.mkdirSync(path.dirname(PUBLISH_WT), { recursive: true });
453
+ try { git(['worktree', 'remove', '--force', PUBLISH_WT], ROOT); } catch { /* not registered */ }
454
+ fs.rmSync(PUBLISH_WT, { recursive: true, force: true });
455
+ git(['worktree', 'add', '--detach', PUBLISH_WT, 'origin/main'], ROOT);
456
+ } else {
457
+ // Existing worktree: hard-reset to the real origin/main so a half-finished prior run cannot
458
+ // contribute stale files to this release.
459
+ git(['reset', '--hard', 'origin/main'], PUBLISH_WT);
460
+ git(['clean', '-fd'], PUBLISH_WT);
461
+ }
462
+ wt = PUBLISH_WT;
463
+ console.log(`[publish] version commit will run in ${PUBLISH_WT} (detached at origin/main) — ROOT is untouched`);
464
+ } catch (e) {
465
+ console.error(`[publish] FATAL: could not prepare the main-pinned publish worktree (${String(e.message || e).split('\n')[0]}).`);
466
+ console.error('[publish] Refusing to fall back to committing in ROOT — that is the exact stranded-commit bug this replaces.');
467
+ process.exit(1);
468
+ }
469
+
470
+ // Seed the worktree with the new version, then let sync-version.mjs fan it out from its single
471
+ // source of truth exactly as it does in ROOT.
472
+ const wtPlugin = path.join(wt, 'plugin', '.claude-plugin', 'plugin.json');
473
+ const wtDoc = JSON.parse(fs.readFileSync(wtPlugin, 'utf8'));
474
+ wtDoc.version = next;
475
+ fs.writeFileSync(wtPlugin, JSON.stringify(wtDoc, null, 2) + '\n');
476
+ execFileSync(NODE, ['scripts/sync-version.mjs'], { cwd: wt, stdio: 'inherit' });
477
+ execFileSync('git', ['add', 'README.md', 'plugin/.claude-plugin/plugin.json', 'plugin/.codex-plugin/plugin.json',
478
+ 'package.json', 'kb/package.json',
479
+ 'data/manifest.json', 'primer/ruvnet-primer.md', 'explainer/index.html'], { cwd: wt, stdio: 'inherit' });
480
+ execFileSync('git', ['commit', '-m', `Nightly brain refresh ${tag}: ${todo.map((p) => p.name).join(', ')}\n\nAutomated by scripts/self-update.mjs --publish (launchd com.ruvnet.brain-nightly).`], { cwd: wt, stdio: 'inherit' });
481
+ execFileSync('git', ['push', 'origin', 'HEAD:main'], { cwd: wt, stdio: 'inherit' });
482
+ // npm publish — ATOMIC with the GitHub Release so npm `latest` can NEVER lag behind it. This block
483
+ // previously shipped GitHub + plugin but never touched npm, so every nightly left npm further behind
484
+ // (the 2026-07-19 npm 3.4.18 / GitHub 3.4.20 drift). Fail LOUD if npm cannot be published — GitHub
485
+ // being ahead of npm must abort the run visibly, never pass silently. One product version, every channel.
486
+ try {
487
+ let onNpm = '';
488
+ try { onNpm = execFileSync('npm', ['view', `ruvnet-brain@${next}`, 'version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* not yet on npm — fine */ }
489
+ if (onNpm !== next) execFileSync('npm', ['publish', '--tag', 'latest'], { cwd: ROOT, stdio: 'inherit' });
490
+ execFileSync('npm', ['dist-tag', 'add', `ruvnet-brain@${next}`, 'latest'], { cwd: ROOT, stdio: 'inherit' });
491
+ console.log(`[publish] npm ${next} + \`latest\` — channels IN SYNC (npm == GitHub == plugin)`);
492
+ } catch (e) {
493
+ console.error(`[publish] FATAL: npm publish/dist-tag FAILED (${e.message.split('\n')[0]}). GitHub is now AHEAD of npm — the exact drift this guards against. The nightly aborts here; republish npm manually (\`npm publish --tag latest && npm dist-tag add ruvnet-brain@${next} latest\`) and check npm auth in the launchd env.`);
494
+ process.exit(1);
495
+ }
496
+ NOTIFY('🟢 Nightly brain published', `${tag} is live on releases/latest + npm — ${todo.length ? `rebuilt: ${todo.map((p) => p.name).join(', ')}` : `reader-script fixes only`}`);
497
+ console.log(`[publish] DONE — ${tag} live. Users' heartbeats will pick up plugin + brain automatically.`);
498
+ }
499
+ }
500
+
501
+ // Sol amendment to F4 (end of run): the verified rebuilds/publish above are real work and were NOT
502
+ // skipped — but if any freshness probe failed, this run still may not report clean. Exit 1 AFTER the
503
+ // work so the wrapper's retry re-probes (rebuilds are change-gated, so the retry is cheap) and a
504
+ // persistent blind spot escalates instead of hiding inside a green "no-op".
505
+ if (typeof probeFailures !== 'undefined' && probeFailures > 0) {
506
+ 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.`);
507
+ process.exit(1);
508
+ }
@@ -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();