ruvnet-brain 4.0.8 → 4.0.24

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 (63) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +109 -42
  3. package/console/app.js +32 -11
  4. package/console/style.css +5 -0
  5. package/package.json +1 -1
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  9. package/plugin/scripts/anticipate.sh +80 -14
  10. package/plugin/scripts/capability-registry.mjs +994 -0
  11. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  12. package/plugin/scripts/continuation-gate.mjs +169 -1
  13. package/plugin/scripts/gates.mjs +146 -0
  14. package/plugin/scripts/goal-match.mjs +398 -0
  15. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  16. package/plugin/scripts/hook-registry.mjs +616 -0
  17. package/plugin/scripts/hook-shim.mjs +13 -2
  18. package/plugin/scripts/learn-flush.mjs +37 -2
  19. package/plugin/scripts/learning-enable.mjs +382 -0
  20. package/plugin/scripts/lesson-promote.mjs +262 -0
  21. package/plugin/scripts/lesson-provenance.mjs +43 -0
  22. package/plugin/scripts/lesson-store.mjs +67 -56
  23. package/plugin/scripts/memory-doctor.mjs +345 -0
  24. package/plugin/scripts/nightly-controller.mjs +98 -0
  25. package/plugin/scripts/project-identity.mjs +89 -0
  26. package/plugin/scripts/ruflo-bin.mjs +81 -0
  27. package/plugin/scripts/runtime-preferences.mjs +18 -0
  28. package/plugin/scripts/session-snapshot-hook.mjs +4 -1
  29. package/plugin/scripts/session-start-core.mjs +19 -2
  30. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  31. package/plugin/scripts/user-settings.mjs +672 -0
  32. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  33. package/scripts/advocacy-outcomes.mjs +4 -808
  34. package/scripts/capability-registry.mjs +4 -876
  35. package/scripts/console-runtime-identity.mjs +74 -0
  36. package/scripts/corpus-qa.mjs +44 -6
  37. package/scripts/distill-project.mjs +9 -1
  38. package/scripts/doc-currency.mjs +45 -4
  39. package/scripts/gates.mjs +4 -146
  40. package/scripts/goal-match.mjs +4 -398
  41. package/scripts/health-repair.mjs +88 -11
  42. package/scripts/hook-registry.mjs +4 -567
  43. package/scripts/host-install-matrix.mjs +155 -0
  44. package/scripts/issue-watch.mjs +108 -0
  45. package/scripts/learning-enable.mjs +4 -380
  46. package/scripts/lesson-promote.mjs +4 -262
  47. package/scripts/memory-doctor.mjs +4 -342
  48. package/scripts/model-router-catalog.mjs +34 -0
  49. package/scripts/nightly-controller.mjs +4 -66
  50. package/scripts/nightly-wrapper.sh +23 -1
  51. package/scripts/onboarding-console.mjs +34 -12
  52. package/scripts/proactivity-metrics.mjs +8 -1
  53. package/scripts/publication-receipt.mjs +10 -1
  54. package/scripts/qe/ux-suite.mjs +72 -1
  55. package/scripts/release-abort-stale.mjs +111 -0
  56. package/scripts/release-convergence-watchdog.mjs +119 -0
  57. package/scripts/release-transaction-provider.mjs +76 -8
  58. package/scripts/release-transaction.mjs +55 -17
  59. package/scripts/rvf-generation.mjs +17 -0
  60. package/scripts/self-update.mjs +63 -10
  61. package/scripts/staged-host-verifier.mjs +27 -54
  62. package/scripts/sync-version.mjs +10 -10
  63. package/scripts/user-settings.mjs +4 -640
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ // memory-doctor.mjs — does this project's AgentDB actually LEARN, or does it just record?
3
+ //
4
+ // WHY THIS EXISTS (2026-07-14). For two days I told Stuart "AgentDB is fixed / verified / 33 of 33."
5
+ // It was never fixed, and I never lied — I ran a check that COULD NOT FAIL:
6
+ // ruflo memory store -> ruflo memory search -> got a row back -> "healthy"
7
+ // That exercises `memory_entries`, a key-value table with an HNSW index. It says NOTHING about
8
+ // whether anything is embedded, distilled, or learned. It would pass on a database with the entire
9
+ // intelligence substrate surgically removed — which is exactly the database most of his projects have.
10
+ //
11
+ // LIVENESS IS NOT HEALTH. This file is the referee that makes that distinction impossible to fudge.
12
+ //
13
+ // THE ROOT CAUSE IT FOUND (measured, not theorised):
14
+ // memory_entries.embedding is NULL for ~99% of rows in most projects.
15
+ // ruvnet-brain 1,023 entries · 99.8% embedded -> 456 patterns (learns)
16
+ // ugo-ai-register-now 7,214 entries · 100% embedded -> 3,610 patterns (learns)
17
+ // AMBUILANCE_INVENTORY 11,133 entries · 0.03% embedded -> 1 pattern (dead)
18
+ // flighttest 19,108 entries · 0.3% embedded -> 12 patterns (dead)
19
+ // No embedding -> no semantic recall AND nothing for distill to consume (ADR-174 explicitly skips
20
+ // rows with no parseable vector) -> no patterns -> no episodes -> no intelligence. The whole chain
21
+ // snaps at the first link.
22
+ //
23
+ // And the rows are unembedded because of WHO WRITES THEM: namespaces `hooks:pre-bash`,
24
+ // `hooks:post-bash`, `command-history`, `command-results`, `performance-metrics` — telemetry
25
+ // emitted by the `npx @claude-flow/cli hooks pre-command/post-command` calls wired into ~190 hooks
26
+ // across 16 projects. Five unembedded rows per Bash command. They bury the real memories under
27
+ // thousands of rows of "I ran a command", none of it recallable or distillable.
28
+ //
29
+ // GROUNDED IN rUv's SOURCE (not invented here):
30
+ // ruflo/v3/docs/adr/ADR-174-memory-distillation-self-optimization.md (ACCEPTED) — the
31
+ // RETRIEVE->JUDGE->DISTILL->CONSOLIDATE pipeline, and the finding that the substrate was empty
32
+ // because the consolidate worker was a stub. rUv hit this exact wall and shipped `memory distill`.
33
+ // agentdb/src/controllers/ReflexionMemory.ts — storeEpisode()/retrieveRelevant()/getCritiqueSummary().
34
+ // Its retrieval INNER-JOINs episode_embeddings, which is empty everywhere, so reflexion recall
35
+ // currently returns nothing on every project. That is a SEPARATE layer from ADR-174 distillation;
36
+ // do not conflate them (I did, and had to retract it).
37
+ //
38
+ // This tool DIAGNOSES ONLY. It opens every database read-only and writes nothing, anywhere.
39
+
40
+ import { execFileSync } from 'node:child_process';
41
+ import fs from 'node:fs';
42
+ import os from 'node:os';
43
+ import path from 'node:path';
44
+ import { canonicalPath, pathIdentity } from './project-identity.mjs';
45
+
46
+ const HOME = os.homedir();
47
+ const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
48
+
49
+ // Telemetry namespaces: high-volume, unembedded, zero-signal. Written by the npx hook calls.
50
+ // Counted separately so "you have 11,000 memories" is never mistaken for "you have 11,000 lessons".
51
+ const NOISE_NS = new Set([
52
+ 'hooks:pre-bash', 'hooks:post-bash', 'hooks:pre-edit', 'hooks:post-edit',
53
+ 'command-history', 'command-results', 'performance-metrics', 'notifications',
54
+ ]);
55
+
56
+ // A doctor that cannot tell "the patient is dead" from "I could not find the patient" is worse than
57
+ // no doctor. The FIRST version of this function swallowed every sqlite error and returned 0 — so a
58
+ // database it could not even OPEN (spaces in the path broke the file: URI) was reported as
59
+ // "0 memories, 0 patterns", indistinguishable from a genuinely empty store. That is the exact
60
+ // can't-fail check this whole file exists to abolish, reproduced inside it. Caught by running it.
61
+ //
62
+ // Now: every query returns {ok, value} or {ok:false, err}. Unreadable is UNKNOWN, never zero, and
63
+ // UNKNOWN is reported loudly rather than averaged into a reassuring number.
64
+ // `mode=ro` ALONE CANNOT OPEN A RESTING WAL DATABASE, and that is not an edge case — it is the normal
65
+ // state of every store nobody is currently using. A WAL database needs its -shm shared-memory segment
66
+ // to be read, and a read-only connection is not allowed to create one, so SQLite returns CANTOPEN(14).
67
+ //
68
+ // MEASURED (fresh WAL db, same file, three ways):
69
+ // sidecars removed + mode=ro -> Error: unable to open database file (14)
70
+ // sidecars present + mode=ro -> 1
71
+ // sidecars removed + mode=ro&immutable=1 -> 1, and `ls` shows no -wal/-shm created
72
+ //
73
+ // This was live on the owner's own machine while it shipped: `.swarm/memory.db-{shm,wal}` had been
74
+ // renamed `.CORRUPT-20260714-144824`, so every single read of the repo's 16MB store returned
75
+ // unreadable, and the console reported memory distillation as permanently UNKNOWN on a healthy store.
76
+ // Three retries two seconds apart cannot fix a structural refusal; they just make it slow.
77
+ //
78
+ // `immutable=1` is GATED, because it promises SQLite the file will not change underneath it — a
79
+ // promise nobody can keep about a database with a live writer, and breaking it returns torn or
80
+ // stale rows rather than an error. So it is used ONLY when both sidecars are absent, which is the
81
+ // observable signature of "no process has this open in WAL mode", and the absence is re-checked
82
+ // AFTER the read so a writer that arrived mid-flight invalidates the result instead of being
83
+ // reported as fact. A locked store still yields an honest unreadable.
84
+ const walSidecarsPresent = (db) => fs.existsSync(`${db}-wal`) || fs.existsSync(`${db}-shm`);
85
+
86
+ const q = (db, sql) => {
87
+ // encodeURI, not raw interpolation: "Helix - Personal Health Intelligence Platform" has spaces,
88
+ // and sqlite3 rejects the URI outright (error 14) rather than falling back to a plain path.
89
+ const base = `file:${encodeURI(db)}?mode=ro`; // read-only: this process will never be a second writer
90
+ const run = (uri) => execFileSync('sqlite3', [uri, sql], { encoding: 'utf8', timeout: 20000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
91
+
92
+ // MODE IS CHOSEN BEFORE THE OPEN, FROM A FACT ON DISK — NOT AFTER CATCHING WHICHEVER ERROR A
93
+ // GIVEN SQLITE BUILD HAPPENS TO THROW. This used to try plain `mode=ro` first and only add
94
+ // `immutable=1` after catching "unable to open database file (14)". CONFIRMED LIVE 2026-07-26
95
+ // (Docker ubuntu:24.04, apt sqlite3 3.45.1 — the exact version this repo's "check" CI job
96
+ // installs): on a resting WAL db with no sidecars, plain `mode=ro` does NOT throw there the way
97
+ // macOS's bundled 3.51.0 does — it succeeds AND silently vivifies `-shm`/`-wal` as a side effect
98
+ // of establishing WAL-index shared memory for the reader. No exception meant the catch block's
99
+ // sidecar check — the only place this was ever guarded — never ran, so a "read-only" diagnosis
100
+ // left litter in the caller's directory on every ubuntu CI run.
101
+ //
102
+ // The fix does not depend on which behavior a given SQLite build has. It decides the mode from a
103
+ // fact available before either would be attempted: do the sidecars already exist?
104
+ // - present -> a live/resting WAL with real frames; open plain `mode=ro` and let a genuine
105
+ // pending WAL be read normally (immutable=1 here would be a promise we cannot make).
106
+ // - absent -> nothing to replay; open `mode=ro&immutable=1` STRAIGHT AWAY — the one mode both
107
+ // measured SQLite builds leave clean. Plain mode=ro never gets the chance to vivify a -shm
108
+ // file nobody asked for.
109
+ const restingWal = fs.existsSync(db) && !walSidecarsPresent(db);
110
+ const uri = restingWal ? `${base}&immutable=1` : base;
111
+
112
+ try {
113
+ const value = run(uri);
114
+ // Belt and suspenders for a third SQLite build we have not measured: a read that started with
115
+ // no sidecars must end with no sidecars. If one appeared anyway — a fresh concurrent writer, or
116
+ // another version-specific quirk — the bytes just read cannot be trusted, and this read's own
117
+ // litter is removed rather than left in the caller's directory.
118
+ if (restingWal && walSidecarsPresent(db)) {
119
+ for (const suffix of ['-wal', '-shm']) {
120
+ try { fs.unlinkSync(`${db}${suffix}`); } catch { /* best-effort cleanup only */ }
121
+ }
122
+ return { ok: false, err: 'a writer opened the store mid-read' };
123
+ }
124
+ return { ok: true, value, viaImmutable: restingWal || undefined };
125
+ } catch (e) {
126
+ const err = String(e.stderr || e.message || '');
127
+ // "no such table/column" is SCHEMA VARIANCE (an older store) — a real, reportable fact.
128
+ if (/no such (table|column)/.test(err)) return { ok: true, value: null, missing: true };
129
+ // Anything else means we could not read the database, and we must say so, not guess zero.
130
+ return { ok: false, err: err.split('\n')[0].slice(0, 60) };
131
+ }
132
+ };
133
+ // n() returns null for "unknown" and a number only when we genuinely counted. Callers must handle null.
134
+ const n = (r) => (r.ok ? (r.value === null || r.value === '' ? null : parseInt(r.value, 10)) : null);
135
+
136
+ export function candidateRoots({
137
+ home = HOME,
138
+ configPath = path.join(home, '.claude', 'ruvnet-brain', 'config.json'),
139
+ } = {}) {
140
+ let configured = [];
141
+ let configuredCount = 0;
142
+ try {
143
+ const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
144
+ if (Array.isArray(value.scanRoots)) {
145
+ configuredCount = value.scanRoots.length;
146
+ configured = value.scanRoots.filter((item) => typeof item === 'string' && item.trim());
147
+ }
148
+ } catch { /* absent or malformed config does not erase the common roots */ }
149
+
150
+ // Keyed by pathIdentity, not by the spelling. DEFAULT_SCAN_ROOTS deliberately lists both `Code`
151
+ // and `code` so neither convention is missed, and on a case-insensitive volume those are ONE
152
+ // directory — which the old Set-of-strings admitted twice and then scanned, counted and summed
153
+ // twice (#107). On a case-sensitive volume they are two inodes and both are still kept.
154
+ const roots = new Map();
155
+ const addRoot = (value) => {
156
+ const absolute = path.isAbsolute(value) ? value : path.join(home, value);
157
+ const canonical = canonicalPath(absolute);
158
+ try {
159
+ if (canonical && fs.statSync(canonical).isDirectory()) {
160
+ const identity = pathIdentity(canonical) ?? canonical;
161
+ if (!roots.has(identity)) roots.set(identity, canonical);
162
+ return true;
163
+ }
164
+ } catch { /* missing/non-directory roots are not candidates on this machine */ }
165
+ return false;
166
+ };
167
+ for (const value of DEFAULT_SCAN_ROOTS) addRoot(value);
168
+ let validConfigured = 0;
169
+ for (const value of configured) {
170
+ if (addRoot(value)) validConfigured += 1;
171
+ }
172
+ if (configuredCount > 0 && validConfigured === 0) {
173
+ throw new Error('configured scanRoots contain no existing directories');
174
+ }
175
+ return [...roots.values()].sort();
176
+ }
177
+
178
+ // Keyed by pathIdentity for the same reason candidateRoots is: one store reached by two names is
179
+ // one store. Values are the canonical spelling, which is what every caller reads back.
180
+ function storesBelow(root) {
181
+ const out = new Map();
182
+ const canonicalRoot = canonicalPath(root);
183
+ if (!canonicalRoot) return out;
184
+ const walk = (dir, depth) => {
185
+ if (depth > 4) return;
186
+ let entries;
187
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
188
+ for (const e of entries) {
189
+ if (!e.isDirectory()) continue;
190
+ if (e.name === 'node_modules' || e.name === '.git') continue;
191
+ if (e.name === '.swarm') {
192
+ const db = path.join(dir, '.swarm/memory.db');
193
+ const canonical = canonicalPath(db);
194
+ if (canonical) out.set(pathIdentity(canonical) ?? canonical, canonical);
195
+ continue;
196
+ }
197
+ if (e.name.startsWith('.') && e.name !== '.swarm') continue;
198
+ walk(path.join(dir, e.name), depth + 1);
199
+ }
200
+ };
201
+ walk(canonicalRoot, 0);
202
+ return out;
203
+ }
204
+
205
+ export function findStores(root) {
206
+ const out = new Map();
207
+ const collect = (found) => { for (const [identity, db] of found) if (!out.has(identity)) out.set(identity, db); };
208
+ if (root !== undefined) {
209
+ collect(storesBelow(root));
210
+ return [...out.values()].sort();
211
+ }
212
+
213
+ for (const candidate of candidateRoots()) collect(storesBelow(candidate));
214
+ // These two stores intentionally sit outside the project-root convention. They belong only to
215
+ // the fleet-wide no-argument scan; an explicit root must remain genuinely scoped.
216
+ for (const extra of [path.join(HOME, '.claude/.swarm/memory.db'), path.join(HOME, 'cognitum-trader/.swarm/memory.db')]) {
217
+ const canonical = canonicalPath(extra);
218
+ if (canonical) collect([[pathIdentity(canonical) ?? canonical, canonical]]);
219
+ }
220
+ return [...out.values()].sort();
221
+ }
222
+
223
+ export function displayStoreName(db, home = HOME) {
224
+ const canonicalDb = canonicalPath(db) || path.resolve(db);
225
+ const project = path.dirname(path.dirname(canonicalDb));
226
+ const canonicalHome = canonicalPath(home) || path.resolve(home);
227
+ const relative = path.relative(canonicalHome, project);
228
+ if (relative === '') return '~';
229
+ if (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
230
+ return `~/${relative.split(path.sep).join('/')}`;
231
+ }
232
+ return project;
233
+ }
234
+
235
+ export function diagnose(db) {
236
+ const name = displayStoreName(db);
237
+
238
+ const ic = q(db, 'PRAGMA integrity_check;');
239
+ if (!ic.ok) {
240
+ // UNREADABLE is its own verdict. It is NOT "empty". Reporting it as zero was the bug.
241
+ return { name, db, unreadable: ic.err, findings: [`UNREADABLE: ${ic.err}`], learns: false };
242
+ }
243
+ const integrity = (ic.value || '').split('\n')[0] || 'unknown';
244
+
245
+ const total = n(q(db, 'SELECT count(*) FROM memory_entries;'));
246
+ const embedded = n(q(db, "SELECT count(*) FROM memory_entries WHERE embedding IS NOT NULL AND length(embedding)>0;"));
247
+ const nsRes = q(db, 'SELECT namespace, count(*) FROM memory_entries GROUP BY namespace;');
248
+ const nsRows = (nsRes.value || '').split('\n').filter(Boolean).map((l) => l.split('|'));
249
+ const noise = nsRows.filter(([ns]) => NOISE_NS.has(ns)).reduce((a, [, c]) => a + (parseInt(c, 10) || 0), 0);
250
+ const real = total === null ? null : total - noise;
251
+
252
+ const patterns = n(q(db, 'SELECT count(*) FROM reasoning_patterns;'));
253
+ const patternEmb = n(q(db, 'SELECT count(*) FROM pattern_embeddings;'));
254
+ const episodes = n(q(db, 'SELECT count(*) FROM episodes;'));
255
+ const epEmb = n(q(db, 'SELECT count(*) FROM episode_embeddings;'));
256
+ const critiques = n(q(db, 'SELECT count(critique) FROM episodes;'));
257
+ const skills = n(q(db, 'SELECT count(*) FROM skills;'));
258
+ const promoted = n(q(db, 'SELECT count(*) FROM reasoning_patterns WHERE promoted=1;')); // absent in older schemas -> null
259
+
260
+ // A store with no memory_entries table at all is a DIFFERENT thing from an empty one. Say which.
261
+ if (total === null) {
262
+ return { name, db, integrity, schemaless: true,
263
+ findings: ['no memory_entries table — pre-AgentDB schema, never initialised'], learns: false };
264
+ }
265
+
266
+ const cover = total ? embedded / total : 0;
267
+ const distilled = real ? patterns / real : 0;
268
+
269
+ // Each finding names the ONE thing that is false, in the order the chain breaks. A doctor that
270
+ // lists twelve symptoms teaches nothing; the first broken link is the only one worth fixing today.
271
+ const findings = [];
272
+ if (integrity !== 'ok') findings.push(`CORRUPT (${integrity})`);
273
+ if (total === 0) findings.push('no memories at all');
274
+ else if (cover < 0.5) findings.push(`only ${(cover * 100).toFixed(1)}% embedded — nothing can be recalled or distilled`);
275
+ else if (patterns === 0) findings.push('embedded but never distilled — run: ruflo memory distill run');
276
+ else if (distilled < 0.05) findings.push(`${patterns} patterns from ${real} real memories — distill barely ran`);
277
+ if (noise > real && noise > 500) findings.push(`${noise} telemetry rows from npx hooks bury ${real} real memories`);
278
+ if (episodes > 0 && epEmb === 0) findings.push('reflexion recall dead (0 episode_embeddings — INNER JOIN returns nothing)');
279
+ if (episodes > 0 && critiques === 0) findings.push('0 critiques — episodes carry no lessons');
280
+
281
+ const learns = cover >= 0.5 && patterns > 0 && distilled >= 0.05;
282
+ return { name, db, integrity, total, embedded, cover, noise, real, patterns, patternEmb,
283
+ episodes, epEmb, critiques, skills, promoted, distilled, findings, learns };
284
+ }
285
+
286
+ // Does this project wire `npx <claude-flow|ruvector>` into its tool-use hooks? Those hooks write
287
+ // telemetry rows (hooks:pre-bash, command-history, ...) with no embedding. THE PREDICTION THIS TESTS:
288
+ // if they are the cause of dead memory, then hooked projects should be dead and unhooked ones alive.
289
+ // If a single project breaks that correlation, the theory is wrong and must be discarded.
290
+ export function hasNpxHooks(db) {
291
+ const projDir = path.dirname(path.dirname(db));
292
+ for (const f of ['.claude/settings.json', '.claude/settings.local.json']) {
293
+ const p = path.join(projDir, f);
294
+ if (!fs.existsSync(p)) continue;
295
+ try {
296
+ const s = JSON.parse(fs.readFileSync(p, 'utf8'));
297
+ const hooks = JSON.stringify(s.hooks || {});
298
+ if (/npx\s+(-y\s+)?(@?claude-flow|ruvector|aqe)/.test(hooks)) return true;
299
+ } catch { /* unparseable settings — cannot claim either way */ }
300
+ }
301
+ return false;
302
+ }
303
+
304
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('memory-doctor.mjs')) {
305
+ const stores = findStores();
306
+ const rows = stores.map((db) => ({ ...diagnose(db), npxHooks: hasNpxHooks(db) }));
307
+ const w = Math.min(34, Math.max(...rows.map((r) => r.name.length), 8));
308
+ const readable = rows.filter((r) => !r.unreadable && !r.schemaless);
309
+
310
+ console.log(`\n AgentDB fleet — ${rows.length} stores found\n`);
311
+ console.log(` ${'PROJECT'.padEnd(w)} ${'MEMORIES'.padStart(9)} ${'EMBED%'.padStart(7)} ${'NOISE'.padStart(7)} ${'PATTERNS'.padStart(8)} ${'npx?'.padStart(5)} LEARNS?`);
312
+ console.log(` ${'-'.repeat(w)} ${'-'.repeat(9)} ${'-'.repeat(7)} ${'-'.repeat(7)} ${'-'.repeat(8)} ${'-'.repeat(5)} -------`);
313
+ for (const r of readable.sort((a, b) => b.total - a.total)) {
314
+ if (r.total === 0) continue;
315
+ const pct = (r.cover * 100).toFixed(1) + '%';
316
+ console.log(` ${r.name.slice(0, w).padEnd(w)} ${String(r.total).padStart(9)} ${pct.padStart(7)} ${String(r.noise).padStart(7)} ${String(r.patterns).padStart(8)} ${(r.npxHooks ? 'YES' : 'no').padStart(5)} ${r.learns ? 'yes' : 'NO'}`);
317
+ }
318
+
319
+ // THE FALSIFICATION TEST — stated before the answer is known, so it can actually fail.
320
+ const withMem = readable.filter((r) => r.total >= 50);
321
+ const hookedDead = withMem.filter((r) => r.npxHooks && !r.learns).length;
322
+ const hookedAlive = withMem.filter((r) => r.npxHooks && r.learns).length;
323
+ const cleanAlive = withMem.filter((r) => !r.npxHooks && r.learns).length;
324
+ const cleanDead = withMem.filter((r) => !r.npxHooks && !r.learns).length;
325
+ console.log(`\n HYPOTHESIS: the npx tool-use hooks cause dead memory (unembedded telemetry floods the store).`);
326
+ console.log(` ${'npx hooks + DEAD memory (predicted)'.padEnd(42)} ${hookedDead}`);
327
+ console.log(` ${'npx hooks + LIVE memory (CONTRADICTS)'.padEnd(42)} ${hookedAlive}`);
328
+ console.log(` ${'no hooks + LIVE memory (predicted)'.padEnd(42)} ${cleanAlive}`);
329
+ console.log(` ${'no hooks + DEAD memory (CONTRADICTS)'.padEnd(42)} ${cleanDead}`);
330
+ const contra = hookedAlive + cleanDead;
331
+ console.log(` => ${contra === 0 ? 'hypothesis SURVIVES: no contradicting project' : `hypothesis is INCOMPLETE: ${contra} project(s) contradict it — the npx hooks are NOT the whole story`}`);
332
+
333
+ const unreadable = rows.filter((r) => r.unreadable);
334
+ const schemaless = rows.filter((r) => r.schemaless);
335
+ if (unreadable.length) { console.log(`\n UNREADABLE (NOT "empty" — we could not open these):`); unreadable.forEach((r) => console.log(` ${r.name} — ${r.unreadable}`)); }
336
+ if (schemaless.length) console.log(`\n no memory_entries table (never initialised): ${schemaless.length}`);
337
+
338
+ const dead = readable.filter((r) => !r.learns && r.total > 0);
339
+ console.log(`\n ${readable.filter((r) => r.learns).length} of ${readable.filter((r) => r.total > 0).length} populated stores actually learn. ${dead.length} record and forget.\n`);
340
+ for (const r of dead.slice(0, 10)) console.log(` ${r.name}\n - ${r.findings.join('\n - ')}`);
341
+
342
+ // Non-zero when the fleet is not learning: no scheduled job — and no assistant — can ever again
343
+ // call this "verified" unless the numbers actually agree.
344
+ process.exit(dead.length || unreadable.length ? 1 : 0);
345
+ }
@@ -0,0 +1,98 @@
1
+ // nightly-controller.mjs — a thin adapter around the installer's one scheduler implementation.
2
+ //
3
+ // It does not write a plist, call launchctl, or invent platform behavior. Both the installer and the
4
+ // console reach the same `bin/install.mjs --enable-nightly/--disable-nightly` door; this adapter only
5
+ // supplies structured status and captures its exit result for the console.
6
+
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ // ROOT is the tree that holds `bin/install.mjs`, and it is resolved by an EXACT layout test rather
14
+ // than by `..` — because `..` means two different things since this file moved into the payload
15
+ // (ADR-065). From `<root>/scripts/` it was the root; from `<root>/plugin/scripts/` it is
16
+ // `<root>/plugin`, and `<root>/plugin/bin/install.mjs` does not exist. Caught live by
17
+ // console-apply-timings.test.mjs, which drove a real /api/apply through the console and got
18
+ // `Error: Cannot find module '<root>/plugin/bin/install.mjs'` back inside a 200 response — a remedy
19
+ // that reported failure honestly, but failed for a packaging reason nobody would have guessed.
20
+ //
21
+ // The test is exact, not a heuristic: this file's directory IS `<candidate>/plugin/scripts` if and
22
+ // only if `<candidate>` is a non-flattened root. A flattened install (the Spine's versions/<gen>/,
23
+ // the plugin cache's <ver>/) has no `plugin/` level, so `../..` is some unrelated parent and the
24
+ // answer falls back to `..` — where `bin/` also does not exist, and applyNightlyChoice() then
25
+ // reports that honestly instead of silently spawning nothing. nightlyStatus() only reads a plist and
26
+ // needs no installer at all, so status stays correct in every layout.
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
+ const ROOT = path.resolve(HERE, '..', '..', 'plugin', 'scripts') === HERE
29
+ ? path.resolve(HERE, '..', '..')
30
+ : path.resolve(HERE, '..');
31
+ const INSTALLER = path.join(ROOT, 'bin', 'install.mjs');
32
+
33
+ /**
34
+ * THE NIGHTLY JOB'S NAME, stated once for everything that has to recognise it.
35
+ *
36
+ * Exported because issue #113 was two files disagreeing about this exact string with nothing to
37
+ * notice: bin/install.mjs writes a LaunchAgent labelled `com.ruvnet.brain-update`, while
38
+ * capability-registry.mjs looked for launchd jobs matching `/(nightly|refresh)/` — so the console
39
+ * reported "not installed" about the very job the installer had loaded, scheduled and run. A label
40
+ * is an interface between the writer and everyone who looks for it; spelling it out per reader is
41
+ * how the two drift apart silently.
42
+ *
43
+ * bin/install.mjs still holds its own literal (installer-owned code, changed under its own review);
44
+ * tests/unit/nightly-job-identity.test.mjs asserts the two are the same string, so a drift is a red
45
+ * test rather than a capability that quietly disappears from the console.
46
+ */
47
+ export const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
48
+
49
+ export function nightlyArtifact({ env = process.env, platform = process.platform } = {}) {
50
+ const home = env.HOME || os.homedir();
51
+ return {
52
+ supported: platform === 'darwin',
53
+ platform,
54
+ path: path.join(home, 'Library', 'LaunchAgents', `${NIGHTLY_LABEL}.plist`),
55
+ label: NIGHTLY_LABEL,
56
+ };
57
+ }
58
+
59
+ export function nightlyStatus(options = {}) {
60
+ const artifact = nightlyArtifact(options);
61
+ if (!artifact.supported) {
62
+ return { state: 'unsupported', evidence: `No reversible scheduler adapter is implemented for ${artifact.platform}.`, artifact };
63
+ }
64
+ const present = fs.existsSync(artifact.path);
65
+ return {
66
+ state: present ? 'on' : 'off',
67
+ evidence: present ? `LaunchAgent plist exists at ${artifact.path}` : `No LaunchAgent plist at ${artifact.path}`,
68
+ artifact,
69
+ };
70
+ }
71
+
72
+ export function applyNightlyChoice(enabled, options = {}) {
73
+ if (typeof enabled !== 'boolean') return { ok: false, log: 'nightly must be true or false' };
74
+ const env = options.env || process.env;
75
+ const before = nightlyStatus({ ...options, env });
76
+ if (!before.artifact.supported) return { ok: false, state: before, log: before.evidence };
77
+ const run = spawnSync(process.execPath, [
78
+ options.installer || INSTALLER,
79
+ enabled ? '--enable-nightly' : '--disable-nightly',
80
+ ], {
81
+ env: { ...env, RUVNET_BRAIN_IMPORT_ONLY: '0' },
82
+ cwd: options.cwd || ROOT,
83
+ encoding: 'utf8',
84
+ shell: false,
85
+ timeout: options.timeout || 30_000,
86
+ });
87
+ const after = nightlyStatus({ ...options, env });
88
+ const desired = enabled ? 'on' : 'off';
89
+ const ok = !run.error && run.status === 0 && after.state === desired;
90
+ return {
91
+ ok,
92
+ before,
93
+ after,
94
+ log: ok
95
+ ? `Nightly refresh is ${desired}; verified from ${after.artifact.path}.`
96
+ : `Nightly refresh did not reach ${desired}: ${run.error?.message || run.stderr?.trim() || run.stdout?.trim() || `exit ${run.status}`}`,
97
+ };
98
+ }
@@ -0,0 +1,89 @@
1
+ // project-identity.mjs — ONE answer to "which directory is this, and have I seen it already?"
2
+ //
3
+ // WHY THIS EXISTS. Two user-filed bugs, #85 and #107, are the same defect: the product derives a
4
+ // project's location independently at each site and the sites then disagree.
5
+ //
6
+ // #85 The PreCompact producer wrote its receipt under `CLAUDE_PROJECT_DIR`; the Console probe
7
+ // that reports whether a receipt exists looked under `process.cwd()`. Launch the Console
8
+ // from a subdirectory of the project and it warns "no supported PreCompact snapshot found"
9
+ // about a snapshot it had itself just written. Nothing was broken except the agreement.
10
+ //
11
+ // #107 `candidateRoots()` returned BOTH `~/Code` and `~/code`, which on APFS are one directory
12
+ // with one inode. Every project under them was scanned, counted and summed twice, so the
13
+ // machine-wide memory total read exactly 2x — silently, confidently, in the one figure the
14
+ // Console exists to make trustworthy. `path.resolve()` was the guard, and it normalises
15
+ // `.`/`..` only: it case-folds nothing and resolves no symlinks, which the guard's own
16
+ // comment ("e.g. a symlink") shows it was believed to do.
17
+ //
18
+ // WHY DEVICE+INODE RATHER THAN LOWER-CASING. Lower-casing is a guess about the filesystem, and it
19
+ // is wrong on the machines that would suffer most: Linux, and case-sensitive APFS volumes, where
20
+ // `Code` and `code` really are two projects and folding them would DELETE one from the report.
21
+ // `st_dev` + `st_ino` is not a guess — it is the filesystem's own answer, correct in both
22
+ // directions on every volume, and it settles symlinks and bind mounts in the same stroke. The
23
+ // case-insensitivity probe the reporter suggested (write a temp file, stat the other spelling)
24
+ // would also work, but it writes to the user's disk to learn something `stat` already knows.
25
+ //
26
+ // `fs.realpathSync.native` is the OS canonicaliser: it resolves symlinks AND returns the case as
27
+ // stored on disk. Plain `fs.realpathSync` is a JavaScript reimplementation that does symlinks only
28
+ // — measured on this repo's own machine, `realpathSync('~/code')` stays `~/code` while
29
+ // `realpathSync.native('~/code')` returns `~/Code`. That one missing word is the whole of #107.
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ const defaultRealpath = (value) => fs.realpathSync.native(value);
34
+ const defaultStat = (value) => fs.statSync(value, { bigint: true });
35
+
36
+ /** The operating system's own spelling of an existing path, or null when it cannot be resolved. */
37
+ export function canonicalPath(value, { realpath = defaultRealpath } = {}) {
38
+ if (typeof value !== 'string' || !value) return null;
39
+ try { return realpath(value); } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * A key that is equal for two names of one directory and different for two directories.
44
+ * Falls back to the canonical path when the volume reports no usable inode (some Windows and
45
+ * network mounts report 0) — never worse than the raw string compare it replaces.
46
+ */
47
+ export function pathIdentity(value, { realpath = defaultRealpath, stat = defaultStat } = {}) {
48
+ const canonical = canonicalPath(value, { realpath });
49
+ if (!canonical) return null;
50
+ try {
51
+ const info = stat(canonical);
52
+ if (info?.ino) return `${info.dev}:${info.ino}`;
53
+ } catch { /* unreadable — the canonical spelling is still a better key than the raw one */ }
54
+ return canonical;
55
+ }
56
+
57
+ /** True when both names denote the same existing directory or file. */
58
+ export function sameLocation(a, b, options = {}) {
59
+ const left = pathIdentity(a, options);
60
+ return left !== null && left === pathIdentity(b, options);
61
+ }
62
+
63
+ /** True when `child` is `root` or lies beneath it, compared canonically rather than by raw string. */
64
+ export function contains(root, child, options = {}) {
65
+ const canonicalRoot = canonicalPath(root, options);
66
+ const canonicalChild = canonicalPath(child, options);
67
+ if (!canonicalRoot || !canonicalChild) return false;
68
+ if (sameLocation(canonicalRoot, canonicalChild, options)) return true;
69
+ const relative = path.relative(canonicalRoot, canonicalChild);
70
+ return Boolean(relative) && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
71
+ }
72
+
73
+ /**
74
+ * THE project directory. The PreCompact snapshot producer and the Console probe that detects the
75
+ * snapshot both call this, so they cannot disagree about where the project is (#85).
76
+ *
77
+ * `CLAUDE_PROJECT_DIR` wins only when the current directory actually lies inside it. Containment,
78
+ * not mere presence, is what makes the two sides agree by construction: a hook and a Console
79
+ * launched anywhere within one project resolve to that project's root, while a caller that hands
80
+ * over an unrelated directory (a test fixture, an explicit `--project`) is never overruled by an
81
+ * environment variable it knows nothing about.
82
+ */
83
+ export function projectDirectory({ env = process.env, cwd = process.cwd(), ...options } = {}) {
84
+ const here = canonicalPath(cwd, options) || path.resolve(cwd);
85
+ const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' && env.CLAUDE_PROJECT_DIR.trim()
86
+ ? canonicalPath(env.CLAUDE_PROJECT_DIR, options)
87
+ : null;
88
+ return declared && contains(declared, here, options) ? declared : here;
89
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ruflo-bin.mjs — the ONE place this repo decides WHERE the global `ruflo` binary is.
3
+ *
4
+ * WHY THIS FILE EXISTS. Issues #99 and #105 are one defect filed twice: `scripts/distill-project.mjs`
5
+ * and `plugin/scripts/learn-flush.mjs` each hardcoded `~/.npm-global/bin/ruflo` — the owner's npm
6
+ * prefix, nobody else's. On a Homebrew, nvm, Volta, or plain `npm -g` install that path does not
7
+ * exist, so #99 died with "ruflo is not at ~/.npm-global/bin/ruflo" and #105 failed every feed
8
+ * silently — both on machines where ruflo was installed and sitting on PATH the whole time.
9
+ *
10
+ * `health-repair.mjs` had already fixed exactly this, and its header says why: "Telling someone their
11
+ * tool is missing when it is on their PATH is the product lying, and it is unfalsifiable from their
12
+ * side: they cannot see why we looked in one place." That fix lived as a private function inside one
13
+ * file, so its two siblings kept the bug — the identical shape ADR-021 was written about ("One class,
14
+ * fixed in one file, regenerated in the sibling"). So the resolver moves here instead of being pasted
15
+ * a third time.
16
+ *
17
+ * MECHANISM — the hardened form, not the first draft. Two resolvers already existed in this repo:
18
+ * • health-repair.mjs (2026-07-21) preferred path, then `sh -lc 'command -v ruflo'`
19
+ * • capability-registry.mjs (2026-07-22) preferred path, then a plain PATH walk, NO shell
20
+ * The second IS the first one, hardened, and it says so in place: `-l` sources the user's entire
21
+ * profile — every export, shim, and one-off line anyone has ever pasted into .profile — as the price
22
+ * of answering "where is ruflo?". Resolving a name against directories is all `command -v` was ever
23
+ * wanted for here, and it needs no shell at all. This module takes that version. Adopting the older
24
+ * mechanism would mean re-introducing, in a shared module, a bug the repo had already fixed one file
25
+ * over.
26
+ *
27
+ * ORDER, and each step earns its place:
28
+ * 1. RUFLO_BIN, if set, is AUTHORITATIVE — returned as given, whether or not it exists. An explicit
29
+ * override that quietly falls back to some other ruflo is not an override, and the caller's
30
+ * "not at <path>" message has to name the path the user actually asked for.
31
+ * 2. ~/.npm-global/bin/ruflo — Rule 21's ONE global binary, checked first so a machine that has it
32
+ * never depends on PATH ordering.
33
+ * 3. A PATH walk — the entire point of #99/#105: an installed ruflo living anywhere else.
34
+ * 4. null — "I could not find it", said plainly. Never guess a path and then blame the user for it.
35
+ *
36
+ * Rule 21 is untouched by this: still ONE ruflo, still the global one, never `npx ruflo@latest`. This
37
+ * resolves WHERE that one global binary is rather than assuming everyone's prefix matches the owner's.
38
+ */
39
+ import fs from 'node:fs';
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+
43
+ /**
44
+ * Locate the global ruflo. LOCATES, NEVER EXECUTES — no shell, no daemon, no side effects.
45
+ *
46
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
47
+ * @returns {string|null} path to ruflo, or null when it is genuinely not on this machine.
48
+ */
49
+ export function resolveRuflo({ env = process.env, home = os.homedir() } = {}) {
50
+ if (env.RUFLO_BIN) return env.RUFLO_BIN;
51
+
52
+ // On Windows a global npm ruflo is `ruflo.cmd`; the extensionless sibling is a POSIX shell wrapper
53
+ // that Node cannot exec (and refuses to spawn without a shell since CVE-2024-27980).
54
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
55
+
56
+ // The preferred path needs that SAME rule. It first checked a bare `ruflo` only, so on Windows it
57
+ // either missed the real `ruflo.cmd` entirely or returned the POSIX wrapper the comment above
58
+ // says is unrunnable — and the caller then reported "ruflo is not at ~/.npm-global/bin/ruflo" to
59
+ // someone who had ruflo installed. That is the exact complaint #99 and #105 were filed about,
60
+ // reintroduced one platform over.
61
+ const preferredDir = path.join(home, '.npm-global', 'bin');
62
+ for (const ext of exts) {
63
+ const cand = path.join(preferredDir, `ruflo${ext}`);
64
+ try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable */ }
65
+ }
66
+ for (const dir of String(env.PATH || '').split(path.delimiter)) {
67
+ if (!dir) continue;
68
+ for (const ext of exts) {
69
+ const cand = path.join(dir, `ruflo${ext}`);
70
+ try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable PATH entry */ }
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * What to say when resolveRuflo() returns null. ONE wording, so every caller reports the same thing
78
+ * and names both places it looked — the diagnostic #99 and #105 never gave anyone.
79
+ */
80
+ export const RUFLO_MISSING = 'ruflo was not found in ~/.npm-global/bin or anywhere on your PATH'
81
+ + ' — install it with `npm i -g ruflo@latest`, or set RUFLO_BIN to its full path';
@@ -66,6 +66,18 @@ function validSettings(raw = {}) {
66
66
  newProjectDefaults: source.newProjectDefaults === true,
67
67
  advocacy: Number.isInteger(source.advocacy) && source.advocacy >= 1 && source.advocacy <= 5
68
68
  ? source.advocacy : 3,
69
+ // ADR-063 / issue #103. Unknown keys are DROPPED by this function, so a setting absent here is
70
+ // silently unreadable no matter what the Console writes — which is how the first cut of the
71
+ // managed-memory boundary read `advise` even when the file said `block`.
72
+ //
73
+ // NOTE THE DUPLICATION, deliberately not "fixed" here: scripts/user-settings.mjs SETTINGS_SCHEMA
74
+ // is the authority for these keys and this is a second enumeration of it. Deriving one from the
75
+ // other is the obvious move and it is the WRONG one — this file ships inside plugin/, that one
76
+ // does not, so the import would resolve in the checkout and throw ERR_MODULE_NOT_FOUND on a real
77
+ // install. Same trap as kb/forge-update.mjs reaching for ../scripts. The honest fix is a drift
78
+ // test that reads both files, not a bridge that breaks installs.
79
+ managedMemoryBoundary: ['advise', 'read-only', 'block'].includes(source.managedMemoryBoundary)
80
+ ? source.managedMemoryBoundary : 'advise',
69
81
  };
70
82
  }
71
83
 
@@ -263,6 +275,12 @@ export function seedProjectDefaults(options = {}) {
263
275
 
264
276
  if (process.argv.includes('--learning-scope')) {
265
277
  process.stdout.write(`${loadRuntimePreferences().values.learningScope}\n`);
278
+ } else if (process.argv.includes('--managed-memory-boundary')) {
279
+ // ADR-063 / issue #103. hijack-ruvnet.sh is POSIX sh and must not parse JSON, so it asks here —
280
+ // the same idiom learn-capture.sh already uses for --learning-scope. Falls back to the shipped
281
+ // default rather than erroring: a hook that cannot read a preference must never refuse a command
282
+ // because of it, so an unreadable settings file degrades to `advise`, which blocks nothing.
283
+ process.stdout.write(`${loadRuntimePreferences().values.managedMemoryBoundary || 'advise'}\n`);
266
284
  } else if (process.argv.includes('--seed-project')) {
267
285
  const result = seedProjectDefaults();
268
286
  if (!result.ok) process.exitCode = 1;