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
@@ -1,262 +1,4 @@
1
- #!/usr/bin/env node
2
- /**
3
- * lesson-promote.mjs — mine project-scoped lessons, find the UNIVERSAL ones, promote them.
4
- *
5
- * THE PROBLEM, MEASURED (2026-07-22, on the owner's own machine — this is not hypothetical):
6
- *
7
- * 736 lessons across 48 project memory stores.
8
- * 284 of them are `type: feedback` — "how I want you to WORK", which is almost never
9
- * project-specific — and they are scattered across 33 separate stores.
10
- *
11
- * "Test before claiming done" taught 87 times across 19 projects
12
- * "Versioning / release discipline" taught 52 times across 14 projects
13
- * "Never fabricate / be honest" taught 37 times across 14 projects
14
- *
15
- * The owner did not repeat himself because he forgot. He repeated himself because a lesson learned
16
- * in project A physically cannot reach project B: Claude Code scopes memory to
17
- * ~/.claude/projects/<project>/memory/, and nothing promotes upward. His words: "I shouldn't ever
18
- * have to tell you twice." He has had to tell us 87 times.
19
- *
20
- * THE PROMOTION RULE IS NOT OURS. It is rUv's, from ruflo ADR-G008 ("Win Twice to Promote",
21
- * Accepted/implemented): a rule may not enter the constitution on one good result, because one
22
- * result is noise. We apply the same test with the strongest evidence available here — INDEPENDENT
23
- * REDISCOVERY. A lesson the user taught in two or more separate projects has already won twice, in
24
- * the only arena that matters: he needed it more than once, in places that could not see each other.
25
- *
26
- * That is deliberately NOT a similarity score or an LLM judgment call. It is a count of how many
27
- * times a human independently arrived at the same instruction. Cheap, explainable, and impossible
28
- * to fudge — which matters, because a promotion engine that guesses will pollute the global rules
29
- * that govern every project, and a bad global rule is far more expensive than a missing one.
30
- *
31
- * READ-ONLY BY DEFAULT. Promotion writes to the user's global instructions, which is the highest
32
- * blast-radius write this project performs. It requires --apply, backs up first, and is reversible.
33
- *
34
- * Usage:
35
- * node scripts/lesson-promote.mjs # report only — what WOULD be promoted, and why
36
- * node scripts/lesson-promote.mjs --json # machine-readable, for the console
37
- * node scripts/lesson-promote.mjs --apply # write the promotion block (backs up first)
38
- * node scripts/lesson-promote.mjs --min-projects 3
39
- */
40
- import fs from 'node:fs';
41
- import path from 'node:path';
42
- import os from 'node:os';
43
-
44
- const HOME = os.homedir();
45
- const PROJECTS = path.join(HOME, '.claude', 'projects');
46
- const argv = process.argv.slice(2);
47
- const has = (f) => argv.includes(f);
48
- const arg = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
49
-
50
- // A lesson must have been independently learned in at least this many DISTINCT projects to be
51
- // considered universal. 2 is ADR-G008's "win twice"; the flag exists so a cautious user can demand
52
- // more evidence, never less — the floor is enforced below.
53
- const MIN_PROJECTS = Math.max(2, parseInt(arg('--min-projects', '2'), 10) || 2);
54
-
55
- /**
56
- * Themes are the unit of promotion, not individual files.
57
- *
58
- * Promoting 87 near-identical "test first" lessons verbatim would be worse than promoting none —
59
- * it would bury the global instructions under duplicates and make them unreadable, which is how a
60
- * constitution stops being read. We cluster to the PROCESS, then promote one canonical statement of
61
- * it, citing the projects that independently discovered it as the evidence.
62
- *
63
- * Deliberately keyword-based rather than embedding-based. An embedding cluster is a black box the
64
- * user cannot audit, and this writes to the file that governs every project he owns. He must be able
65
- * to read the rule that decided, disagree with it, and edit it. Legibility beats cleverness here.
66
- */
67
- const THEMES = [
68
- { key: 'release-discipline', label: 'Versioning and release discipline',
69
- match: /version|semver|bump|release|ship|deploy|publish|rollback/i },
70
- { key: 'proof-before-done', label: 'Prove it works before calling it done',
71
- match: /test|verify|prove|validat|\bqa\b|gate|green|passes/i },
72
- { key: 'honesty', label: 'Never fabricate, never assume, never inflate',
73
- match: /honest|lie|fabricat|assum|guess|placeholder|inflat|real data|made up/i },
74
- { key: 'docs-upkeep', label: 'Keep docs and README current with the code',
75
- match: /readme|document|changelog|\bdocs?\b|narrative/i },
76
- { key: 'people', label: 'How to communicate with people',
77
- match: /thank|contributor|personal|tone|nudge|deferential|communicat/i },
78
- { key: 'tooling-discipline', label: 'Use the real tool; never hand-roll a substitute',
79
- match: /hand-roll|impersonat|substitut|reinvent|use the tool|existing tool|ruvnet wins/i },
80
- { key: 'cost-routing', label: 'Route work to the cheapest capable model',
81
- match: /cheap|cost|route|routing|model selection|budget|spend/i },
82
- ];
83
-
84
- /** Every lesson file on this machine, with its project, type, and text. */
85
- export function collectLessons(root = PROJECTS) {
86
- const out = [];
87
- let dirs = [];
88
- try { dirs = fs.readdirSync(root); } catch { return out; }
89
- for (const p of dirs) {
90
- const md = path.join(root, p, 'memory');
91
- if (!fs.existsSync(md)) continue;
92
- let files = [];
93
- try { files = fs.readdirSync(md); } catch { continue; }
94
- for (const f of files) {
95
- if (!f.endsWith('.md') || f === 'MEMORY.md') continue;
96
- let s = '';
97
- try { s = fs.readFileSync(path.join(md, f), 'utf8'); } catch { continue; }
98
- const type = (s.match(/^\s*type:\s*(\w+)/m) || [])[1] || 'unknown';
99
- const desc = (s.match(/^description:\s*"?(.*?)"?\s*$/m) || [])[1] || '';
100
- out.push({
101
- project: p.replace(/^-Users-[^-]+-/, ''),
102
- file: f.replace(/\.md$/, ''),
103
- type, desc,
104
- // name + description only — never the body. The body can hold project specifics (paths,
105
- // client names, URLs); the identity of a PROCESS lives in its title. Classifying on the body
106
- // would drag project facts into a global rule, which is the one thing promotion must not do.
107
- text: `${f} ${desc}`,
108
- });
109
- }
110
- }
111
- return out;
112
- }
113
-
114
- /**
115
- * Cluster lessons into themes and decide which have won often enough to be universal.
116
- *
117
- * Only `feedback` lessons are eligible. `project` lessons are, by their own declared type, about one
118
- * codebase; promoting them would be a category error and would leak one client's details into every
119
- * other project's context.
120
- */
121
- /**
122
- * Themes the user has explicitly rejected. Read from the lesson store's demoted rows.
123
- *
124
- * WITHOUT THIS, DEMOTION WAS THEATRE. `lesson-ratify.mjs --demote` set a flag the miner never
125
- * looked at, so the next mining run would re-propose the exact rule the user had just deleted.
126
- * ADR-030 §5 states the requirement plainly — "a one-click demote that the next nightly silently
127
- * undoes is worse than no demote at all, because the user stops trusting the control and, correctly,
128
- * stops using it" — and the code did not implement it. Verified 2026-07-22: zero references to
129
- * `demoted` in this file.
130
- *
131
- * Read defensively: the store may be absent, locked, or from a newer schema. A miner that throws
132
- * because it could not read an optional file is worse than one that proposes a rejected theme.
133
- */
134
- function demotedThemeKeys() {
135
- try {
136
- const file = process.env.RUVNET_LESSON_STORE
137
- || path.join(os.homedir(), '.config', 'ruvnet-brain', 'lessons.json');
138
- const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
139
- return new Set(
140
- (raw.lessons || [])
141
- .filter((l) => l && l.demoted === true && typeof l.themeKey === 'string')
142
- .map((l) => l.themeKey),
143
- );
144
- } catch { return new Set(); }
145
- }
146
-
147
- export function analyze(lessons, { minProjects = MIN_PROJECTS, rejected = null } = {}) {
148
- // Injectable for tests; defaults to the real store so the CLI honours real demotions.
149
- const demoted = rejected instanceof Set ? rejected : demotedThemeKeys();
150
- const eligible = lessons.filter((l) => l.type === 'feedback');
151
- const themes = [];
152
- for (const t of THEMES) {
153
- const hits = eligible.filter((l) => t.match.test(l.text));
154
- if (!hits.length) continue;
155
- const projects = [...new Set(hits.map((h) => h.project))].sort();
156
- // A theme the user has demoted is NEVER re-proposed. Sticky across every future run.
157
- if (demoted.has(t.key)) continue;
158
- themes.push({
159
- key: t.key,
160
- label: t.label,
161
- lessons: hits.length,
162
- projects,
163
- projectCount: projects.length,
164
- // The whole verdict, in one line anyone can check by hand.
165
- universal: projects.length >= minProjects,
166
- evidence: `taught ${hits.length} time${hits.length === 1 ? '' : 's'} across ${projects.length} independent project${projects.length === 1 ? '' : 's'}`,
167
- examples: hits.slice(0, 4).map((h) => `${h.project}: ${h.file}`),
168
- });
169
- }
170
- themes.sort((a, b) => b.projectCount - a.projectCount || b.lessons - a.lessons);
171
-
172
- const promotable = themes.filter((t) => t.universal);
173
- return {
174
- scanned: { projects: new Set(lessons.map((l) => l.project)).size, lessons: lessons.length, feedback: eligible.length },
175
- minProjects,
176
- themes,
177
- promotable,
178
- // The headline the console should say out loud, computed rather than written.
179
- headline: promotable.length
180
- ? `${promotable.length} process${promotable.length === 1 ? '' : 'es'} you have taught in ${minProjects}+ separate projects are still trapped at project level`
181
- : 'no cross-project process has met the promotion bar yet',
182
- };
183
- }
184
-
185
- /** Render the promotion block. Idempotent, fenced, and safe to regenerate. */
186
- export function renderBlock(result, now) {
187
- const lines = [];
188
- lines.push(BEGIN);
189
- lines.push('<!-- Generated by scripts/lesson-promote.mjs. Regeneration REPLACES this fenced block');
190
- lines.push(' wholesale on the next --apply — do NOT hand-edit between the markers, those changes');
191
- lines.push(' are overwritten. Everything OUTSIDE the markers is left untouched. -->');
192
- lines.push('');
193
- lines.push(`## Cross-project lessons (promoted ${now})`);
194
- lines.push('');
195
- lines.push('These processes were learned independently in multiple projects. Per ruflo ADR-G008');
196
- lines.push('("win twice to promote"), independent rediscovery IS the evidence — each one below was');
197
- lines.push('needed more than once, in places that could not see each other.');
198
- lines.push('');
199
- for (const t of result.promotable) {
200
- lines.push(`- **${t.label}** — ${t.evidence}.`);
201
- lines.push(` <sub>projects: ${t.projects.slice(0, 6).join(', ')}${t.projects.length > 6 ? `, +${t.projects.length - 6} more` : ''}</sub>`);
202
- }
203
- lines.push('');
204
- lines.push(END);
205
- return lines.join('\n');
206
- }
207
-
208
- const BEGIN = '<!-- BEGIN ruvnet-brain: promoted-lessons -->';
209
- const END = '<!-- END ruvnet-brain: promoted-lessons -->';
210
-
211
- /** Write the block into the user's global CLAUDE.md, backing up first. Reversible by design. */
212
- export function applyPromotion(result, { file, now }) {
213
- if (!result.promotable.length) return { ok: true, noop: true, log: 'nothing met the promotion bar — nothing written' };
214
- let existing = '';
215
- try { existing = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, log: `cannot read ${file}` }; }
216
-
217
- const backup = `${file}.bak-promote-${now.replace(/[:.]/g, '-')}`;
218
- try { fs.copyFileSync(file, backup); } catch (e) { return { ok: false, log: `refusing to write — backup failed: ${e.message}` }; }
219
-
220
- const block = renderBlock(result, now);
221
- const next = existing.includes(BEGIN)
222
- ? existing.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}`), block) // replace ONLY our fence
223
- : `${existing.trimEnd()}\n\n${block}\n`; // first run: append
224
-
225
- try { fs.writeFileSync(file, next); } catch (e) { return { ok: false, log: `write failed: ${e.message}; backup at ${backup}` }; }
226
- return { ok: true, backup, promoted: result.promotable.length, log: `promoted ${result.promotable.length} process(es) into ${file.replace(HOME, '~')}` };
227
- }
228
-
229
- // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
230
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('lesson-promote.mjs');
231
- if (invokedDirectly) {
232
- const result = analyze(collectLessons());
233
- if (has('--json')) { console.log(JSON.stringify(result, null, 2)); process.exit(0); }
234
-
235
- console.log(`\n Scanned ${result.scanned.lessons} lessons across ${result.scanned.projects} projects `
236
- + `(${result.scanned.feedback} are about how you want work done).\n`);
237
- console.log(` ${result.headline}.\n`);
238
- const w = 42;
239
- for (const t of result.themes) {
240
- const mark = t.universal ? ' ⬆ PROMOTE ' : ' · project ';
241
- console.log(`${mark}${t.label.padEnd(w)} ${String(t.lessons).padStart(3)} lessons · ${t.projectCount} projects`);
242
- }
243
- if (result.promotable.length) {
244
- console.log(`\n Evidence for each (independent rediscovery — ADR-G008 "win twice"):`);
245
- for (const t of result.promotable) {
246
- console.log(`\n ${t.label}`);
247
- console.log(` ${t.evidence}`);
248
- for (const ex of t.examples) console.log(` · ${ex}`);
249
- }
250
- }
251
-
252
- if (has('--apply')) {
253
- const file = arg('--file', path.join(HOME, '.claude', 'CLAUDE.md'));
254
- const res = applyPromotion(result, { file, now: new Date().toISOString().slice(0, 10) });
255
- console.log(`\n ${res.ok ? '✓' : '✗'} ${res.log}`);
256
- if (res.backup) console.log(` backup: ${res.backup.replace(HOME, '~')}`);
257
- process.exit(res.ok ? 0 : 1);
258
- } else {
259
- console.log(`\n This was a REPORT — nothing was written.`);
260
- console.log(` To promote these into your global instructions: node scripts/lesson-promote.mjs --apply\n`);
261
- }
262
- }
1
+ // Compatibility export for repository tools. The executable implementation belongs inside the
2
+ // self-contained plugin payload so Stable Spine and Codex-only installs never depend on a separate
3
+ // Claude marketplace checkout.
4
+ export * from '../plugin/scripts/lesson-promote.mjs';
@@ -1,345 +1,4 @@
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 '../plugin/scripts/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
- }
1
+ // Compatibility export for repository tools. The executable implementation belongs inside the
2
+ // self-contained plugin payload so Stable Spine and Codex-only installs never depend on a separate
3
+ // Claude marketplace checkout.
4
+ export * from '../plugin/scripts/memory-doctor.mjs';