ruvnet-brain 4.0.35 → 4.0.90-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 (61) hide show
  1. package/README.md +4 -4
  2. package/bin/install.mjs +283 -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 +0 -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 +252 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +25 -1
  45. package/scripts/console-engine.mjs +1 -1
  46. package/scripts/health-repair.mjs +11 -2
  47. package/scripts/host-install-matrix.mjs +21 -0
  48. package/scripts/ingest-repo.mjs +66 -6
  49. package/scripts/learning-replay-cli.mjs +8 -3
  50. package/scripts/learning-replay-fixture.mjs +25 -6
  51. package/scripts/learning-replay-proof.mjs +38 -0
  52. package/scripts/nightly-wrapper.sh +13 -0
  53. package/scripts/onboarding-console.mjs +21 -1
  54. package/scripts/org-repo-count.mjs +119 -0
  55. package/scripts/publication-receipt.mjs +7 -3
  56. package/scripts/repo-count-detector.mjs +62 -0
  57. package/scripts/restore-local-ingests.mjs +116 -0
  58. package/scripts/selfcheck.mjs +9 -1
  59. package/scripts/stabilization-receipt.mjs +11 -1
  60. package/scripts/sync-census.mjs +0 -0
  61. package/scripts/sync-commands.mjs +117 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * brain-score.mjs — the ONLY sanctioned source of a score about this product.
3
+ *
4
+ * THE DEFECT THIS REPLACES, measured 2026-08-13. Asked to grade the architecture, I counted files
5
+ * — stores/repos, cards/stores — called the result 52/100, and reported it as an architecture
6
+ * grade. Six graders sat unused in this same repo. The real numbers were nothing like 52, and they
7
+ * disagreed with each other in ways that mattered:
8
+ *
9
+ * grounded 100/100 does it fabricate? (evals/baseline.json)
10
+ * routed 63/80 does it reach the right store? same file, same run
11
+ * panel ~56 independent multi-vendor grade (data/grade-*.json)
12
+ *
13
+ * A single number could not have carried that. My 52 did not measure quality at all; it measured
14
+ * CATALOGUE COVERAGE and wore a quality label. That is the specific error this file makes
15
+ * structurally impossible: dimensions carry a `kind`, and `composite()` REFUSES to average across
16
+ * kinds. A coverage percentage and a quality percentage are answers to different questions, and
17
+ * collapsing them tells the owner his working product is failing while his users report it works.
18
+ *
19
+ * THE SECOND DEFECT, committed while correcting the first. Having found `grounded 100/100`, I
20
+ * quoted it as current. It was recorded 2026-07-10 — 34 days and ~40 commits earlier. The existing
21
+ * claims gate passed it because it checks INTERNAL consistency (k<=n, lo<=p<=hi) and never asks
22
+ * WHEN. So every dimension here declares `maxAgeDays`, and a reading past it is `stale` — reported,
23
+ * never silently used, and never the basis of a claim.
24
+ *
25
+ * This is not new machinery. It is ADR-065's own rule — facts are GENERATED, behaviours are TESTED
26
+ * — applied to the one class of fact that was still being typed by hand. `orgTotalApprox: 248` was
27
+ * this exact bug about a repo count; it was wrong by 48 in two files at once. `sync-version.mjs`
28
+ * and `sync-census.mjs` fixed it for versions and counts. Scores were never brought along.
29
+ */
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
35
+ const DAY = 86_400_000;
36
+
37
+ /**
38
+ * Every dimension names the QUESTION it answers, because a number whose question is unstated is
39
+ * the thing that produced the 52. `kind` is load-bearing: see `composite()`.
40
+ */
41
+ export const DIMENSIONS = [
42
+ {
43
+ key: 'grounded',
44
+ kind: 'quality',
45
+ question: 'When it answers, is the answer bound to real retrieved source rather than invented?',
46
+ artifact: 'evals/baseline.json',
47
+ maxAgeDays: 14,
48
+ read: (j) => ({ value: pct(j?.score?.grounded), detail: frac(j?.score?.grounded), at: j?.recorded }),
49
+ },
50
+ {
51
+ key: 'routed',
52
+ kind: 'quality',
53
+ question: 'Does a by-description query reach the store that actually holds the answer?',
54
+ artifact: 'evals/baseline.json',
55
+ maxAgeDays: 14,
56
+ read: (j) => ({ value: pct(j?.score?.routed), detail: frac(j?.score?.routed), at: j?.recorded }),
57
+ },
58
+ {
59
+ key: 'abstained',
60
+ kind: 'quality',
61
+ question: 'When it does NOT know, does it decline instead of producing something?',
62
+ artifact: 'evals/baseline.json',
63
+ maxAgeDays: 14,
64
+ read: (j) => ({ value: pct(j?.score?.abstain), detail: frac(j?.score?.abstain), at: j?.recorded }),
65
+ },
66
+ {
67
+ key: 'panelStrict',
68
+ kind: 'quality',
69
+ question: 'How do INDEPENDENT-vendor graders score the answers, strictly? (ADR-0002 gate)',
70
+ artifact: 'data/grade-*.json',
71
+ maxAgeDays: 30,
72
+ read: null, // aggregated across files — see readPanel()
73
+ },
74
+ {
75
+ key: 'catalogue',
76
+ kind: 'coverage',
77
+ question: 'What FRACTION of rUv\'s live repos has a store at all? (NOT a quality measure)',
78
+ artifact: 'data/org-repo-count.json + the live store root',
79
+ maxAgeDays: 7,
80
+ read: null, // derived — see readCatalogue()
81
+ },
82
+ {
83
+ key: 'routable',
84
+ kind: 'coverage',
85
+ question: 'What fraction of BUILT stores can a by-description query actually reach?',
86
+ artifact: 'capability-cards.md at the store root',
87
+ maxAgeDays: 7,
88
+ read: null,
89
+ },
90
+ ];
91
+
92
+ /**
93
+ * Is this store reachable by a by-description query? ALIAS-AWARE, because the router is.
94
+ *
95
+ * Pure and exported so the rule can be tested without a live store root — the previous version was
96
+ * an inline `cards.has(s)` buried in an async reader, which is why nobody noticed it disagreed with
97
+ * the router. `resolve` is injected rather than imported here so a fixture can supply its own
98
+ * registry; production passes `repositoryNames` from kb/card-lane.mjs, the router's own resolver.
99
+ */
100
+ export function isRoutable(store, cards, root, resolve) {
101
+ return resolve(store, root).some((name) => cards.has(name));
102
+ }
103
+
104
+ const pct = (s) => (s && Number.isFinite(s.p) ? Math.round(s.p * 1000) / 10 : null);
105
+ const frac = (s) => (s && Number.isFinite(s.k) ? `${s.k}/${s.n}` : null);
106
+
107
+ function readJson(rel) {
108
+ try { return JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8')); } catch { return null; }
109
+ }
110
+
111
+ /** Aggregate the multi-vendor panel across whatever stores have actually been graded. */
112
+ function readPanel() {
113
+ const dir = path.join(ROOT, 'data');
114
+ let files = [];
115
+ try { files = fs.readdirSync(dir).filter((f) => /^grade-.*\.json$/.test(f)); } catch { /* none */ }
116
+ const rows = files.map((f) => ({ f, j: readJson(path.join('data', f)) })).filter((r) => r.j?.summary);
117
+ if (!rows.length) return { value: null, detail: null, at: null };
118
+ const strict = rows.map((r) => r.j.summary.avgStrict).filter(Number.isFinite);
119
+ if (!strict.length) return { value: null, detail: null, at: null };
120
+ const at = rows
121
+ .map((r) => { try { return fs.statSync(path.join(dir, r.f)).mtime.toISOString(); } catch { return null; } })
122
+ .filter(Boolean).sort().pop();
123
+ return {
124
+ value: Math.round((strict.reduce((a, b) => a + b, 0) / strict.length) * 10) / 10,
125
+ detail: `${strict.length} store(s) graded`,
126
+ at,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Coverage is DERIVED from the live store root, never from a number in a doc. Returns null rather
132
+ * than a guess when the denominator is unknown — a wrong denominator still renders, which is worse
133
+ * than an absent one.
134
+ */
135
+ async function readCoverage() {
136
+ const { storeRoot, storesAt, cardsAt } = await import('../kb/store-root.mjs');
137
+ // ROUTABILITY IS ALIAS-AWARE, BECAUSE THE ROUTER IS.
138
+ //
139
+ // `carded` was `stores.filter((s) => cards.has(s))` — a direct name match against card HEADINGS,
140
+ // while the real router resolves a store through `repositoryNames()` first. So a store reachable
141
+ // ONLY under an alias counted as unroutable, and this metric under-reported the brain's own
142
+ // reachability: measured 2026-08-19, 30 stores called unroutable when 26 actually are —
143
+ // `metaharness` among the four, which is reachable through the `agent-harness-generator` card.
144
+ //
145
+ // That is not a rounding error, it is the same alias-blindness ADR-058 records as open in
146
+ // `darkStores()`, and it already cost something real: a false "dark store" reading led to a
147
+ // DUPLICATE `## metaharness` card being added, which collided with `agent-harness-generator` and
148
+ // broke alias resolution outright. A metric that cannot see aliases manufactures work that
149
+ // damages the thing it measures.
150
+ const { repositoryNames } = await import('../kb/card-lane.mjs');
151
+ const root = storeRoot();
152
+ const stores = storesAt(root);
153
+ const cards = new Set(cardsAt(root));
154
+ const org = readJson('data/org-repo-count.json');
155
+ const total = Number.isFinite(org?.count) && org.count > 0 ? org.count : null;
156
+ const carded = stores.filter((s) => isRoutable(s, cards, root, repositoryNames)).length;
157
+ return {
158
+ catalogue: {
159
+ value: total ? Math.round((stores.length / total) * 1000) / 10 : null,
160
+ detail: total ? `${stores.length}/${total} live repos` : `${stores.length} stores, org total UNKNOWN`,
161
+ at: org?.at ?? null,
162
+ },
163
+ routable: {
164
+ value: stores.length ? Math.round((carded / stores.length) * 1000) / 10 : null,
165
+ detail: `${carded}/${stores.length} built stores have a card`,
166
+ // Derived from the live store root at this instant, so it is current BY CONSTRUCTION. A
167
+ // read-now value reported as stale would train the reader to ignore the stale flag, which
168
+ // costs more than the flag is worth.
169
+ at: new Date().toISOString(),
170
+ },
171
+ };
172
+ }
173
+
174
+ /** A reading past its dimension's `maxAgeDays`, or with no timestamp at all, is not current. */
175
+ export function staleness(at, maxAgeDays, now = Date.now()) {
176
+ if (!at) return { stale: true, ageDays: null, why: 'no timestamp recorded' };
177
+ const ms = now - Date.parse(at);
178
+ if (!Number.isFinite(ms)) return { stale: true, ageDays: null, why: 'unparseable timestamp' };
179
+ const ageDays = Math.round((ms / DAY) * 10) / 10;
180
+ return { stale: ageDays > maxAgeDays, ageDays, why: ageDays > maxAgeDays ? `${ageDays}d > ${maxAgeDays}d budget` : null };
181
+ }
182
+
183
+ export async function brainScore({ now = Date.now() } = {}) {
184
+ const cov = await readCoverage();
185
+ const out = [];
186
+ for (const d of DIMENSIONS) {
187
+ let r;
188
+ if (d.key === 'panelStrict') r = readPanel();
189
+ else if (cov[d.key]) r = cov[d.key];
190
+ else r = d.read ? d.read(readJson(d.artifact)) : { value: null, detail: null, at: null };
191
+ out.push({
192
+ ...d,
193
+ ...r,
194
+ ...staleness(r.at, d.maxAgeDays, now),
195
+ status: r.value === null ? 'unmeasured' : (staleness(r.at, d.maxAgeDays, now).stale ? 'stale' : 'current'),
196
+ });
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * THE STRUCTURAL GUARD. Averaging a coverage percentage with a quality percentage is precisely the
203
+ * 52. It is refused here rather than discouraged in prose, because prose did not stop me.
204
+ */
205
+ export function composite(dims, kind) {
206
+ if (!kind) return { value: null, refused: 'a score must name WHICH question it answers — pass a kind' };
207
+ const mine = dims.filter((d) => d.kind === kind);
208
+ if (!mine.length) return { value: null, refused: `no dimensions of kind "${kind}"` };
209
+ const usable = mine.filter((d) => d.status === 'current' && d.value !== null);
210
+ if (usable.length < mine.length) {
211
+ return {
212
+ value: null,
213
+ refused: `${mine.length - usable.length} of ${mine.length} ${kind} dimension(s) are stale or unmeasured`,
214
+ missing: mine.filter((d) => !usable.includes(d)).map((d) => `${d.key} (${d.status})`),
215
+ };
216
+ }
217
+ return { value: Math.round((usable.reduce((a, d) => a + d.value, 0) / usable.length) * 10) / 10, of: usable.length };
218
+ }
219
+
220
+ const isMain = (() => {
221
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
222
+ catch { return false; }
223
+ })();
224
+
225
+ if (isMain) {
226
+ const dims = await brainScore();
227
+ const asJson = process.argv.includes('--json');
228
+ if (asJson) {
229
+ console.log(JSON.stringify({ recorded: new Date().toISOString(), dimensions: dims }, null, 2));
230
+ } else {
231
+ console.log('\nRUVNET-BRAIN — MEASURED, never typed. Each row names the question it answers.\n');
232
+ for (const kind of ['quality', 'coverage']) {
233
+ console.log(` ${kind.toUpperCase()}`);
234
+ for (const d of dims.filter((x) => x.kind === kind)) {
235
+ const v = d.value === null ? 'UNMEASURED' : `${d.value}`.padStart(5);
236
+ const age = d.ageDays === null ? '' : ` ${d.ageDays}d old`;
237
+ const flag = d.status === 'current' ? '' : ` <-- ${d.status.toUpperCase()}${d.why ? ` (${d.why})` : ''}`;
238
+ console.log(` ${d.key.padEnd(13)} ${v} ${(d.detail ?? '').padEnd(26)}${age}${flag}`);
239
+ console.log(` ${d.question}`);
240
+ }
241
+ const c = composite(dims, kind);
242
+ console.log(c.value === null ? ` => NO ${kind} COMPOSITE: ${c.refused}` : ` => ${kind} composite ${c.value} (mean of ${c.of})`);
243
+ console.log('');
244
+ }
245
+ console.log(' A quality number and a coverage number are NEVER averaged. That was the 52.\n');
246
+ }
247
+ const anyStale = dims.some((d) => d.status !== 'current');
248
+ if (process.argv.includes('--require-current') && anyStale) {
249
+ console.error('brain-score: refusing — at least one dimension is stale or unmeasured. Re-run the graders.');
250
+ process.exit(1);
251
+ }
252
+ }
@@ -10,6 +10,9 @@ import { execFileSync } from 'node:child_process';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { canonicalRvfStores, hasCanonicalRvfStore } from './rvf-generation.mjs';
12
12
  import { getVersionTag, stripTag } from './version.mjs';
13
+ // The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
14
+ // sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
15
+ import { orgRepoCount } from './org-repo-count.mjs';
13
16
 
14
17
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
18
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
@@ -70,11 +73,12 @@ for (const store of canonicalRvfStores(KB_DIR)) {
70
73
  });
71
74
  }
72
75
  const built = repos.filter((r) => r.status === 'built');
76
+ const ORG = orgRepoCount();
73
77
  const manifest = {
74
78
  brainVersion: stripTag(BRAIN_VERSION), // FIELD = bare literal; the "v" tag is display-only (see version.mjs)
75
79
  generated: NOW,
76
80
  generatedHuman: new Date(NOW).toUTCString(),
77
- coverage: { built: built.length, catalogued: repos.length, orgTotalApprox: 248, pending: repos.length - built.length },
81
+ coverage: { built: built.length, catalogued: repos.length, orgTotalApprox: ORG.count, orgTotalSource: ORG.source, orgTotalAt: ORG.at, pending: repos.length - built.length },
78
82
  builtRepos: built,
79
83
  pendingRepos: repos.filter((r) => r.status === 'pending').map((r) => ({ name: r.name, tier: r.tier })),
80
84
  };
@@ -19,6 +19,9 @@ import { spawnSync } from 'node:child_process';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { getVersion, getVersionTag, stripTag } from './version.mjs';
21
21
  import { auditRvfIndexes } from './rvf-index-audit.mjs';
22
+ // The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
23
+ // sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
24
+ import { orgRepoCount } from './org-repo-count.mjs';
22
25
 
23
26
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
27
  const KB = path.join(ROOT, 'kb');
@@ -263,6 +266,26 @@ if (hasGists) for (const suf of ['ruv-gists.big.rvf', 'ruv-gists.big.rvf.idmap.j
263
266
  fs.writeFileSync(path.join(OUT, 'capability-cards.md'), parts[0] + kept.join(''));
264
267
  copied++;
265
268
  if (filteredAny) console.log('[build-bundle] filtered private repo card(s) out of shipped capability-cards.md');
269
+
270
+ // repo-aliases.json TRAVELS WITH THE CARDS OR EVERY ALIAS IS DEAD ON ARRIVAL.
271
+ //
272
+ // card-lane.mjs reads it (REPO_ALIASES_FILE) to resolve a PRODUCT name to its installed store:
273
+ // `agent-harness-generator -> metaharness`, `ruvnet-brain -> brain`, `ruvector -> rvf`,
274
+ // `agentic-qe -> qe`. The bundle never copied it, so on every install those mappings were absent
275
+ // and the fast lane could not route them. Measured 2026-08-19 against the live store root:
276
+ // repo-aliases.json ABSENT, and `search_ruvnet("metaharness: what does Darwin mode mutate?")`
277
+ // answered "card router was ambiguous" — the product cannot be found by its own name.
278
+ //
279
+ // `forge-update.mjs:505` already lists this file among the ones it manages, so the updater knew
280
+ // about it and the builder did not — one fact, two shipping paths, only one of them told. Same
281
+ // shape as the reader/writer store-root split and the ingest `--out` bug fixed this week.
282
+ const aliasesSrc = path.join(KB, 'repo-aliases.json');
283
+ if (fs.existsSync(aliasesSrc)) {
284
+ fs.copyFileSync(aliasesSrc, path.join(OUT, 'repo-aliases.json'));
285
+ copied++;
286
+ } else {
287
+ console.log('[build-bundle] note: kb/repo-aliases.json absent — product-name aliases will not resolve on install');
288
+ }
266
289
  } else {
267
290
  console.log('[build-bundle] note: kb/capability-cards.md absent — the fast lane ships with no card source and will honestly fall through on every query');
268
291
  }
@@ -395,11 +418,12 @@ cp(path.join(ROOT, 'keys', 'ruvnet-brain-signing.pub.pem'), path.join(OUT, 'keys
395
418
  const builtLower = new Set(built.map((b) => b.toLowerCase()));
396
419
  const pendingRepos = regFlat.filter((r) => !builtLower.has(r.name.toLowerCase())).map((r) => ({ name: r.name, tier: r.tier }));
397
420
  const now = new Date();
421
+ const ORG = orgRepoCount();
398
422
  const manifest = {
399
423
  brainVersion: stripTag(BRAIN_VERSION), // FIELD = bare literal; BRAIN_VERSION stays the v-prefixed Release tag
400
424
  generated: now.toISOString(),
401
425
  generatedHuman: now.toUTCString(),
402
- coverage: { built: builtRepos.length, catalogued: regFlat.length, orgTotalApprox: 248, pending: pendingRepos.length },
426
+ coverage: { built: builtRepos.length, catalogued: regFlat.length, orgTotalApprox: ORG.count, orgTotalSource: ORG.source, orgTotalAt: ORG.at, pending: pendingRepos.length },
403
427
  crossRepoTool: { mcp: 'forge-mcp-all.mjs', cli: 'forge-ask-all.mjs', tool: 'search_ruvnet' },
404
428
  conceptsStore: hasConcepts ? { store: 'concepts.big.rvf', note: 'L2 synthesis + per-repo primers embedded as prose; unioned by search_ruvnet so code-implemented capabilities are retrievable as high-confidence prose.' } : null,
405
429
  builtRepos,
@@ -126,7 +126,7 @@ export function buildHealthRecommendations({ memory = null, learning = null } =
126
126
  if (Number.isFinite(age) && age > STALE_TRAIN_SECONDS) {
127
127
  recs.push(makeRecommendation({
128
128
  id: 'learning:train',
129
- scope: 'user',
129
+ scope: 'project',
130
130
  title: 'Your learner has gone quiet',
131
131
  rationale: 'It is installed and switched on, but it has not learned anything recently — so it is not getting smarter.',
132
132
  severity: 'SUGGESTED',
@@ -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
  }
@@ -70,6 +70,27 @@ export const VARIANTS = Object.freeze({
70
70
  env: () => ({
71
71
  RUVNET_STRICT_INSTALL: '1',
72
72
  RUVNET_BRAIN_PROFILE: 'complete',
73
+ // A VIRGIN AUTOMATED HOME CAN NEVER HAVE RECORDED CODEX HOOK TRUST (fixed 2026-08-08).
74
+ //
75
+ // The staged variant above has carried this since it was written; the published variant did
76
+ // not, and that asymmetry failed the post-publication seal on EVERY release. The doctor ran
77
+ // green on everything that matters — "✓ Healthy", "✓ Grounding PROVEN", "✓ Self-check passed",
78
+ // 17 hook registrations across 68 firings — and then exited non-zero on the one condition a
79
+ // fixture cannot satisfy:
80
+ //
81
+ // ! Codex installed the Brain, but 17 lifecycle hooks await review.
82
+ // Fix: Start a fresh Codex session, run /hooks, and trust ruvnet-brain@ruvnet-brain.
83
+ //
84
+ // That instruction is correct for a human and impossible for a runner: hook trust is recorded
85
+ // interactively. So each release published both channels successfully and then reported
86
+ // failure, which is the exact "gate reporting something other than what it measured" pattern
87
+ // this repo has spent a week removing — and worse here, because it made a GOOD release look bad.
88
+ //
89
+ // This does NOT weaken the check. install.mjs:1994 documents the bypass as executing the real
90
+ // hook commands "without pretending a fresh interactive user has already reviewed them", and
91
+ // the hooks still run — 68 firings, all inside contract. Only the pending-trust verdict is
92
+ // waived, and only for an automated fixture. End-user doctor runs remain fail-closed.
93
+ RUVNET_CODEX_HOOK_TRUST_MODE: 'bypass',
73
94
  }),
74
95
  }),
75
96
  });
@@ -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
  }
@@ -83,6 +83,19 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path .swar
83
83
  # automatically (keeps last 14), zero risk to the live DB (reads only).
84
84
  RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memory.db --keep 14 >> "$LOG" 2>&1 || true
85
85
 
86
+ # LESSON BRIDGE (ADR-066, 2026-08-10) — carry newly-tagged machine-wide lessons into the store the
87
+ # hooks actually read. Without this the bridge is a command someone has to remember, and a learning
88
+ # system that depends on being remembered is the thing ADR-066 was written to fix: knowledge sat in
89
+ # AgentDB for 18 days while the repo re-made the mistake it described.
90
+ #
91
+ # Safe to run unattended and to run when nothing changed: it is idempotent (bridged rows are replaced
92
+ # wholesale, native rows are never touched), it writes through the store's locked atomic writer, and
93
+ # it REFUSES to apply when it reads zero rows while bridged lessons are installed — so a transient
94
+ # read failure can never strip the store. Best-effort, never blocks the rebuild.
95
+ echo "===== lesson-bridge — $(date -u +%FT%TZ) =====" >> "$LOG"
96
+ /usr/local/bin/node plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
97
+ || echo "===== lesson-bridge: SKIPPED (see line above) =====" >> "$LOG"
98
+
86
99
  # AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
87
100
  # 2026-07-09/10, the exact nights idx_bridge_key/idx_bridge_ns corruption recurred 3x on this very DB
88
101
  # while in WAL mode. Best-effort, same shape as its brain-health/key-health siblings below: never