ruvnet-brain 4.0.12 → 4.0.28

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 (46) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +2 -2
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  6. package/plugin/scripts/anticipate.sh +80 -14
  7. package/plugin/scripts/capability-registry.mjs +994 -0
  8. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  9. package/plugin/scripts/continuation-gate.mjs +129 -1
  10. package/plugin/scripts/gates.mjs +146 -0
  11. package/plugin/scripts/goal-match.mjs +398 -0
  12. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  13. package/plugin/scripts/hook-registry.mjs +616 -0
  14. package/plugin/scripts/hook-shim.mjs +13 -2
  15. package/plugin/scripts/learning-enable.mjs +382 -0
  16. package/plugin/scripts/lesson-promote.mjs +262 -0
  17. package/plugin/scripts/lesson-provenance.mjs +43 -0
  18. package/plugin/scripts/lesson-store.mjs +67 -56
  19. package/plugin/scripts/memory-doctor.mjs +345 -0
  20. package/plugin/scripts/nightly-controller.mjs +98 -0
  21. package/plugin/scripts/runtime-preferences.mjs +18 -0
  22. package/plugin/scripts/session-start-core.mjs +3 -3
  23. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  24. package/plugin/scripts/user-settings.mjs +672 -0
  25. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  26. package/scripts/advocacy-outcomes.mjs +4 -808
  27. package/scripts/capability-registry.mjs +4 -876
  28. package/scripts/corpus-qa.mjs +44 -6
  29. package/scripts/doc-currency.mjs +30 -2
  30. package/scripts/gates.mjs +4 -146
  31. package/scripts/goal-match.mjs +4 -398
  32. package/scripts/hook-registry.mjs +4 -567
  33. package/scripts/issue-watch.mjs +108 -0
  34. package/scripts/learning-enable.mjs +4 -380
  35. package/scripts/lesson-promote.mjs +4 -262
  36. package/scripts/memory-doctor.mjs +4 -345
  37. package/scripts/nightly-controller.mjs +4 -66
  38. package/scripts/nightly-wrapper.sh +23 -1
  39. package/scripts/proactivity-metrics.mjs +8 -1
  40. package/scripts/qe/ux-suite.mjs +72 -1
  41. package/scripts/release-abort-stale.mjs +111 -0
  42. package/scripts/release-convergence-watchdog.mjs +119 -0
  43. package/scripts/release-transaction-provider.mjs +61 -7
  44. package/scripts/release-transaction.mjs +63 -17
  45. package/scripts/self-update.mjs +63 -10
  46. package/scripts/user-settings.mjs +4 -640
@@ -5,6 +5,20 @@ export const SOURCE_CLASS = Object.freeze({
5
5
  DEMONSTRATION: 'demonstration',
6
6
  });
7
7
 
8
+ /**
9
+ * STATUS — the ratification ladder. A lesson does not become policy by existing.
10
+ * candidate → ratified (a human agreed) → active (in force at its trigger).
11
+ *
12
+ * It lives HERE, beside the provenance it is read with, because `isUntouchedOwnerSeedRow` below
13
+ * needs both and neither may import the other's module. lesson-store re-exports it, so every
14
+ * existing importer is unaffected.
15
+ */
16
+ export const STATUS = Object.freeze({
17
+ CANDIDATE: 'candidate',
18
+ RATIFIED: 'ratified',
19
+ ACTIVE: 'active',
20
+ });
21
+
8
22
  export const BUNDLED_OWNER_SEED_IDS = new Set([
9
23
  'L01-verify-with-a-capable-channel',
10
24
  'L02-check-before-you-assert',
@@ -19,3 +33,32 @@ export const BUNDLED_OWNER_SEED_IDS = new Set([
19
33
  'L11-retrieval-without-volition-is-broken',
20
34
  'L12-efficiency-seeking-is-the-tell',
21
35
  ]);
36
+
37
+ /**
38
+ * Is this stored row STILL an untouched bundled maintainer seed row?
39
+ *
40
+ * THE FACT BELONGS TO THE WRITERS, NOT TO THE READER. Issue #111: loadLessons decided it by
41
+ * fingerprinting the store's ID SET — "exactly these twelve IDs, nothing more, nothing less" — and
42
+ * that predicate is invariant under every legitimate change it needed to detect. A store seeded from
43
+ * the bundle carries those twelve IDs forever, so the quarantine overwrite re-ran on EVERY load,
44
+ * forcing `origin`/`sourceClass`/`status`/`demoted`/`ratifiedBy` back to imported-candidate-demoted
45
+ * and discarding ratification the console itself had recorded. Rows the user had promoted to
46
+ * `enforcement: block` then failed makeLesson's own trust boundary ("enforcement:block requires
47
+ * origin:user-stated") against the origin the overwrite had just invented, and were dropped with a
48
+ * warning that read like a schema change.
49
+ *
50
+ * So ask it the way the writers answer it, per row. Ratification is stamped by `ratify()` — the one
51
+ * human action in this store — as `status` plus `ratifiedBy`. A row carrying either has been through
52
+ * a person, and a person's decision is not maintainer history to be re-quarantined. An untouched
53
+ * seed row carries neither, because the seed ships every lesson as an unratified candidate on
54
+ * purpose ("the model does not get to ratify its own rules").
55
+ *
56
+ * Dropping the whole-store fingerprint also fixes its under-counting twin: a legacy store holding
57
+ * the twelve bundled rows PLUS the user's own lessons matched nothing at all, so the maintainer rows
58
+ * in it were never quarantined. Per row, they are.
59
+ */
60
+ export function isUntouchedOwnerSeedRow(stored) {
61
+ if (!stored || !BUNDLED_OWNER_SEED_IDS.has(stored.id)) return false;
62
+ if (stored.status === STATUS.RATIFIED || stored.status === STATUS.ACTIVE) return false;
63
+ return !stored.ratifiedBy;
64
+ }
@@ -23,9 +23,9 @@
23
23
  import fs from 'node:fs';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
- import { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
26
+ import { SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
27
27
 
28
- export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
28
+ export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
29
29
 
30
30
  /** Resolve fixture/plugin configuration without mutating the child process account HOME. */
31
31
  export function resolveConfigRoot(env = process.env, home = os.homedir()) {
@@ -104,15 +104,8 @@ const ORIGIN_VALUES = new Set(Object.values(ORIGIN));
104
104
 
105
105
  const SOURCE_CLASS_VALUES = new Set(Object.values(SOURCE_CLASS));
106
106
 
107
- /**
108
- * STATUS — the ratification ladder. A lesson does not become policy by existing.
109
- * candidate → ratified (a human agreed) → active (in force at its trigger).
110
- */
111
- export const STATUS = Object.freeze({
112
- CANDIDATE: 'candidate',
113
- RATIFIED: 'ratified',
114
- ACTIVE: 'active',
115
- });
107
+ // STATUS — the ratification ladder (candidate → ratified → active) — lives in lesson-provenance.mjs
108
+ // beside the seed identity it is read with, and is re-exported above for every existing importer.
116
109
  const STATUS_VALUES = new Set(Object.values(STATUS));
117
110
 
118
111
  /**
@@ -262,50 +255,55 @@ export function unenforceable(lessons) {
262
255
  export const STORE_PATH = process.env.RUVNET_LESSON_STORE
263
256
  || path.join(CONFIG_ROOT, 'lessons.json');
264
257
 
265
- export function loadLessons(file = STORE_PATH) {
266
- try {
267
- const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
268
- // Re-validate on READ, not just on write. A hand-edited store is expected (the user must be able
269
- // to edit and delete these); a malformed entry must be dropped loudly rather than acted upon.
270
- const out = [];
271
- const dropped = [];
272
- const rows = Array.isArray(raw.lessons) ? raw.lessons : [];
273
- const ids = new Set(rows.map((lesson) => lesson?.id));
274
- const legacyOwnerSeed = rows.length === BUNDLED_OWNER_SEED_IDS.size
275
- && ids.size === BUNDLED_OWNER_SEED_IDS.size
276
- && [...BUNDLED_OWNER_SEED_IDS].every((id) => ids.has(id));
277
- for (const stored of rows) {
278
- // SKIP THE BAD ROW, BUT NEVER SILENTLY. An adversarial review proved that a schema change
279
- // (ADR-035 proposes new enforcement values the current enum rejects) would take this store
280
- // from 16 lessons to 0 with NO error and exit 0 — output indistinguishable from "no lessons
281
- // apply". Every ratified rule the owner had personally approved would vanish, and the first
282
- // symptom would be the model quietly misbehaving again.
283
- //
284
- // A store that empties itself quietly is the worst possible failure here, because the whole
285
- // product promise is "you should never have to tell me twice."
286
- const l = legacyOwnerSeed ? {
287
- ...stored,
288
- origin: ORIGIN.IMPORTED,
289
- sourceClass: SOURCE_CLASS.IMPORTED_OWNER,
290
- status: STATUS.CANDIDATE,
291
- demoted: true,
292
- ratifiedBy: null,
293
- } : stored;
294
- try { out.push(makeLesson(l)); } catch (e) {
295
- dropped.push({ id: l && l.id, why: String(e && e.message || e) });
296
- }
297
- }
298
- if (dropped.length) {
299
- // stderr, not stdout: a hook's stdout may be a JSON protocol channel, and corrupting it would
300
- // turn a data-integrity warning into a broken tool call.
301
- process.stderr.write(
302
- `\n ⚠ lesson store: ${dropped.length} of ${(raw.lessons || []).length} lesson(s) could not be loaded and were IGNORED.\n`
303
- + dropped.slice(0, 5).map((d) => ` ${d.id || '(no id)'} — ${d.why.slice(0, 120)}\n`).join('')
304
- + ` Your rules are still in the file; they are not being applied. This is usually a schema change.\n\n`,
305
- );
258
+ /**
259
+ * Read the store, returning BOTH what parsed and the raw rows that did not.
260
+ *
261
+ * `loadLessons` exposes only the valid rows, which is the right contract for every reader — but a
262
+ * WRITER that sees only them will write only them, and the rows this version could not parse are
263
+ * gone forever. See updateLessons: that is the second half of issue #111.
264
+ */
265
+ function readStore(file) {
266
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
267
+ // Re-validate on READ, not just on write. A hand-edited store is expected (the user must be able
268
+ // to edit and delete these); a malformed entry must be dropped loudly rather than acted upon.
269
+ const out = [];
270
+ const dropped = [];
271
+ const rows = Array.isArray(raw.lessons) ? raw.lessons : [];
272
+ for (const stored of rows) {
273
+ // SKIP THE BAD ROW, BUT NEVER SILENTLY. An adversarial review proved that a schema change
274
+ // (ADR-035 proposes new enforcement values the current enum rejects) would take this store
275
+ // from 16 lessons to 0 with NO error and exit 0 — output indistinguishable from "no lessons
276
+ // apply". Every ratified rule the owner had personally approved would vanish, and the first
277
+ // symptom would be the model quietly misbehaving again.
278
+ //
279
+ // A store that empties itself quietly is the worst possible failure here, because the whole
280
+ // product promise is "you should never have to tell me twice."
281
+ const l = isUntouchedOwnerSeedRow(stored) ? {
282
+ ...stored,
283
+ origin: ORIGIN.IMPORTED,
284
+ sourceClass: SOURCE_CLASS.IMPORTED_OWNER,
285
+ status: STATUS.CANDIDATE,
286
+ demoted: true,
287
+ ratifiedBy: null,
288
+ } : stored;
289
+ try { out.push(makeLesson(l)); } catch (e) {
290
+ dropped.push({ row: stored, id: l && l.id, why: String(e && e.message || e) });
306
291
  }
307
- return out;
308
- } catch { return []; }
292
+ }
293
+ if (dropped.length) {
294
+ // stderr, not stdout: a hook's stdout may be a JSON protocol channel, and corrupting it would
295
+ // turn a data-integrity warning into a broken tool call.
296
+ process.stderr.write(
297
+ `\n ⚠ lesson store: ${dropped.length} of ${rows.length} lesson(s) could not be loaded and were IGNORED.\n`
298
+ + dropped.slice(0, 5).map((d) => ` ${d.id || '(no id)'} — ${d.why.slice(0, 120)}\n`).join('')
299
+ + ` Your rules are still in the file; they are not being applied. This is usually a schema change.\n\n`,
300
+ );
301
+ }
302
+ return { lessons: out, dropped };
303
+ }
304
+
305
+ export function loadLessons(file = STORE_PATH) {
306
+ try { return readStore(file).lessons; } catch { return []; }
309
307
  }
310
308
 
311
309
  /**
@@ -412,7 +410,20 @@ export function updateLessons(transform, file = STORE_PATH) {
412
410
  if (fd === null) throw new Error('lesson store is locked by another writer — nothing was saved, try again');
413
411
 
414
412
  try {
415
- const fresh = loadLessons(file); // INSIDE the lock, which is what the old comment promised
413
+ // READ THE WHOLE FILE, NOT JUST THE PART THIS VERSION UNDERSTANDS. Second half of issue #111:
414
+ // the shrink guard below was computed against `loadLessons()`, which silently omits every row
415
+ // that failed validation — so a store whose rows this version cannot parse presented a SMALLER
416
+ // baseline, nothing appeared to shrink, and the write erased those rows for good. The guard's own
417
+ // reader was the deletion path it exists to refuse, and the file's own comment says an
418
+ // unparseable row is the EXPECTED case ("this is usually a schema change").
419
+ //
420
+ // A row we cannot parse is still the user's rule. It is carried through the write byte-for-byte,
421
+ // so a future version that understands it finds it intact.
422
+ let fresh = [];
423
+ let dropped = [];
424
+ // Same tolerance loadLessons has always had: no store yet (a first write) is not an error.
425
+ try { ({ lessons: fresh, dropped } = readStore(file)); } catch { /* absent or unreadable — treated as empty, exactly as before */ }
426
+
416
427
  const next = transform(fresh);
417
428
  if (!Array.isArray(next)) throw new Error('updateLessons: transform must return an array of lessons');
418
429
  if (next.length < fresh.length) {
@@ -420,7 +431,7 @@ export function updateLessons(transform, file = STORE_PATH) {
420
431
  // Deletion has its own path (demote), so refuse rather than lose a rule silently.
421
432
  throw new Error(`updateLessons refused: would drop ${fresh.length - next.length} lesson(s). Use demote() to retire one.`);
422
433
  }
423
- return saveLessons(next, file, { lockHeld: true });
434
+ return saveLessons(dropped.length ? [...next, ...dropped.map((d) => d.row)] : next, file, { lockHeld: true });
424
435
  } finally {
425
436
  try { fs.closeSync(fd); } catch { /* already closed */ }
426
437
  try { fs.rmSync(lock, { force: true }); } catch { /* best effort */ }
@@ -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
+ }