ruvnet-brain 4.0.36 → 4.2.2-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +28 -5
  2. package/bin/install.mjs +405 -23
  3. package/data/model-catalog.json +104 -15
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/brain-console.md +72 -9
  8. package/plugin/commands/configure.md +67 -21
  9. package/plugin/commands/rvcb.md +72 -9
  10. package/plugin/hooks/codex-hooks.json +40 -33
  11. package/plugin/hooks/hook-contracts.json +14 -24
  12. package/plugin/hooks/hooks.json +7 -42
  13. package/plugin/mcp/server.mjs +23 -6
  14. package/plugin/scripts/adr-currency-gate.mjs +150 -0
  15. package/plugin/scripts/capability-registry.mjs +10 -1
  16. package/plugin/scripts/codex-hook-adapter.mjs +121 -19
  17. package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
  18. package/plugin/scripts/continuation-gate.mjs +148 -8
  19. package/plugin/scripts/decision-gate.mjs +428 -0
  20. package/plugin/scripts/decision-outcomes.mjs +231 -0
  21. package/plugin/scripts/degradation-watch.mjs +271 -0
  22. package/plugin/scripts/ground-ruvnet.sh +51 -11
  23. package/plugin/scripts/hijack-ruvnet.sh +11 -3
  24. package/plugin/scripts/hook-registry.mjs +48 -3
  25. package/plugin/scripts/hook-shim.mjs +58 -3
  26. package/plugin/scripts/identifier-preflight.mjs +134 -0
  27. package/plugin/scripts/learn-capture.sh +50 -1
  28. package/plugin/scripts/learn-flush.mjs +5 -5
  29. package/plugin/scripts/lesson-bridge.mjs +343 -0
  30. package/plugin/scripts/lesson-hooks.sh +26 -0
  31. package/plugin/scripts/lesson-promote.mjs +50 -0
  32. package/plugin/scripts/lesson-store.mjs +6 -1
  33. package/plugin/scripts/mcp-readiness.mjs +107 -0
  34. package/plugin/scripts/protect-brain-state.sh +9 -0
  35. package/plugin/scripts/runtime-preferences.mjs +40 -0
  36. package/plugin/scripts/session-snapshot-hook.mjs +15 -6
  37. package/plugin/scripts/spend-guard.mjs +125 -0
  38. package/plugin/scripts/unprompted-runtime.mjs +12 -2
  39. package/plugin/scripts/update-apply.mjs +7 -2
  40. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
  41. package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
  42. package/scripts/brain-score.mjs +261 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +38 -4
  45. package/scripts/card-from-source.mjs +114 -0
  46. package/scripts/console-engine.mjs +1 -1
  47. package/scripts/corpus-candidate.mjs +294 -0
  48. package/scripts/corpus-reconcile.mjs +273 -0
  49. package/scripts/corpus-seed-publish.mjs +110 -0
  50. package/scripts/health-repair.mjs +11 -2
  51. package/scripts/ingest-new-repos.mjs +122 -0
  52. package/scripts/ingest-repo.mjs +66 -6
  53. package/scripts/learning-replay-cli.mjs +8 -3
  54. package/scripts/learning-replay-fixture.mjs +25 -6
  55. package/scripts/learning-replay-proof.mjs +38 -0
  56. package/scripts/nightly-wrapper.sh +55 -7
  57. package/scripts/onboarding-console.mjs +21 -1
  58. package/scripts/org-repo-count.mjs +119 -0
  59. package/scripts/rebuild-gists-from-receipts.mjs +246 -0
  60. package/scripts/release-transaction.mjs +30 -2
  61. package/scripts/release.mjs +147 -0
  62. package/scripts/repo-count-detector.mjs +62 -0
  63. package/scripts/restore-local-ingests.mjs +116 -0
  64. package/scripts/rvf-generation.mjs +44 -0
  65. package/scripts/selfcheck.mjs +9 -1
  66. package/scripts/stabilization-receipt.mjs +11 -1
  67. package/scripts/sync-census.mjs +0 -0
  68. package/scripts/sync-commands.mjs +117 -0
@@ -24,7 +24,7 @@ import path from 'node:path';
24
24
  import os from 'node:os';
25
25
  import { execFileSync, spawnSync } from 'node:child_process';
26
26
  import { findStores, diagnose } from './memory-doctor.mjs';
27
- import { loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
27
+ import { learnerCwd, loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
28
28
 
29
29
  const HOME = os.homedir();
30
30
  // The SAME project root learn-flush.mjs computes, by the same rule — the two halves of the flush
@@ -195,7 +195,16 @@ function flushLearning() {
195
195
  function trainLearning() {
196
196
  if (!RUFLO) return { ok: false, log: 'ruflo is not on this machine — install it with `npm i -g ruflo@latest` to enable learning' };
197
197
  try {
198
- execFileSync(RUFLO, ['hooks', 'intelligence', '--train'], { cwd: HOME, env: RUFLO_ENV, stdio: 'ignore', timeout: 600_000 });
198
+ // ISSUE #136: train the SAME store the console card reads. With `cwd: HOME` the card read the
199
+ // project's learner and this trained the home one, so the button could never clear the card it
200
+ // was offered for — a remedy that cannot resolve its own finding is worse than no button.
201
+ // PROJECT is the same root learn-flush.mjs and learn-capture.sh resolve (#134).
202
+ // ISSUE #139 — now that ruflo v3.38.9 made `--train` REAL (ruvnet/ruflo#2940 was a no-op
203
+ // before), training the wrong store is no longer harmless: it moves that store's
204
+ // lastAdaptation to 0s and the card SILENTLY SELF-CLEARS while the learner the operator
205
+ // actually uses is untouched. #136 already moved this off `cwd: HOME`; it now shares the
206
+ // console's resolver so the remedy provably trains the store the card measured.
207
+ execFileSync(RUFLO, ['hooks', 'intelligence', '--train'], { cwd: learnerCwd({ cwd: PROJECT }), env: RUFLO_ENV, stdio: 'ignore', timeout: 600_000 });
199
208
  } catch (e) { return { ok: false, log: `training cycle failed: ${e.message}` }; }
200
209
  return { ok: true, log: 'ran one training cycle in the cross-project learner' };
201
210
  }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ingest-new-repos.mjs — load what rUv shipped, without being asked.
4
+ *
5
+ * THE GAP THIS CLOSES, and it is embarrassing rather than subtle. The owner asked on 2026-08-20
6
+ * why `ruvnet/ultrasonic` was not in the brain: *"aren't you loading everything Ruv creates every
7
+ * day?????"* The honest answer was no, and nothing ever had. `nightly-wrapper.sh` refreshed
8
+ * lessons, health, the release watchdog and the replay proof — and contained ZERO references to
9
+ * ingestion. New rUv repos only ever entered the brain when a human typed `ingest-repo.mjs` by
10
+ * hand, so the corpus drifted behind the org silently and the only symptom was a question the
11
+ * brain could not answer.
12
+ *
13
+ * `brain-stamp.mjs` already COMPUTES built-vs-pending every night. Nothing acted on it. Measuring
14
+ * a gap and never closing it is how 137 repos stayed uningested while the scorecard reported the
15
+ * number cheerfully.
16
+ *
17
+ * WHY IT IS BOUNDED. Ingestion embeds a whole repository; doing 137 in one night would run for
18
+ * hours and starve everything after it in the nightly. `--max` (default 3) keeps the nightly
19
+ * bounded and lets the corpus converge over days instead of blocking on one enormous run. That is
20
+ * a deliberate trade, and the run SAYS how many remain rather than implying it finished.
21
+ *
22
+ * WHAT IT WILL NOT DO. It does not write capability cards. An ingested store with no card is DARK —
23
+ * valid bytes that no by-description query can reach — and `ingest-repo.mjs` already says so on
24
+ * every run. Inventing a card from a repo name would put a confident description in the routing
25
+ * layer that nobody grounded in the source, which is worse than an honest gap: it would route real
26
+ * questions to a corpus that cannot answer them. So this reports the dark stores it created and
27
+ * leaves the card to someone who reads the repo.
28
+ *
29
+ * node scripts/ingest-new-repos.mjs # report only
30
+ * node scripts/ingest-new-repos.mjs --apply # ingest up to --max new repos
31
+ * node scripts/ingest-new-repos.mjs --apply --max 5
32
+ */
33
+ import { execFileSync, spawnSync } from 'node:child_process';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { storeRoot, storesAt, darkStores } from '../kb/store-root.mjs';
37
+
38
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
39
+ const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
40
+ const APPLY = process.argv.includes('--apply');
41
+ const arg = (n, d) => {
42
+ const i = process.argv.indexOf(n);
43
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d;
44
+ };
45
+ const MAX = Number(arg('--max', '3'));
46
+
47
+ /** Every repo the org actually has right now. Live, never a remembered list. */
48
+ function liveOrgRepos() {
49
+ try {
50
+ const out = execFileSync('gh', [
51
+ 'repo', 'list', OWNER, '--limit', '500', '--no-archived', '--json', 'name,isFork,pushedAt,diskUsage',
52
+ ], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 });
53
+ return JSON.parse(out)
54
+ .filter((r) => !r.isFork)
55
+ // AN EMPTY REPO IS NOT A FAILURE TO RETRY FOREVER (measured 2026-08-20).
56
+ //
57
+ // The first bulk run reported 7 failures; six of them are size=0KB — genuinely empty
58
+ // repositories (socket, Auto-GPT, CodeGPT, ruvGPT2, rUvGPT, AIConverse). `git fetch` succeeds
59
+ // and there is simply nothing to embed, so ingestion fails correctly and then the repo stays
60
+ // "missing", so the NEXT night retries it, forever. A permanent nightly failure that is
61
+ // actually correct behaviour trains the reader to ignore the failure line — which is how a
62
+ // real failure would then hide inside it.
63
+ .filter((r) => Number(r.diskUsage ?? 1) > 0)
64
+ // Newest first: if the budget only allows a few tonight, spend it on what rUv shipped most
65
+ // recently, which is what a question is most likely to be about.
66
+ .sort((a, b) => String(b.pushedAt).localeCompare(String(a.pushedAt)))
67
+ .map((r) => r.name);
68
+ } catch (error) {
69
+ return { error: error?.message || String(error) };
70
+ }
71
+ }
72
+
73
+ export function pendingRepos(orgRepos, root = storeRoot()) {
74
+ const have = new Set(storesAt(root).map((s) => String(s).toLowerCase()));
75
+ return orgRepos.filter((name) => !have.has(String(name).toLowerCase()));
76
+ }
77
+
78
+ const isMain = (() => {
79
+ try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
80
+ catch { return false; }
81
+ })();
82
+
83
+ if (isMain) {
84
+ const repos = liveOrgRepos();
85
+ if (!Array.isArray(repos)) {
86
+ // A LIST WE COULD NOT FETCH IS NOT AN EMPTY LIST. Reporting "0 new repos" here would read as
87
+ // "the brain is current" when the truth is "I could not look" — the exact conflation this
88
+ // repository keeps paying for.
89
+ console.error(`[ingest-new] could NOT list ${OWNER}'s repos: ${repos.error}`);
90
+ console.error('[ingest-new] this is NOT "nothing new" — it is "I could not look". Exiting 1.');
91
+ process.exit(1);
92
+ }
93
+
94
+ const root = storeRoot();
95
+ const missing = pendingRepos(repos, root);
96
+ console.log(`[ingest-new] ${OWNER}: ${repos.length} live repos, ${storesAt(root).length} ingested, ${missing.length} missing`);
97
+ if (!missing.length) { console.log('[ingest-new] corpus is level with the org.'); process.exit(0); }
98
+
99
+ console.log(`[ingest-new] next up (newest first): ${missing.slice(0, MAX).join(', ')}`);
100
+ if (!APPLY) {
101
+ console.log(`[ingest-new] report only. Re-run with --apply to ingest up to ${MAX}.`);
102
+ process.exit(0);
103
+ }
104
+
105
+ let done = 0; let failed = 0;
106
+ for (const name of missing.slice(0, MAX)) {
107
+ console.log(`\n[ingest-new] ingesting ${name}`);
108
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'ingest-repo.mjs'), '--name', name],
109
+ { cwd: ROOT, stdio: 'inherit', env: process.env, timeout: 45 * 60_000 });
110
+ if (r.status === 0) done += 1;
111
+ else { failed += 1; console.log(`[ingest-new] ${name} FAILED — left missing so the next run retries it`); }
112
+ }
113
+
114
+ const stillMissing = missing.length - done;
115
+ const dark = darkStores(root);
116
+ console.log(`\n[ingest-new] ingested ${done}, failed ${failed}, ${stillMissing} still missing.`);
117
+ if (dark.length) {
118
+ console.log(`[ingest-new] ${dark.length} store(s) are DARK — valid bytes no by-description query can reach.`);
119
+ console.log('[ingest-new] a card is a claim about what a repo is FOR; write it from the source, not the name.');
120
+ }
121
+ process.exit(failed ? 1 : 0);
122
+ }
@@ -20,6 +20,7 @@ import fs from 'node:fs';
20
20
  import { execFileSync } from 'node:child_process';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
23
+ import { storeRoot } from '../kb/store-root.mjs';
23
24
 
24
25
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
26
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
@@ -33,7 +34,26 @@ if (!NAME) {
33
34
  process.exit(2);
34
35
  }
35
36
 
36
- const KB = path.join(ROOT, 'kb');
37
+ // THE WRITER NOW USES THE SAME RESOLVER AS THE READER. This was `path.join(ROOT, 'kb')` — the
38
+ // repo's build directory — while retrieval (kb/forge-mcp-all.mjs) reads storeRoot(), which defaults
39
+ // to ~/.cache/ruvnet-brain/kb. That split is why on 2026-08-12 three repos were ingested, each
40
+ // printed `roundtrip 3/3 PASS` and "searchable now", and NONE of them could be found by search: the
41
+ // ingest wrote one root and the live brain read another. This file's own header promises "the new
42
+ // repo is searchable immediately", and that promise was false by construction.
43
+ //
44
+ // Two adversarial audits both flagged that fixing the READER alone (kb/store-root.mjs, earlier
45
+ // today) made the split GUARANTEED rather than accidental for a dev checkout. This closes it at the
46
+ // writer, which is the half that was left.
47
+ //
48
+ // The repo's own bundle build still targets ./kb by setting KB_DIR — an explicit override of a
49
+ // stated default, not a second answer to the same question.
50
+ // TWO DIFFERENT THINGS, WHICH THE ORIGINAL `path.join(ROOT,'kb')` CONFLATED and my first fix
51
+ // inherited: where the BUILD SCRIPTS live (forge-refresh.mjs, always in this checkout) and where
52
+ // the STORES go (the root retrieval actually reads). Pointing both at storeRoot() broke immediately
53
+ // with `Cannot find module .../.cache/ruvnet-brain/kb/forge-refresh.mjs` — the tool is not the
54
+ // output. One name for two facts is the same defect as one fact in two names.
55
+ const KB_TOOLS = path.join(ROOT, 'kb'); // where forge-refresh.mjs lives
56
+ const KB = storeRoot(); // where stores go — the root the reader reads
37
57
  const CLONES = path.join(ROOT, 'clones');
38
58
  const kb = NAME.toLowerCase();
39
59
  const dir = SOURCE ? path.resolve(SOURCE) : path.join(CLONES, NAME);
@@ -62,18 +82,58 @@ const url = process.env.RUVNET_CANONICAL_URL || 'https://raw.githubusercontent.c
62
82
  // full-body source indexing (the 2026-07-10 depth-restore run zeroed ruvector this way).
63
83
  const FULL = arg('--full', FULL_HINTS[kb] || '');
64
84
  const KEEP = arg('--keep', KEEP_DIRS[kb] || '');
65
- const buildArgs = ['forge-refresh.mjs', '--repo', dir, '--out', '.', '--name', kb, '--canonical-url', url];
85
+ // `--out` is the STORE ROOT, not '.' — cwd is the tools directory now, and '.' would have written
86
+ // the store next to the build script, which is precisely the reader/writer split this change closes.
87
+ const buildArgs = ['forge-refresh.mjs', '--repo', dir, '--out', KB, '--name', kb, '--canonical-url', url];
66
88
  if (FULL) buildArgs.push('--full', FULL);
67
89
  if (KEEP) buildArgs.push('--keep', KEEP);
68
90
  console.log(`[refresh bge-768] ${kb}${FULL ? ' (--full: ' + FULL.split(',').length + ' prefixes)' : ''}${KEEP ? ' (--keep: ' + KEEP + ')' : ''}`);
69
- run('node', buildArgs, { cwd: KB, env });
91
+ run('node', buildArgs, { cwd: KB_TOOLS, env });
70
92
  console.log(`[symbols] ${kb}`);
71
93
  try { run('node', ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env }); } catch { console.log(' (symbols skipped — sparse repo)'); }
72
94
 
73
95
  const ok = fs.existsSync(path.join(KB, `${kb}.big.rvf`))
74
96
  && fs.existsSync(path.join(KB, `${kb}.passages.jsonl`))
75
97
  && fs.existsSync(path.join(KB, `${kb}.meta.json`));
76
- console.log(ok
77
- ? `\n[done] ${NAME} ingested → searchable now via search_ruvnet (no restart). For capability-confidence, build its primer next.`
78
- : `\n[FAIL] ${NAME}: expected stores missing after build.`);
98
+ // BUILT IS NOT REACHABLE, AND THIS LINE USED TO CLAIM OTHERWISE. It printed "searchable now" on
99
+ // the strength of three files existing. Measured 2026-08-13 on the very next run: helix ingested
100
+ // cleanly, every file present, and `search_ruvnet` answered "Searched 0 RuvNet repos" — the router
101
+ // declined because nothing in capability-cards.md describes the repo, so a by-description query
102
+ // cannot reach it. 26 of 66 stores are in exactly that state. The 2026-08-12 incident (three repos
103
+ // ingested, all reporting success, none findable) was read as a path bug; the path was only half of
104
+ // it. A store with no card is BUILT AND DARK, and saying "searchable" of it is the product lying.
105
+ const carded = (() => {
106
+ try {
107
+ return new RegExp(`^##\\s+${kb}\\s*$`, 'im')
108
+ .test(fs.readFileSync(path.join(KB, 'capability-cards.md'), 'utf8'));
109
+ } catch { return false; }
110
+ })();
111
+ // AN ARTIFACT WITH NO COMMITTED RECIPE IS NOT DURABLE WORK — it is a local side effect with a
112
+ // countdown on it. Proven overnight 2026-08-13 -> 08-14: a scheduled bundle apply extracted an
113
+ // Aug-8 bundle over the live cache. capability-cards.md SURVIVED because it was committed and the
114
+ // brain was restored from the repo in one command. helix, rvQR, RuCelium and wifi-veil — ingested
115
+ // and VERIFIED ROUTING hours earlier — were gone without a trace, because a .rvf is a 32MB
116
+ // gitignored build artifact that existed in exactly one directory on one machine.
117
+ //
118
+ // The stores themselves cannot be committed. The FACT that they were ingested can, so a wipe
119
+ // becomes detectable and replayable instead of silent and permanent. This record is the recipe.
120
+ if (ok) {
121
+ try {
122
+ const ledger = path.join(ROOT, 'kb', 'local-ingests.json');
123
+ const prior = fs.existsSync(ledger) ? JSON.parse(fs.readFileSync(ledger, 'utf8')) : { ingests: [] };
124
+ const rest = (prior.ingests || []).filter((e) => e.name !== NAME);
125
+ rest.push({ name: NAME, org: ORG, at: new Date().toISOString(), store: kb });
126
+ rest.sort((a, b) => a.name.localeCompare(b.name));
127
+ fs.writeFileSync(ledger, `${JSON.stringify({ ingests: rest }, null, 2)}\n`);
128
+ console.log(`[record] kb/local-ingests.json — ${rest.length} local ingest(s) recorded. COMMIT THIS.`);
129
+ } catch (e) { console.log(`[record] could not record the ingest: ${e.message}`); }
130
+ }
131
+
132
+ console.log(!ok
133
+ ? `\n[FAIL] ${NAME}: expected stores missing after build.`
134
+ : carded
135
+ ? `\n[done] ${NAME} ingested AND routable → answers by-description queries now (no restart).`
136
+ : `\n[done] ${NAME} ingested, but DARK: no '## ${kb}' section in ${path.join(KB, 'capability-cards.md')}.\n`
137
+ + ` The store is valid and a by-description query CANNOT reach it. Write a card describing\n`
138
+ + ` what ${NAME} is for and when to reach for it, then re-check with a real search_ruvnet query.`);
79
139
  process.exit(ok ? 0 : 1);
@@ -132,9 +132,14 @@ export async function main(argv = process.argv.slice(2)) {
132
132
  const dirs = buildFixtures(base);
133
133
  process.once('exit', () => cleanupFixtureDaemons(dirs));
134
134
  const record = recordInProjectA(dirs, { trap });
135
- const seed = trap === TRAP.MEMORY_SEARCH
136
- ? seedProjectBMemory(dirs)
137
- : { key: null, storeExit: null, ok: true, skipped: 'not required' };
135
+ // Every target fixture needs a real AgentDB file before Codex starts. The global SessionStart
136
+ // contract otherwise injects an opt-in question and requires the model to wait for an answer,
137
+ // which prevents both post-task arms from producing the command this trap is meant to compare.
138
+ // Only the memory-search trap needs the independent cache note; post-task needs an initialized,
139
+ // otherwise empty store so its control differs solely on the lesson under test.
140
+ const seed = seedProjectBMemory(dirs, {
141
+ includeFixtureRecord: trap === TRAP.MEMORY_SEARCH,
142
+ });
138
143
  const refresh = nightlyRefresh(dirs);
139
144
  console.log(` record: ${record.projectCount} sources; promoted=${record.promoted}; ok=${record.ok}`);
140
145
  console.log(` seed: ${seed.skipped || `ok=${seed.ok}`}`);
@@ -23,11 +23,16 @@ import {
23
23
  const CLAUDE_BIN = process.env.RUVNET_CLAUDE_BIN
24
24
  || path.join(os.homedir(), '.npm-global', 'bin', 'claude');
25
25
  const CODEX_BIN = process.env.RUVNET_CODEX_BIN || 'codex';
26
- const sh = (cmd, args, options = {}) => spawnSync(cmd, args, {
27
- encoding: 'utf8',
28
- timeout: 120_000,
29
- ...options,
30
- });
26
+ const sh = (cmd, args, options = {}) => {
27
+ const [binary, argv] = /\.[cm]?js$/i.test(cmd)
28
+ ? [process.execPath, [cmd, ...args]]
29
+ : [cmd, args];
30
+ return spawnSync(binary, argv, {
31
+ encoding: 'utf8',
32
+ timeout: 120_000,
33
+ ...options,
34
+ });
35
+ };
31
36
  const remove = (target) => {
32
37
  try { fs.rmSync(target, { recursive: true, force: true }); } catch { /* already gone */ }
33
38
  };
@@ -143,9 +148,23 @@ export function recordInProjectA(dirs, {
143
148
  };
144
149
  }
145
150
 
146
- export function seedProjectBMemory(dirs, { ruflo = RUFLO_BIN } = {}) {
151
+ export function seedProjectBMemory(dirs, {
152
+ ruflo = RUFLO_BIN,
153
+ includeFixtureRecord = true,
154
+ } = {}) {
147
155
  const db = path.join(dirs.projectB, '.swarm', 'memory.db');
148
156
  const init = initMemoryDb(ruflo, db, dirs.projectB);
157
+ if (!includeFixtureRecord) {
158
+ return {
159
+ db,
160
+ key: null,
161
+ initExit: init.status,
162
+ storeExit: null,
163
+ exactReadExit: null,
164
+ ok: init.status === 0 && fs.existsSync(db),
165
+ skipped: 'AgentDB initialized; memory-search record not required',
166
+ };
167
+ }
149
168
  const store = sh(ruflo, [
150
169
  'memory', 'store', '-k', PROJECT_B_MEMORY_KEY,
151
170
  '--value', PROJECT_B_MEMORY_VALUE, '-n', 'default', '--path', db,
@@ -454,6 +454,44 @@ export function writeArtifact(file, aggregateResult, meta = {}) {
454
454
  runs,
455
455
  };
456
456
  fs.mkdirSync(path.dirname(file), { recursive: true });
457
+
458
+ // AN ENVIRONMENT THAT CANNOT MEASURE MUST NOT OVERWRITE ONE THAT DID.
459
+ // Measured 2026-08-19: `learning-replay` had been red on main since 2026-08-11 because the
460
+ // artifact was re-recorded carrying `verdict: UNKNOWN` and "3/3 run(s) could not be measured;
461
+ // executor error: spawnSync codex ENOENT" — written by a nightly container with no `codex` on
462
+ // PATH. `codex` resolves fine on the owner's machine (0.148.0), so a real PASS was replaced by
463
+ // "I could not look", and every later CI run correctly refused the non-PASS verdict. The gate was
464
+ // right; its INPUT had been destroyed by a host that could never produce one. Same distinction
465
+ // `restore-local-ingests.mjs` and `degradation-watch` both needed: CANNOT-MEASURE is not
466
+ // MEASURED-AND-FAILED. A real FAIL still overwrites — that is a measurement, and it must land.
467
+ // "PARTIALLY MEASURED" IS NOT "COULD NOT MEASURE" either, and the first version of this guard got
468
+ // that wrong within the hour — the same conflation it exists to prevent, committed inside it. Its
469
+ // predicate was a substring match on "could not be measured", so a run reporting
470
+ // "1/3 run(s) could not be measured; 1/3 passed" — a genuine mixed result carrying a live
471
+ // regression signal — was swallowed, leaving a PASS from 2026-08-03 standing. It suppressed
472
+ // exactly the finding it should have let through. A host that cannot measure produces NO
473
+ // measurements: every run unknown. If even one run yielded a pass or a fail, the executor plainly
474
+ // ran and the result is real, however unwelcome.
475
+ const runsTotal = Number(artifact.n ?? 0);
476
+ const runsUnknown = Number(artifact.unknowns ?? 0);
477
+ const nothingMeasured = runsTotal > 0 && runsUnknown === runsTotal;
478
+ const unmeasurable = artifact.verdict === 'UNKNOWN'
479
+ && nothingMeasured
480
+ && /ENOENT|executor error/i.test(String(artifact.why ?? ''));
481
+ if (unmeasurable && fs.existsSync(file)) {
482
+ try {
483
+ const prior = JSON.parse(fs.readFileSync(file, 'utf8'));
484
+ if (prior?.verdict && prior.verdict !== 'UNKNOWN') {
485
+ process.stderr.write(
486
+ `[learning-replay] REFUSING to overwrite a ${prior.verdict} recorded ${prior.at} with an\n`
487
+ + ` unmeasurable UNKNOWN — this host cannot run the executor (${artifact.why}).\n`
488
+ + ' The prior measurement stands. Re-record on a host that has the executor.\n',
489
+ );
490
+ return prior;
491
+ }
492
+ } catch { /* unreadable prior is not a reason to keep a bad one — fall through and write */ }
493
+ }
494
+
457
495
  fs.writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`);
458
496
  return artifact;
459
497
  }
@@ -7,6 +7,29 @@
7
7
  # class a blind retry can fix) -> if it fails AGAIN, loud phone alert + a durable marker file that
8
8
  # ground-ruvnet.sh surfaces at the top of the very next session, unprompted. Success or a legitimate
9
9
  # "nothing was due tonight" no-op: log only, phone stays silent.
10
+
11
+ # WHICH NODE RUNS THIS DECIDES WHETHER TONIGHT'S WRITES SURVIVE (measured 2026-08-20).
12
+ #
13
+ # This file hardcoded "$NODE_BIN" in eight places. That is node v22.13.1 (ABI 127), and the
14
+ # better-sqlite3 binding agentdb needs is built for ABI 137 — so under it agentdb SILENTLY falls
15
+ # back to non-persistent sql.js and every write is lost. Silently, because better-sqlite3 is an
16
+ # OPTIONAL dependency (ruvnet/ruflo#2219): the failure never errors and the CLI still prints a
17
+ # success table. `lesson-bridge.mjs --apply` and `learning-replay.mjs` both write, so the nightly
18
+ # was discarding its own results. Measured, same command, same DB, only the interpreter differing:
19
+ # "$NODE_BIN" v22.13.1 -> 2 sql.js fallback msgs, DURABLE=NO
20
+ # /opt/homebrew/bin/node v24.18.0 -> 0 fallback msgs, DURABLE=YES
21
+ # Resolve a node whose ABI matches the binding, and fail loudly rather than silently losing data.
22
+ NODE_BIN=""
23
+ for _cand in /opt/homebrew/opt/node@24/bin/node /opt/homebrew/bin/node "$(command -v node 2>/dev/null)"; do
24
+ [ -x "$_cand" ] || continue
25
+ if [ "$("$_cand" -p 'process.versions.modules' 2>/dev/null)" = "137" ]; then NODE_BIN="$_cand"; break; fi
26
+ done
27
+ if [ -z "$NODE_BIN" ]; then
28
+ echo "[nightly] FATAL: no node with ABI 137 found — agentdb writes would silently not persist." >&2
29
+ echo "[nightly] checked: /opt/homebrew/opt/node@24/bin/node /opt/homebrew/bin/node \$(command -v node)" >&2
30
+ exit 1
31
+ fi
32
+
10
33
  set -u
11
34
  # kb/models-cache (the fallback) starts cold every time -> every nightly re-downloads the ONNX
12
35
  # embedder from HuggingFace. Point at the already-warm cache instead (verified present).
@@ -55,9 +78,9 @@ run_once() {
55
78
  { echo "===== nightly-wrapper rebuild attempt $1 — $(date -u +%FT%TZ) ====="
56
79
  if [ "$SMOKE" = "1" ]; then
57
80
  echo "[SMOKE] dry-run: exercising the full chain, NOT building"
58
- /usr/local/bin/node scripts/self-update.mjs --fresh-window 60
81
+ "$NODE_BIN" scripts/self-update.mjs --fresh-window 60
59
82
  else
60
- /usr/local/bin/node scripts/self-update.mjs --apply --fresh-window 60
83
+ "$NODE_BIN" scripts/self-update.mjs --apply --fresh-window 60
61
84
  fi
62
85
  } >> "$LOG" 2>&1
63
86
  rc=$?
@@ -83,6 +106,19 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path .swar
83
106
  # automatically (keeps last 14), zero risk to the live DB (reads only).
84
107
  RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memory.db --keep 14 >> "$LOG" 2>&1 || true
85
108
 
109
+ # LESSON BRIDGE (ADR-066, 2026-08-10) — carry newly-tagged machine-wide lessons into the store the
110
+ # hooks actually read. Without this the bridge is a command someone has to remember, and a learning
111
+ # system that depends on being remembered is the thing ADR-066 was written to fix: knowledge sat in
112
+ # AgentDB for 18 days while the repo re-made the mistake it described.
113
+ #
114
+ # Safe to run unattended and to run when nothing changed: it is idempotent (bridged rows are replaced
115
+ # wholesale, native rows are never touched), it writes through the store's locked atomic writer, and
116
+ # it REFUSES to apply when it reads zero rows while bridged lessons are installed — so a transient
117
+ # read failure can never strip the store. Best-effort, never blocks the rebuild.
118
+ echo "===== lesson-bridge — $(date -u +%FT%TZ) =====" >> "$LOG"
119
+ "$NODE_BIN" plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
120
+ || echo "===== lesson-bridge: SKIPPED (see line above) =====" >> "$LOG"
121
+
86
122
  # AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
87
123
  # 2026-07-09/10, the exact nights idx_bridge_key/idx_bridge_ns corruption recurred 3x on this very DB
88
124
  # while in WAL mode. Best-effort, same shape as its brain-health/key-health siblings below: never
@@ -128,15 +164,27 @@ sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
128
164
  # REPORTS ONLY. It cannot push, merge, close or publish (asserted by test). Exit 1 means a human
129
165
  # needs to look, and the reasons print in full rather than as a count.
130
166
  echo "===== GITHUB-HEALTH watch — $(date -u +%FT%TZ) =====" >> "$LOG"
131
- /usr/local/bin/node scripts/github-health-watch.mjs >> "$LOG" 2>&1 \
167
+ # LOAD WHAT rUv SHIPPED, WITHOUT BEING ASKED (added 2026-08-20).
168
+ #
169
+ # The owner: "aren't you loading everything Ruv creates every day?????" The answer was no — this
170
+ # file had ZERO ingestion, so new repos only entered the brain when a human typed the command.
171
+ # Measured the day this landed: 181 live repos, 69 ingested, 125 missing. `brain-stamp.mjs` had
172
+ # been COMPUTING that gap nightly and nothing acted on it.
173
+ #
174
+ # Bounded to 3/night on purpose: ingestion embeds a whole repository, and 125 in one run would
175
+ # starve everything after it here. The corpus converges over days and the run says how many
176
+ # remain rather than implying it finished. A failed repo is left missing so the next night retries.
177
+ "$NODE_BIN" scripts/ingest-new-repos.mjs --apply --max 3 >> "$LOG" 2>&1 || true
178
+
179
+ "$NODE_BIN" scripts/github-health-watch.mjs >> "$LOG" 2>&1 \
132
180
  || echo "[github-health] findings above need attention" >> "$LOG"
133
181
 
134
182
  echo "===== RELEASE-CONVERGENCE watchdog — $(date -u +%FT%TZ) =====" >> "$LOG"
135
- /usr/local/bin/node scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
183
+ "$NODE_BIN" scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
136
184
  || echo "[release-watchdog] exited non-zero — see above; nightly continues" >> "$LOG"
137
185
 
138
186
  echo "===== LEARNING-REPLAY counterfactual trap — $(date -u +%FT%TZ) =====" >> "$LOG"
139
- /usr/local/bin/node scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
187
+ "$NODE_BIN" scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
140
188
  LR_RC=$?
141
189
  case "$LR_RC" in
142
190
  0) echo "===== LEARNING-REPLAY: PASS =====" >> "$LOG" ;;
@@ -152,7 +200,7 @@ esac
152
200
  BRAIN_KB="$HOME/.cache/ruvnet-brain/kb"
153
201
  if [ -f "$BRAIN_KB/forge-ask-all.mjs" ]; then
154
202
  echo "===== brain-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
155
- if (cd "$BRAIN_KB" && /usr/local/bin/node forge-ask-all.mjs --dir . --q "HNSW vector index" --k 1) >> "$LOG" 2>&1; then
203
+ if (cd "$BRAIN_KB" && "$NODE_BIN" forge-ask-all.mjs --dir . --q "HNSW vector index" --k 1) >> "$LOG" 2>&1; then
156
204
  echo "===== brain-health canary: OK =====" >> "$LOG"
157
205
  else
158
206
  echo "===== brain-health canary: DOWN — escalating =====" >> "$LOG"
@@ -168,7 +216,7 @@ fi
168
216
  # The canary itself handles urgent pushes on alive->DEAD transitions (and recovery notices), so a
169
217
  # known-dead key doesn't re-alarm every night; here we only log.
170
218
  echo "===== key-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
171
- zsh -lc 'cd /Users/stuartkerr/Code/ruvnet-brain && /usr/local/bin/node scripts/key-canary.mjs --notify' >> "$LOG" 2>&1 \
219
+ zsh -lc 'cd /Users/stuartkerr/Code/ruvnet-brain && "$NODE_BIN" scripts/key-canary.mjs --notify' >> "$LOG" 2>&1 \
172
220
  && echo "===== key-health canary: all present keys alive =====" >> "$LOG" \
173
221
  || echo "===== key-health canary: at least one key DEAD (push sent on new deaths) =====" >> "$LOG"
174
222
 
@@ -66,6 +66,7 @@ import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf,
66
66
  import {
67
67
  openRouterCredentialStatus,
68
68
  saveOpenRouterCredential,
69
+ learnerCwd,
69
70
  } from '../plugin/scripts/runtime-preferences.mjs';
70
71
  import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
71
72
  // One canonical answer to "which directory is this, and have I counted it already?" — shared with
@@ -2213,10 +2214,29 @@ function observeLearning() {
2213
2214
 
2214
2215
  let lastTrainSeconds = null; let trajectories = 0;
2215
2216
  try {
2217
+ // ISSUE #136 — THE LEARNER IS PROJECT-SCOPED, so this must ask about the SERVED project.
2218
+ //
2219
+ // `ruflo hooks intelligence --status` reports `Data Dir: <cwd>/.claude-flow/neural`. With
2220
+ // `cwd: SYSTEM_HOME` this measured `~/.claude-flow/neural` — a store nothing writes to on a
2221
+ // machine whose work happens inside project directories. Measured on one machine, one minute:
2222
+ // the home store held 1,216 trajectories last trained 6.9 DAYS ago while the served project held
2223
+ // 9,940 last trained 22 SECONDS ago. The card said "Your learner has gone quiet" about a learner
2224
+ // training every few seconds.
2225
+ //
2226
+ // This file already carries the verdict on this exact mistake at the refresh-child spawn: "cwd =
2227
+ // the SERVED project, NOT REPO … it was a real console-honesty bug". Same rule, same file,
2228
+ // different call site — #104's and #134's residual arriving a third time.
2229
+ // ISSUE #139 (@ObiWanKenobi) — `process.cwd()` was a HARDCODE in the opposite direction from
2230
+ // #136's `SYSTEM_HOME`. Neither asked which scope was in effect; the first was wrong by default
2231
+ // and the second is right only BECAUSE `project` is the default. Under
2232
+ // `RUVNET_LEARNING_SCOPE=user` the flush feeds ~/.claude-flow/neural while this read
2233
+ // <project>/.claude-flow/neural — the same false-positive card, inverted. The writer and this
2234
+ // reader now call ONE resolver (runtime-preferences.learnerCwd), so they agree by construction
2235
+ // instead of by coincidence.
2216
2236
  const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
2217
2237
  ['hooks', 'intelligence', '--status'],
2218
2238
  {
2219
- cwd: SYSTEM_HOME,
2239
+ cwd: learnerCwd(),
2220
2240
  env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
2221
2241
  encoding: 'utf8',
2222
2242
  timeout: 20_000,
@@ -0,0 +1,119 @@
1
+ /**
2
+ * org-repo-count.mjs — how many repos rUv actually has, DERIVED, with the source recorded.
3
+ *
4
+ * THE DEFECT THIS REPLACES. `orgTotalApprox: 248` was a literal, written twice:
5
+ *
6
+ * scripts/build-bundle.mjs:402 coverage: { …, orgTotalApprox: 248, … }
7
+ * scripts/brain-stamp.mjs:77 coverage: { …, orgTotalApprox: 248, … }
8
+ *
9
+ * Measured 2026-08-12, two independent ways: `users/ruvnet.public_repos` = 200, and a paginated
10
+ * `users/ruvnet/repos` fetch = 200 distinct names. So the denominator every coverage percentage in
11
+ * this product is computed against was wrong by 48 — and wrong in BOTH copies, which is what a
12
+ * restated fact always does.
13
+ *
14
+ * Replacing 248 with 200 would repeat the mistake with a fresher number. The count changes whenever
15
+ * rUv pushes a new repo, so it is not a constant and must not be stored as one.
16
+ *
17
+ * HONESTY OVER AVAILABILITY. A build with no network must not silently invent a total. `source` is
18
+ * carried alongside the number so every consumer can see whether it is live:
19
+ *
20
+ * { count: 200, source: 'live', at: '2026-08-12T…' } ← queried just now
21
+ * { count: 200, source: 'recorded', at: '2026-08-12T…' } ← last live reading, reused offline
22
+ * { count: null, source: 'unknown', at: null } ← never say a number we cannot source
23
+ *
24
+ * A consumer that receives `unknown` must omit the claim rather than print a guess — the same rule
25
+ * sync-census follows when it refuses to write a non-positive census.
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { spawnSync } from 'node:child_process';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
33
+ /** The last LIVE reading, so an offline build reuses a real measurement instead of a literal. */
34
+ export const RECORD_PATH = process.env.RUVNET_ORG_COUNT_RECORD
35
+ || path.join(ROOT, 'data', 'org-repo-count.json');
36
+
37
+ export const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
38
+
39
+ /** Ask GitHub. Returns a positive integer or null — never a guess, never a partial page. */
40
+ export function fetchLiveCount(owner = OWNER, { run = spawnSync } = {}) {
41
+ // `public_repos` is the account's own count: one request, no pagination to get wrong.
42
+ const r = run('gh', ['api', `users/${owner}`, '--jq', '.public_repos'], { encoding: 'utf8', timeout: 30_000 });
43
+ if (r.error || r.status !== 0) return null;
44
+ const n = Number(String(r.stdout || '').trim());
45
+ return Number.isInteger(n) && n > 0 ? n : null;
46
+ }
47
+
48
+ const readRecord = (file) => {
49
+ try {
50
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ return Number.isInteger(j?.count) && j.count > 0 ? j : null;
52
+ } catch { return null; }
53
+ };
54
+
55
+ /**
56
+ * The count, its provenance, and when it was taken.
57
+ * A successful live read updates the record, so the offline fallback is always a real past reading.
58
+ */
59
+ /**
60
+ * READING A NUMBER MUST NOT MUTATE A TRACKED FILE.
61
+ *
62
+ * `persist` defaults to FALSE, and that default is the fix for a release that could not ship.
63
+ * Every call used to write `data/org-repo-count.json` — a TRACKED file — with a fresh `at`
64
+ * timestamp. Both production callers (`brain-stamp.mjs`, `build-bundle.mjs`) are BUILD scripts, so
65
+ * building the release candidate dirtied the working tree, and `release-qe`'s stabilization seal
66
+ * requires `dirty === false`. Measured 2026-08-19, once the seal was made to name its cause:
67
+ *
68
+ * stabilization seal failed: INVALID_LINEAGE
69
+ * working tree is dirty (1 path(s)):
70
+ * M data/org-repo-count.json
71
+ *
72
+ * That single line had blocked EVERY release since 2026-08-08 (issue #141: nothing published in
73
+ * eleven days while main advanced 46 commits), because the seal previously reported only the code
74
+ * `INVALID_LINEAGE` and named nothing.
75
+ *
76
+ * The committed record still matters — it is the honest offline fallback, so an air-gapped or
77
+ * rate-limited build reuses the last REAL reading instead of inventing one. It just may not be
78
+ * written as a side effect of being read. Refreshing it is now a deliberate act (`--record`), the
79
+ * same separation `refresh-model-catalog.mjs` already uses for the model snapshot.
80
+ */
81
+ export function orgRepoCount({ owner = OWNER, file = RECORD_PATH, now = new Date(), fetch = fetchLiveCount, persist = false } = {}) {
82
+ const live = fetch(owner);
83
+ if (live) {
84
+ const at = now.toISOString();
85
+ try {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true });
87
+ if (persist) fs.writeFileSync(file, `${JSON.stringify({ owner, count: live, at }, null, 2)}\n`);
88
+ } catch { /* recording is best-effort; the live number still stands */ }
89
+ return { count: live, source: 'live', at };
90
+ }
91
+ const rec = readRecord(file);
92
+ if (rec) return { count: rec.count, source: 'recorded', at: rec.at || null };
93
+ return { count: null, source: 'unknown', at: null };
94
+ }
95
+
96
+ /**
97
+ * Refreshing the committed record is a DELIBERATE ACT, never a side effect of a build.
98
+ *
99
+ * node scripts/org-repo-count.mjs --record
100
+ *
101
+ * Same separation `refresh-model-catalog.mjs` uses for the model snapshot: a build READS, a human
102
+ * (or a scheduled refresh that commits its own result) WRITES. That boundary is what keeps the
103
+ * working tree clean for `release-qe`'s stabilization seal.
104
+ */
105
+ const isMain = (() => {
106
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
107
+ catch { return false; }
108
+ })();
109
+
110
+ if (isMain) {
111
+ const record = process.argv.includes('--record');
112
+ const r = orgRepoCount({ persist: record });
113
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
114
+ if (record && r.source !== 'live') {
115
+ process.stderr.write('NOT recorded: the live read failed, and a remembered number may not be '
116
+ + 'rewritten as if it were fresh.\n');
117
+ process.exit(1);
118
+ }
119
+ }