ruvnet-brain 4.0.12 → 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 (45) hide show
  1. package/README.md +3 -3
  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/unprompted-runtime.mjs +22 -7
  23. package/plugin/scripts/user-settings.mjs +672 -0
  24. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  25. package/scripts/advocacy-outcomes.mjs +4 -808
  26. package/scripts/capability-registry.mjs +4 -876
  27. package/scripts/corpus-qa.mjs +44 -6
  28. package/scripts/doc-currency.mjs +30 -2
  29. package/scripts/gates.mjs +4 -146
  30. package/scripts/goal-match.mjs +4 -398
  31. package/scripts/hook-registry.mjs +4 -567
  32. package/scripts/issue-watch.mjs +108 -0
  33. package/scripts/learning-enable.mjs +4 -380
  34. package/scripts/lesson-promote.mjs +4 -262
  35. package/scripts/memory-doctor.mjs +4 -345
  36. package/scripts/nightly-controller.mjs +4 -66
  37. package/scripts/nightly-wrapper.sh +23 -1
  38. package/scripts/proactivity-metrics.mjs +8 -1
  39. package/scripts/qe/ux-suite.mjs +72 -1
  40. package/scripts/release-abort-stale.mjs +111 -0
  41. package/scripts/release-convergence-watchdog.mjs +119 -0
  42. package/scripts/release-transaction-provider.mjs +46 -6
  43. package/scripts/release-transaction.mjs +55 -17
  44. package/scripts/self-update.mjs +63 -10
  45. package/scripts/user-settings.mjs +4 -640
@@ -0,0 +1,994 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * capability-registry.mjs — the data model behind "the top things you own and don't use".
4
+ *
5
+ * WHY THIS EXISTS, and why it is a REGISTRY rather than more detectors.
6
+ *
7
+ * `capability-audit.mjs` answers "what is dormant?" and it answers it well, but it only speaks up
8
+ * when a detector decides something is WRONG. That shape cannot answer the flat question a person
9
+ * actually asks — "is X on?" — because a healthy capability produces no finding at all, and silence
10
+ * is indistinguishable from "I never looked." The console needs a row per capability whether the
11
+ * news is good, bad, or unavailable.
12
+ *
13
+ * THE ONE RULE THIS FILE EXISTS TO ENFORCE: 'unknown' is a first-class state, and it outranks
14
+ * 'off' every single time a probe could not run. Reporting "off" for something you failed to
15
+ * measure is not a rounding error — it is the exact lie the whole project was built to kill, and
16
+ * it is *easy* to commit here because every underlying helper has a falsy default.
17
+ *
18
+ * That is not a hypothetical. While this file was being written (2026-07-22, ~00:22) a live probe of
19
+ * this repo's own `.swarm/memory.db` came back `{unreadable: 'unable to open database file (14)',
20
+ * learns: false}` — and a naive `learns ? 'on' : 'off'` would have reported "memory distillation is
21
+ * OFF". Re-running the identical query 90 seconds later returned 1201 memories, 99.8% embedded, 596
22
+ * distilled patterns: the store was fully healthy and the first read had simply lost a race with a
23
+ * concurrent writer holding the WAL. One transient lock, and the console would have told its owner
24
+ * to fix a system that was already working. Every detector below therefore maps "could not read" to
25
+ * 'unknown' WITH THE REASON, and only ever says 'off' about a value it genuinely observed.
26
+ *
27
+ * THE SECOND RULE: `turnOn` is null unless the exact command was run with `--help` and the
28
+ * subcommand confirmed present. A confidently-wrong command is worse than no command — it sends a
29
+ * person to a shell to be told "unknown subcommand", which costs them trust in every other row on
30
+ * the page. Six of the eleven capabilities below have `turnOn: null` for that reason, and each one
31
+ * records the negative check that produced the null, so nobody re-litigates it from memory.
32
+ *
33
+ * (That count said FOUR until 2026-08-05 and the real number was seven — stale by three, in the
34
+ * paragraph explaining why nulls must be re-checked. Counted, not remembered:
35
+ * `grep -c '^ turnOn: null,'`. Issue #116 removed one, leaving six.)
36
+ *
37
+ * learning-hooks `ruflo hooks --help` lists list/route/metrics/pretrain/... and NO
38
+ * enable|disable subcommand (grep for "enable" exits 1). There is no CLI
39
+ * that flips them on; inventing one would be fabrication. Deeper still,
40
+ * that capability's own detector proves there is no readable on/off state
41
+ * to flip — see the long note on it before trusting any hook table.
42
+ * harness-evolution CORRECTED 2026-08-05 (issue #116). This block used to read "No `evolve`"
43
+ * and called it VERIFIED NULL. That measurement DRIFTED. Re-measured live
44
+ * against ruflo v3.34.0, which is what a user actually has:
45
+ * --subcommand One of: score | genome | mcp-scan | threat-model |
46
+ * oia-audit | audit-list | audit-trend | similarity | drift-from-history |
47
+ * mint | redblue | learn | gepa | evolve | bench | flywheel
48
+ * `evolve` is there, and so are `bench` and `flywheel`.
49
+ *
50
+ * The stale claim was LOAD-BEARING, not commentary: it justified
51
+ * `turnOn: null`, so the console could never offer an action that had since
52
+ * started existing. A null justified by a measurement must be re-measured,
53
+ * or it silently becomes a lie — the same failure mode as every other
54
+ * drifted assertion in this repo, sitting inside the registry whose whole
55
+ * job is to describe what is actually available.
56
+ *
57
+ * The offer names its precondition. plugin/skills/brain-score/SKILL.md:97 is
58
+ * explicit that the WRITE layer needs OPENROUTER_API_KEY and that we must
59
+ * never claim the evolve loop "just works" without it, so the human text
60
+ * says so rather than handing someone a command that will fail.
61
+ * lessons-in-force Deliberate, not missing: `lesson-seed.mjs --apply` stores CANDIDATES only,
62
+ * because "the model does not get to ratify its own rules." A turnOn here
63
+ * would hand the model the pen it was explicitly denied.
64
+ * session-capture,
65
+ * write-gates,
66
+ * nightly-refresh Turning these on means editing settings.json / loading a launchd plist —
67
+ * multi-step machine mutation with no single verified command, and global
68
+ * Rule 10 forbids handing out system-mutating one-liners unprompted.
69
+ *
70
+ * Everything here is READ-ONLY. It observes; it never installs, enables, or writes.
71
+ */
72
+ import fs from 'node:fs';
73
+ import path from 'node:path';
74
+ import os from 'node:os';
75
+ import { execFileSync } from 'node:child_process';
76
+ import { fileURLToPath } from 'node:url';
77
+ // Two facts this file must NOT restate in its own words, because it already did and both were wrong
78
+ // (issues #112, #113): the name of the nightly job the installer loads, and which hooks a session
79
+ // really has wired. Both are imported from the modules that own them, statically — a missing sibling
80
+ // here is a broken build caught by tests, not a runtime degradation to paper over.
81
+ import { NIGHTLY_LABEL } from './nightly-controller.mjs';
82
+ import { buildRegistry, REPO } from './hook-registry.mjs';
83
+
84
+ const HOME = os.homedir();
85
+
86
+ /**
87
+ * REPO is WHERE THIS CODE IS INSTALLED. It is NOT the user's project, and confusing the two was the
88
+ * single most damaging bug this file has shipped.
89
+ *
90
+ * Every `scope: PROJECT` detector used to read REPO, and `auditAll()` took no argument, so the two
91
+ * project-scoped rows always described the ruvnet-brain package directory no matter where the person
92
+ * running the console actually stood. Proven in both directions, and the second one is the harmful one:
93
+ *
94
+ * from an empty folder: "write-gates | ON | 6 gates can refuse a write, and 203 refusals have been
95
+ * recorded" — ruvnet-brain's own numbers, presented as the user's.
96
+ * from a real project
97
+ * holding a healthy
98
+ * 16MB memory store: "memory-distillation | ABSENT | no memory store exists for this project
99
+ * yet" — plus a turnOn button offering to fix a problem they do not have.
100
+ *
101
+ * Anyone not standing inside a ruvnet-brain checkout — which is every user — got one of those two.
102
+ * `capability-audit.mjs` had this right from the start (process.cwd(), with a --repo override); the
103
+ * registry was the file that disagreed, so the registry is the file that changed.
104
+ *
105
+ * REPO survives for exactly one honest purpose: it is the root `dispatchGateWiring()` hands to
106
+ * hook-registry's buildRegistry(). It is IMPORTED from that module rather than recomputed here,
107
+ * because `..` from this file stopped meaning "the repo root" when this file moved into the payload
108
+ * (2026-08-06) — and hook-registry.mjs is the module that owns resolving that root across both
109
+ * shipped layouts. One answer, in the one place that already had to know it.
110
+ *
111
+ * Resolving the scripts THIS package ships is a SEPARATE job, and it is SCRIPTS_DIR's — see below.
112
+ */
113
+ const DAY = 86_400_000;
114
+
115
+ /**
116
+ * A turnOn command must name its script ABSOLUTELY (a relative `node scripts/x.mjs` only runs for
117
+ * someone standing inside a checkout), and it must name a script that EXISTS — the house rule is
118
+ * "never render a control without a real executor", and capability-registry.test.mjs enforces it.
119
+ *
120
+ * Two candidate homes, probed in that order, because this package ships in two shapes:
121
+ * 1. SCRIPTS_DIR — a sibling inside the payload. True in EVERY shipped layout (the Spine's
122
+ * versions/<gen>/scripts, the plugin cache's <ver>/scripts, and this file's
123
+ * own <src>/plugin/scripts), so payload tools resolve everywhere.
124
+ * 2. <root>/scripts — the repo-root scripts/ dir, which exists in a git checkout and in the npm
125
+ * tarball but NOT in the flattened plugin payload. Tools that live only
126
+ * there (distill-project.mjs, route-cheap.mjs — both wired into the console's
127
+ * remedy registry and the installer's router-tools copy, so relocating them
128
+ * is a different change with a different blast radius) resolve here.
129
+ *
130
+ * NULL WHEN NEITHER HOLDS IT. `turnOn: null` is an established, tested shape in this file — it is how
131
+ * every capability with no verified command already renders — and both consumers (console-engine's
132
+ * offer builder and anticipate.sh's one line) already treat a null/blank cmd as "no button". Emitting
133
+ * a plausible-looking `node …/route-cheap.mjs` that ENOENTs on a Spine install would be strictly
134
+ * worse than saying nothing: the whole point of this registry is that it does not claim what it did
135
+ * not check.
136
+ */
137
+ const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
138
+ function selfScript(name, args) {
139
+ const home = [SCRIPTS_DIR, path.join(REPO, 'scripts')].find((d) => fs.existsSync(path.join(d, name)));
140
+ if (!home) return null;
141
+ // plain quotes, not JSON.stringify: JSON doubles every backslash on Windows and users copy-paste this
142
+ return `node "${path.join(home, name)}"${args ? ` ${args}` : ''}`;
143
+ }
144
+ /** `{human, cmd}` only when the executor is really there; otherwise null. See selfScript() above. */
145
+ const selfTurnOn = (human, name, args) => {
146
+ const cmd = selfScript(name, args);
147
+ return cmd ? { human, cmd } : null;
148
+ };
149
+
150
+ /** The four states. 'absent' means "not installed here", which is NOT the same as "installed and off". */
151
+ /**
152
+ * IDLE — "you think this is on; it is set up and it is not running."
153
+ *
154
+ * THE STATE THIS PRODUCT EXISTS FOR, and it was missing. Owner, 2026-07-24: "this is exactly what we
155
+ * mean by people thinking something is 'On' only to find out it is not really running and working the
156
+ * way they thought it would — that is exactly what this tool is for."
157
+ *
158
+ * It was found on ourselves. `cheap-model-routing` reported ON off a receipt count alone: any n > 0
159
+ * meant on, forever. The router had 38 receipts, an active policy and a current catalog — and had not
160
+ * routed anything in 4.8 days, because the PreToolUse gate that would invoke it was written on
161
+ * 2026-07-13 and never wired into settings.json. Configured, proven, and inert. The age was even
162
+ * PRINTED in the evidence string and did not touch the verdict, which is the tell: we had the fact and
163
+ * threw it away at the moment of judgement.
164
+ *
165
+ * IDLE is deliberately NOT a flavour of OFF. Off means "we looked and it is not running" and points at
166
+ * turnOn. Idle means "it ran, it works, nothing is calling it now" and points at a WIRING question —
167
+ * usually a hook that was built and never installed. Collapsing the two would send someone to
168
+ * re-enable a thing that is already enabled, which is how a diagnosis becomes a wild goose chase.
169
+ *
170
+ * The horizon is a property of the capability, not a constant: a nightly job idle for 2 days is
171
+ * broken, a router idle for 2 days may just be a quiet weekend. Each detector passes its own.
172
+ */
173
+ export const STATE = Object.freeze({ ON: 'on', OFF: 'off', IDLE: 'idle', UNKNOWN: 'unknown', ABSENT: 'absent' });
174
+ export const SCOPE = Object.freeze({ PROJECT: 'project', USER: 'user', MACHINE: 'machine' });
175
+
176
+ /**
177
+ * Sibling helpers are loaded ONCE, lazily, and a load failure degrades to 'unknown' instead of
178
+ * taking the whole registry down. Top-level await keeps every detect() synchronous, which matters:
179
+ * a sync detector cannot be half-awaited by a caller that forgot, and the console renders these
180
+ * rows during a request. The repo has been bitten by a silent import landmine before, so a helper
181
+ * that vanishes must produce an honest "could not load", never a confident zero.
182
+ */
183
+ const helpers = {};
184
+ for (const [name, spec] of Object.entries({
185
+ memoryDoctor: './memory-doctor.mjs',
186
+ lessonStore: './lesson-store.mjs',
187
+ lessonPromote: './lesson-promote.mjs',
188
+ gates: './gates.mjs',
189
+ // learning-enable.mjs owns the ONE reading of the learner's state file. It is imported rather than
190
+ // re-implemented because the two used to disagree out loud: on a stats.json whose counters had been
191
+ // renamed upstream, this registry said "off — 0 trajectories, 0 patterns, nothing has been learned"
192
+ // while learning-enable, reading the identical bytes, said "UNKNOWN — no recognisable counters".
193
+ // Both shipped, on one machine, in the same minute. Two answers to one question is worse than
194
+ // either answer alone, so the second implementation is gone rather than merely corrected.
195
+ learningEnable: './learning-enable.mjs',
196
+ })) {
197
+ try { helpers[name] = await import(spec); }
198
+ catch (e) { helpers[name] = null; helpers[`${name}Err`] = String(e?.message || e).split('\n')[0].slice(0, 90); }
199
+ }
200
+
201
+ const row = (state, evidence) => ({ state, evidence });
202
+ const daysSince = (ms) => (ms ? Math.round((Date.now() - ms) / DAY) : null);
203
+
204
+ /** Read+parse JSON, distinguishing "absent" from "unreadable" — collapsing them hides real corruption. */
205
+ function readJSON(file) {
206
+ if (!fs.existsSync(file)) return { missing: true };
207
+ try { return { value: JSON.parse(fs.readFileSync(file, 'utf8')) }; }
208
+ catch (e) { return { err: String(e?.message || e).split('\n')[0].slice(0, 80) }; }
209
+ }
210
+
211
+ /** Newest mtime of a file, or null when it does not exist / cannot be stat'd. Never 0 — 0 reads as 1970. */
212
+ function mtimeOf(file) {
213
+ try { return fs.statSync(file).mtimeMs; } catch { return null; }
214
+ }
215
+
216
+ /** Count non-blank lines. Returns null (unknown) rather than 0 when the file cannot be read. */
217
+ function lineCount(file) {
218
+ try { return fs.readFileSync(file, 'utf8').split('\n').filter((l) => l.trim()).length; }
219
+ catch { return null; }
220
+ }
221
+
222
+ /**
223
+ * Is a PreToolUse gate on subagent dispatch wired to the cheap-model router?
224
+ *
225
+ * READ THE MERGED REGISTRY, NOT ONE FILE (issue #112). This used to scan `~/.claude/settings.json`
226
+ * for the literal string `route-dispatch.sh`, which is how the LEGACY standalone install wires the
227
+ * gate — and is invisible to the way most people now have it. A plugin-marketplace install wires it
228
+ * in the plugin's own `hooks.json` as `hook-shim.mjs route-dispatch`, never touching settings.json,
229
+ * so `gateWired` was false for every plugin user no matter how correctly the hook was installed. The
230
+ * console then told them "nothing can invoke it" about a gate that was wired.
231
+ *
232
+ * hook-registry.mjs already enumerates every registry a session loads and resolves each command to
233
+ * its HANDLER through hook-shim.mjs's own dispatch table — so `route-dispatch.sh` is recognised
234
+ * whether it is named directly or reached through the shim, and the wiring is found in whichever
235
+ * layer holds it. That module is the authority; this one asks it rather than describing hooks again.
236
+ *
237
+ * WHICH COPY COUNTS, and this is the whole care of the function. The question is what THIS MACHINE
238
+ * loads, so the two code copies nothing boots are excluded: the repo's own `plugin/hooks/hooks.json`
239
+ * is the PREIMAGE (a checkout can be ahead of the installed plugin, and reading the preimage instead
240
+ * of the booted copy is the adjacent-door defect ADR-055 F16 names), and the marketplace clone is
241
+ * where installs are fetched FROM. What Claude Code actually booted on a marketplace install is the
242
+ * plugin-cache copy — and because a cache directory outlives the plugin being switched off, that one
243
+ * counts only while the plugin is enabled.
244
+ */
245
+ export function dispatchGateWiring({ repo = REPO, home = HOME } = {}) {
246
+ const PREIMAGE = new Set(['plugin', 'marketplace-clone']);
247
+ let records;
248
+ try { records = buildRegistry({ repo, home }).records; }
249
+ catch { return { wired: false, layer: null, unreadable: true }; }
250
+ const hits = records.filter((r) => r.event === 'PreToolUse'
251
+ && r.handler === 'route-dispatch.sh'
252
+ && r.tools.some((t) => t === 'Task' || t === 'Agent' || t === '*')
253
+ && !PREIMAGE.has(r.layer));
254
+ const external = hits.find((r) => r.layer !== 'plugin-installed');
255
+ if (external) return { wired: true, layer: external.layer, unreadable: false };
256
+ const enabled = Object.entries(readJSON(path.join(home, '.claude/settings.json')).value?.enabledPlugins || {})
257
+ .some(([k, v]) => k.startsWith('ruvnet-brain@') && v === true);
258
+ const ours = enabled ? hits[0] : null;
259
+ return { wired: Boolean(ours), layer: ours ? ours.layer : null, unreadable: false };
260
+ }
261
+
262
+ /**
263
+ * Locate the ONE global ruflo (global Rule 21 — never npx, which masks a stale global install).
264
+ *
265
+ * LOCATES. NEVER EXECUTES. That distinction is load-bearing and was learned the expensive way: an
266
+ * earlier version of the learning-hooks detector ran `ruflo hooks list` to count rows, and ruflo
267
+ * responds to ANY invocation by auto-starting its daemon and adopting the caller's cwd as its
268
+ * workspace. Measured on a scratch HOME: one call to auditAll() left a live `node cli.js daemon
269
+ * start --foreground` process running after the script exited, plus four files written into HOME
270
+ * (.claude-flow/daemon.pid, daemon-state.json, logs/daemon.log, update-state.json).
271
+ *
272
+ * This is a READ-ONLY status page. Hundreds of people opening it must not each acquire an
273
+ * unrequested long-lived process and a polluted home directory as the price of asking a question.
274
+ * `command -v` is safe because it resolves a name without running the program behind it.
275
+ */
276
+ function rufloBin() {
277
+ const p = path.join(HOME, '.npm-global/bin/ruflo');
278
+ if (fs.existsSync(p)) return p;
279
+
280
+ // NO LOGIN SHELL. This used to run `sh -lc 'command -v ruflo'`, and the `-l` sources the user's
281
+ // entire profile — every export, nvm/rbenv shim, and one-off line anyone has ever pasted into
282
+ // .profile — as the price of answering "is ruflo installed?". Arbitrary startup code executed by a
283
+ // page whose defining promise, stated four lines above, is that it only observes. Milder than the
284
+ // daemon spawn already removed from this file, and the same category of mistake.
285
+ //
286
+ // PATH lookup does the same job with no shell at all: resolving a name against directories, which
287
+ // is all `command -v` was ever wanted for here.
288
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
289
+ for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
290
+ if (!dir) continue;
291
+ for (const ext of exts) {
292
+ const cand = path.join(dir, `ruflo${ext}`);
293
+ try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable PATH entry */ }
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Count hook entries that carry an actual command, across a settings.json hook group array.
301
+ *
302
+ * Counting the GROUPS instead — `hooks.PreCompact.length` — is the bug this replaces. A matcher
303
+ * group is a container; `[{matcher:'.*',hooks:[]}]` has length 1 and runs nothing at all. Verified:
304
+ * a settings.json holding exactly that for both boundaries made this registry report session capture
305
+ * "on — both boundaries are covered", which is a fabricated status about a machine that would lose
306
+ * every session. Only a non-empty `command` string is evidence that anything executes.
307
+ */
308
+ function countHookCommands(groups) {
309
+ if (!Array.isArray(groups)) return 0;
310
+ let n = 0;
311
+ for (const g of groups) {
312
+ for (const h of Array.isArray(g?.hooks) ? g.hooks : []) {
313
+ if (typeof h?.command === 'string' && h.command.trim()) n += 1;
314
+ }
315
+ }
316
+ return n;
317
+ }
318
+
319
+ /**
320
+ * Commands at a session boundary that plausibly PERSIST STATE — the only ones "session capture" is a
321
+ * true statement about.
322
+ *
323
+ * Deliberately a whitelist of named mechanisms rather than "any command": the boundary tells you when
324
+ * something runs, never what it does, and this row claims what it does. `echo done` at SessionEnd is
325
+ * a registered hook and captures nothing. Each pattern below is a real writer — the global autocapture
326
+ * hook, ruflo/claude-flow's own session and memory subcommands, agentdb, or a script whose name says
327
+ * it captures/persists — so a match is evidence, not a guess.
328
+ *
329
+ * Returns null (never 0) when any part of the structure is unparseable. See the caller: an incomplete
330
+ * count rendered as a complete one is the failure this whole file exists to refuse.
331
+ */
332
+ const CAPTURE_COMMAND = /(agentdb|autocapture|auto-capture|session-end|session_end|sessionend|precompact|pre-compact|memory[\s_-]*(store|save|persist)|\bruflo\b[^"]*\b(memory|session|hooks)\b|claude-flow[^"]*\b(memory|session|hooks)\b|(capture|persist|snapshot|checkpoint)[\w-]*\.(mjs|js|sh|py))/i;
333
+
334
+ function countCaptureCommands(groups) {
335
+ if (groups === undefined) return 0; // nothing registered at this boundary is a real answer
336
+ if (!Array.isArray(groups)) return null; // present but unreadable — not the same as absent
337
+ let n = 0;
338
+ for (const g of groups) {
339
+ if (g?.hooks !== undefined && !Array.isArray(g.hooks)) return null;
340
+ for (const h of Array.isArray(g?.hooks) ? g.hooks : []) {
341
+ const cmd = typeof h?.command === 'string' ? h.command.trim() : '';
342
+ if (!cmd) continue;
343
+ if (CAPTURE_COMMAND.test(cmd)) n += 1;
344
+ }
345
+ }
346
+ return n;
347
+ }
348
+
349
+ // ── The capabilities ─────────────────────────────────────────────────────────────────────────────
350
+ // Ordered by blast radius: the ones whose dormancy costs the most sit at the top, because this list
351
+ // is rendered in order and nobody reads to the bottom.
352
+
353
+ export const CAPABILITIES = [
354
+ {
355
+ key: 'learning-hooks',
356
+ label: 'Learning hooks',
357
+ whatItBuysYou: 'Your AI writes down which approach actually worked and reuses it next time, instead of solving the same problem from scratch every session.',
358
+ scope: SCOPE.MACHINE,
359
+ // VERIFIED NULL: there is no enable command, and — more importantly — no readable state to flip.
360
+ turnOn: null,
361
+ /**
362
+ * THIS DETECTOR RETURNS 'unknown' ON PURPOSE, AND THE FIRST VERSION OF IT WAS A LIE.
363
+ *
364
+ * It originally parsed the `Enabled` column of `ruflo hooks list` and reported, confidently:
365
+ * "all 26 registered hooks report Enabled: No … nothing is being learned from your sessions."
366
+ * That is the single most alarming sentence this registry could print, and it was false. It was
367
+ * caught within the hour by cross-checking against the installed ruflo source, and every step
368
+ * was then re-verified here rather than taken on trust:
369
+ *
370
+ * · `ruflo hooks list --format json` returns rows of {name, type, status:"active"} — there is
371
+ * NO `enabled` key in the payload at all.
372
+ * · The CLI renderer draws a column keyed `enabled`, so `v` is `undefined` for every row and
373
+ * the formatter prints its falsy branch: "No", 26 times. Same artifact empties Priority and
374
+ * Executions and makes Last Executed read "Never" for everything.
375
+ * · `ruflo hooks list --enabled`, documented as "Show only enabled hooks", returns the exact
376
+ * same 27 lines as the unfiltered call — the filter is passed to a handler that takes no
377
+ * arguments.
378
+ * · The handler itself (@claude-flow/cli .../mcp-tools/hooks-tools.js, `export const
379
+ * hooksList`) contains zero reads of any file, database, or env var, and the string
380
+ * "enabled" does not appear in it. It is a hardcoded catalog of which subcommands exist.
381
+ *
382
+ * So `ruflo hooks list` is a MENU, not a dashboard, and BOTH readings of it are worthless:
383
+ * "Enabled: No" is a field-name bug, and status:"active" is a literal in a static array. It
384
+ * cannot answer "is learning on?" in either direction, which makes 'unknown' the only honest
385
+ * state available from this source — and 'off' the precise false accusation the header warns
386
+ * about, committed against rUv's own tooling.
387
+ *
388
+ * The MEASURED answer lives in the `workflow-pattern-learning` row, which counts trajectories
389
+ * and patterns actually recorded. Outcomes are evidence; a catalog of subcommands is not.
390
+ */
391
+ /**
392
+ * AND IT NO LONGER RUNS `ruflo hooks list` AT ALL — which is the second lesson, layered on the
393
+ * first. Having established above that the table cannot answer the question in either direction,
394
+ * the old code still SHELLED OUT to fetch it, purely to print a row count in a sentence whose
395
+ * substance is "this number tells you nothing." That cost a daemon and four files in the user's
396
+ * home directory (see rufloBin) for a fact we then disclaim in the same breath.
397
+ *
398
+ * A probe whose result you have already decided to disregard should not be run. So presence is
399
+ * established from the binary on disk — a fact a status page is entitled to read — and the state
400
+ * stays honestly unknown, pointing at the row that measures OUTCOMES instead.
401
+ */
402
+ detect() {
403
+ const bin = rufloBin();
404
+ if (!bin) return row(STATE.ABSENT, 'ruflo is not installed on this machine, so there are no learning hooks to enable');
405
+ return row(STATE.UNKNOWN, 'ruflo is installed, but whether its learning hooks are switched on cannot be read from it: `ruflo hooks list` is a static catalog of available subcommands, not a state readout (its own --enabled filter returns every row unchanged, and its handler reads no file, database, or env var). Rather than run a command whose answer we would have to disclaim — and which starts a background daemon to produce it — nothing is claimed here. Measured learning activity is reported by the workflow-learning row instead.');
406
+ },
407
+ },
408
+
409
+ {
410
+ key: 'memory-distillation',
411
+ label: 'Memory distillation',
412
+ whatItBuysYou: 'Loose notes from past sessions get mined into reusable patterns, so your AI recalls the lesson instead of re-reading every old note to find it.',
413
+ scope: SCOPE.PROJECT,
414
+ // The offer points at scripts/distill-project.mjs, NOT at bare `ruflo memory distill run`, and the
415
+ // difference is the whole reason ADR-047 was rejected. Both duelists found the same hole: the
416
+ // registry offers `turnOn` commands whose promised undo lives on a DIFFERENT execution path than
417
+ // the action actually handed to the user. Here that was literal — the inverse advertised for
418
+ // distillation restores snapshots that `health-repair.mjs --distill-fleet` takes, while this line
419
+ // used to hand over the raw command, which (verified against `--help`) takes no snapshot at all.
420
+ // Run it, dislike the result, and there was nothing to go back to.
421
+ //
422
+ // The wrapper sequences rUv's own commands so the operation is reversible: WAL-safe
423
+ // `ruflo memory backup` FIRST (cp on a live WAL DB silently amputates the newest transactions —
424
+ // this project has lost data that way), a durable fsync'd receipt fail-closed BEFORE any mutation,
425
+ // `distill run --db` scoped to THIS project rather than whatever the cwd implies, and a verified
426
+ // pattern delta reported as a measurement. `--restore` is the tested inverse.
427
+ //
428
+ // PROVEN end to end against the real store, 2026-07-24: 644 → 648 patterns (+4), restore → 644,
429
+ // re-run → 648, five durable receipts, $0.0000. This is the ONE capability whose undo has actually
430
+ // been run rather than merely promised — which is precisely what makes it the only one offerable.
431
+ turnOn: selfTurnOn(
432
+ 'Mine this project\'s stored memories into reusable patterns (snapshots first; reversible)',
433
+ 'distill-project.mjs',
434
+ ),
435
+ detect({ project = process.cwd() } = {}) {
436
+ const db = path.join(project, '.swarm/memory.db');
437
+ if (!fs.existsSync(db)) return row(STATE.ABSENT, `no memory store exists for this project yet (${path.join(path.basename(project), '.swarm/memory.db')} is not present)`);
438
+ if (!helpers.memoryDoctor) return row(STATE.UNKNOWN, `the memory diagnostic could not be loaded (${helpers.memoryDoctorErr}) — distillation state not checked`);
439
+
440
+ let d;
441
+ try { d = helpers.memoryDoctor.diagnose(db); }
442
+ catch (e) { return row(STATE.UNKNOWN, `the memory store could not be diagnosed: ${String(e?.message || e).slice(0, 60)}`); }
443
+
444
+ // THE UNREADABLE CASE, and the entire reason this file states its rule twice. `learns` is false
445
+ // in BOTH the dead case and the could-not-open case, so trusting it blindly turns a failed read
446
+ // into a false accusation. The STATE here was always right; the REASON was not.
447
+ //
448
+ // What this used to say, to every unreadable store without distinction: "this is often a passing
449
+ // lock from another session, not a fault; re-check before acting." That sentence generalised ONE
450
+ // real observation — a store that read unreadable and then healthy 90 seconds later, which was a
451
+ // genuine concurrent writer — into a blanket explanation for every failure mode. It was wrong on
452
+ // this very repo, whose WAL sidecars had been renamed to .CORRUPT-*: that store was structurally
453
+ // unopenable, re-checking would never have cleared it, and the console told its owner to wait.
454
+ // Advice that cannot work is worse than no advice, because the person takes it.
455
+ //
456
+ // The open failure itself is now handled properly in memory-doctor's q() (resting-WAL fallback),
457
+ // so what reaches here is a real lock or a real fault — and it says which it can distinguish
458
+ // rather than asserting one of them.
459
+ if (d.unreadable) {
460
+ const locked = /lock|busy|writer/i.test(String(d.unreadable));
461
+ return row(STATE.UNKNOWN, locked
462
+ ? `the memory store is currently held by another process (${d.unreadable}) — that is a passing lock, not a fault; re-checking in a moment should clear it`
463
+ : `the memory store could not be read (${d.unreadable}) — this is not a transient lock, so re-checking will not clear it; the store or its journal files need attention before distillation state can be established`);
464
+ }
465
+ if (d.schemaless) return row(STATE.UNKNOWN, 'the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) — nothing to distill yet, and nothing is broken');
466
+ if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows — distillation state not established');
467
+
468
+ if (d.total === 0) return row(STATE.ABSENT, 'the memory store is empty, so there is nothing to distill yet');
469
+ if (d.learns) return row(STATE.ON, `${d.patterns} reusable patterns distilled from ${d.real} memories (${(d.cover * 100).toFixed(1)}% embedded)`);
470
+ if (d.patterns === 0) return row(STATE.OFF, `${d.total} memories stored and ${(d.cover * 100).toFixed(1)}% embedded, but 0 have been distilled into patterns — the store records and forgets`);
471
+ // "BARELY RUN" IS NOT "NOT RUNNING". This returned STATE.OFF while its own sentence says the
472
+ // thing has produced patterns — used-and-weak reported as never-used. OFF is a claim that we
473
+ // looked and found it stopped; here we looked and found it working, thinly. Reporting a working
474
+ // capability as off sends the user to switch on something already on, and it corrupts the
475
+ // dormancy predicate that ADR-047 wants to build offers from: a capability that HAS run is not
476
+ // a dormancy finding, whatever its ratio. The weak ratio is still said out loud — it belongs in
477
+ // the evidence, which is where a concern with no action attached should live.
478
+ // Found by Fable 5 in the ADR-047 duel, 2026-07-24.
479
+ return row(STATE.ON, `only ${d.patterns} patterns from ${d.real} memories — distillation has run, but thinly`);
480
+ },
481
+ },
482
+
483
+ {
484
+ key: 'workflow-pattern-learning',
485
+ label: 'Workflow learning',
486
+ whatItBuysYou: 'Your AI picks up how you personally like work done and carries that across every project, rather than starting each one as a stranger.',
487
+ scope: SCOPE.USER,
488
+ // VERIFIED: `ruflo hooks pretrain --help` exists (4-step pipeline + embeddings, --path default '.').
489
+ turnOn: { human: 'Bootstrap the learner from this repository', cmd: 'ruflo hooks pretrain' },
490
+ /**
491
+ * DELEGATED to learning-enable.mjs, which is the only place the learner's state file is read.
492
+ * The hand-rolled version this replaces committed BOTH of the mistakes this file warns about:
493
+ *
494
+ * SCHEMA DRIFT READ AS A MEASUREMENT. `Number(r.value?.trajectoriesRecorded) || 0` turns
495
+ * NaN into 0, so the day rUv renames that field every user is simultaneously told "the learner
496
+ * file exists but records 0 trajectories and 0 patterns — nothing has been learned yet."
497
+ * Reproduced on a stats.json carrying 457 real trajectories under `trajectories_recorded`:
498
+ * this row said OFF; learning-enable, on the same bytes, said UNKNOWN. Its `num()` returns
499
+ * null rather than 0 precisely so an unreadable counter can never masquerade as a measured
500
+ * zero — which is the header's rule, implemented once, correctly, in the other file.
501
+ *
502
+ * NO STALENESS. A learner last adapted 400 days ago reported "on — 457 sessions recorded",
503
+ * while learning-enable called the same file "IDLE — nothing in 400 days". Freshness is part
504
+ * of the verdict, not a footnote, and STALE_DAYS now has exactly one definition.
505
+ */
506
+ detect() {
507
+ if (!helpers.learningEnable) return row(STATE.UNKNOWN, `the learner probe could not be loaded (${helpers.learningEnableErr}) — learning state not checked`);
508
+ let learner;
509
+ let v;
510
+ try {
511
+ learner = helpers.learningEnable.readLearnerState({ home: HOME });
512
+ v = helpers.learningEnable.verdict(learner);
513
+ } catch (e) { return row(STATE.UNKNOWN, `the learner state could not be read (${String(e?.message || e).slice(0, 60)}) — learning state not checked`); }
514
+
515
+ const traj = learner.trajectories;
516
+ const pat = learner.patterns;
517
+ const days = learner.ageMinutes === null ? null : Math.floor(learner.ageMinutes / 1440);
518
+ switch (v.code) {
519
+ case 'NO_LEARNER_STATE':
520
+ return row(STATE.ABSENT, 'no learner state exists yet (~/.claude-flow/neural/stats.json has never been written)');
521
+ case 'CORRUPT':
522
+ return row(STATE.UNKNOWN, 'the learner state file exists but could not be parsed — counts not checked, and nothing is concluded from an unreadable file');
523
+ case 'UNKNOWN_SHAPE':
524
+ // The drift case, stated as the obstacle it is. NEVER "0 trajectories" — that is a claim
525
+ // about the learner; this is a claim about our ability to read it.
526
+ return row(STATE.UNKNOWN, 'the learner state file exists but carries no counters this version recognises — the field names have probably changed upstream, so whether it has learned anything cannot be read here');
527
+ case 'UNKNOWN_PARTIAL':
528
+ // HALF-DRIFT, and the half we cannot read decides the answer. Rendering the readable half
529
+ // as though it settled the question is how "null work sessions recorded and 457 patterns
530
+ // learned" reached a user's screen. One unread counter, one honest unknown.
531
+ return row(STATE.UNKNOWN, `the learner state file is only half-readable — ${v.missingField} is not a number this version recognises, so the counters cannot be compared and no verdict is drawn from the half that did parse`);
532
+ case 'INITIALISED_EMPTY':
533
+ return row(STATE.OFF, 'the learner file exists and genuinely records 0 trajectories and 0 patterns — it has been created but never fed');
534
+ case 'IDLE':
535
+ // WAS STATE.OFF UNTIL 2026-07-24, AND THAT WAS THE SAME BUG THIS FILE ADDED STATE.IDLE TO END.
536
+ //
537
+ // The verdict is literally named IDLE and its own sentence says "ran before and has gone
538
+ // quiet" — the textbook definition of the state added to the top of this file hours earlier.
539
+ // It kept returning OFF because STATE.IDLE was wired into exactly ONE detector
540
+ // (cheap-model-routing) and no others. One bug, found once, fixed once, left everywhere else.
541
+ //
542
+ // WHY IT MATTERS BEYOND TIDINESS: OFF means "we looked and it is not running" and points the
543
+ // user at turnOn. A learner holding hundreds of trajectories is not off — it worked, and
544
+ // something stopped calling it. Offering to "turn on" an already-populated learner is the
545
+ // category error that put "457 patterns learned" next to an invitation to enable it.
546
+ // Found by Fable 5 in the ADR-047 duel, one file over from where I had just fixed it.
547
+ return row(STATE.IDLE, `${traj} work sessions and ${pat} patterns were recorded, but nothing in ${days} days — the learner ran before and has gone quiet. It is not off; something that fed it stopped.`);
548
+ default: {
549
+ // TWO IDENTICAL NUMBERS ARE ONE FACT, NOT TWO ACHIEVEMENTS.
550
+ //
551
+ // Measured live 2026-07-24: trajectoriesRecorded 1114, patternsLearned 1114 — exactly 1:1.
552
+ // Rendered as "1114 work sessions recorded AND 1114 patterns learned", that reads as two
553
+ // independent wins and implies a distillation step. Fable 5's verdict, and it is right: a
554
+ // sharp reader sees the 1:1 instantly and concludes the counter is counting itself.
555
+ //
556
+ // WHAT I DID NOT CONCLUDE: that ruflo's learner is fake. Grounded in rUv's own source
557
+ // (ruflo/v3/@claude-flow/memory/src/persistent-sona.ts), extractPatternsFromTrajectory()
558
+ // stores a pattern ONLY when findSimilarPatterns() finds no near-duplicate — so patterns
559
+ // ARE deduplicated by design and the ratio should sit below 1:1. I cannot explain an exact
560
+ // 1:1 from the code I have read, and the counters in ~/.claude-flow/neural/stats.json may
561
+ // be written by a different path than that module. Unexplained is not the same as false.
562
+ //
563
+ // So this says only what is observed. When the two counts are equal we report ONE number
564
+ // and name the identity out loud, which is both honest and the more interesting signal —
565
+ // it tells the reader something is worth asking about instead of quietly inflating.
566
+ const when = days === null ? '' : `, last updated ${days} day${days === 1 ? '' : 's'} ago`;
567
+ if (traj === pat && traj > 0) {
568
+ return row(STATE.ON, `${traj} work sessions recorded, and the pattern count matches it exactly (${pat}) — one pattern per session, with no reduction between them${when}`);
569
+ }
570
+ return row(STATE.ON, `${traj} work sessions recorded and ${pat} patterns learned${when}`);
571
+ }
572
+ }
573
+ },
574
+ },
575
+
576
+ {
577
+ key: 'cheap-model-routing',
578
+ label: 'Cheap-model routing',
579
+ whatItBuysYou: 'Reading and summarising work runs on a model that costs a fraction of the top-tier one, and each run leaves a receipt showing what it saved.',
580
+ scope: SCOPE.MACHINE,
581
+ // VERIFIED: `node scripts/route-cheap.mjs` prints its usage line requiring --task; script present in repo.
582
+ // ABSOLUTE, via selfScript(). `node scripts/route-cheap.mjs` is copy-pasteable only by someone
583
+ // already standing in a ruvnet-brain checkout; everyone else got `Cannot find module`. A real
584
+ // executor behind an unreachable path is a dead button with extra steps.
585
+ turnOn: selfTurnOn('Route one read-only task through the cheap path', 'route-cheap.mjs', '--task "<text>"'),
586
+ detect() {
587
+ const bin = path.join(HOME, '.npm-global/bin/agentic-flow');
588
+ const installed = fs.existsSync(bin);
589
+ const receipts = process.env.METAHARNESS_RECEIPTS || path.join(HOME, '.claude/metaharness/routing-receipts.jsonl');
590
+ const n = lineCount(receipts);
591
+
592
+ // Receipts are the proof, and they outrank installation: a receipt file with lines means this
593
+ // genuinely ran, even if the binary later moved. Absence of the binary AND of receipts is the
594
+ // only honest 'absent'.
595
+ if (n === null && !fs.existsSync(receipts)) {
596
+ return installed
597
+ ? row(STATE.OFF, 'agentic-flow is installed but no routing receipt has ever been written — the cheap path exists and has never been used')
598
+ : row(STATE.ABSENT, 'agentic-flow is not installed and no routing receipts exist, so cheap routing has never been set up here');
599
+ }
600
+ if (n === null) return row(STATE.UNKNOWN, 'the routing receipt ledger exists but could not be read — usage not checked');
601
+ if (n === 0) return row(STATE.OFF, 'the routing receipt ledger is present but empty — no task has been routed to a cheaper model');
602
+ const age = daysSince(mtimeOf(receipts));
603
+
604
+ // THE AGE NOW DECIDES, INSTEAD OF DECORATING. This line used to return ON for any n > 0 and
605
+ // merely MENTION the age in the evidence — so a router with 38 receipts and nothing invoking it
606
+ // for a fortnight read as healthy. We were holding the disproving fact and printing it politely.
607
+ //
608
+ // 7 days: this path should fire on ordinary sessions, so a full quiet week means something
609
+ // upstream stopped calling it — not that the user had a light week. Measured on this machine
610
+ // 2026-07-24: 38 receipts, last one 4.8 days old, and the PreToolUse gate that invokes it
611
+ // (plugin/scripts/route-dispatch.sh, written 2026-07-13) had never been added to settings.json.
612
+ // Built, correct, and unwired — which no state in this registry could previously express.
613
+ // MEASURE THE CAUSE, NOT A SYMPTOM. An age threshold alone is a proxy and it FAILED on the real
614
+ // case: measured 2026-07-24, the last receipt was 5 days old — under any sane horizon — while the
615
+ // router was in fact never being consulted at all. A quiet week and a severed wire look identical
616
+ // from the receipt file, so read the wire directly.
617
+ //
618
+ // Two things must both be true for the host-limited dispatch audit to record anything: a
619
+ // PreToolUse hook on subagent dispatch and the opt-in profile it refuses to act without
620
+ // (route-dispatch.sh exits 0 when
621
+ // profile.json is absent). Either missing ⇒ the router cannot fire, regardless of how healthy
622
+ // the receipt ledger looks.
623
+ const profile = fs.existsSync(path.join(HOME, '.claude/model-router/profile.json'));
624
+ // ONE READING OF THE WIRING, from the module that owns it — see dispatchGateWiring(). Scanning
625
+ // settings.json here was a second, narrower implementation of that question, and it answered
626
+ // "not wired" for every plugin-marketplace install (issue #112).
627
+ const gate = dispatchGateWiring();
628
+ const gateWired = gate.wired;
629
+ // THE ONE RULE OF THIS FILE. A census we could not take is not a gate we observed to be
630
+ // missing, and "nothing can invoke it" is a claim about the user's machine.
631
+ if (gate.unreadable) return row(STATE.UNKNOWN, `${n} routing receipt${n === 1 ? '' : 's'} recorded, but the hook registries on this machine could not be read — whether anything is wired to invoke the router was not checked`);
632
+
633
+ if (!gateWired || !profile) {
634
+ const missing = [!gateWired && 'no PreToolUse gate on Task|Agent is wired to route-dispatch.sh',
635
+ !profile && 'no ~/.claude/model-router/profile.json (the opt-in the gate requires)'].filter(Boolean).join('; and ');
636
+ return row(STATE.IDLE,
637
+ `set up and proven — ${n} routing receipt${n === 1 ? '' : 's'} recorded — but nothing can invoke it: ${missing}. `
638
+ + 'Every receipt so far came from someone running the router by hand. Until the gate is wired, subagents keep '
639
+ + 'inheriting this session\'s model, which is the single largest cost leak in the harness.');
640
+ }
641
+
642
+ const IDLE_AFTER_DAYS = 7;
643
+ if (age !== null && age > IDLE_AFTER_DAYS) {
644
+ return row(STATE.IDLE,
645
+ `set up and proven — ${n} routing receipt${n === 1 ? '' : 's'} recorded — but nothing has routed through it in ${age} days. `
646
+ + 'It is configured; something that should be calling it is not. Check that the subagent-dispatch gate is wired '
647
+ + '(a PreToolUse hook on Task|Agent) and that ~/.claude/model-router/profile.json exists — without either, the router is never consulted.');
648
+ }
649
+ return row(STATE.ON, `${n} routing receipt${n === 1 ? '' : 's'} recorded${age === null ? '' : `, most recent ${age} day${age === 1 ? '' : 's'} ago`}`);
650
+ },
651
+ },
652
+
653
+ {
654
+ key: 'cross-project-lessons',
655
+ label: 'Cross-project lessons',
656
+ whatItBuysYou: 'A rule you have taught in three separate projects gets applied everywhere, instead of being re-taught project by project forever.',
657
+ scope: SCOPE.USER,
658
+ // VERIFIED: `lesson-promote.mjs --apply` exists (backs up first, reversible — see its header).
659
+ turnOn: selfTurnOn('Promote the processes you have proven in several projects', 'lesson-promote.mjs', '--apply'),
660
+ detect() {
661
+ if (!helpers.lessonPromote) return row(STATE.UNKNOWN, `the cross-project scanner could not be loaded (${helpers.lessonPromoteErr}) — promotion state not checked`);
662
+ let result;
663
+ try { result = helpers.lessonPromote.analyze(helpers.lessonPromote.collectLessons()); }
664
+ catch (e) { return row(STATE.UNKNOWN, `the cross-project lesson scan failed (${String(e?.message || e).slice(0, 60)}) — promotion state not checked`); }
665
+
666
+ const scanned = result?.scanned || {};
667
+ const promotable = result?.promotable || [];
668
+ if (!scanned.lessons) return row(STATE.ABSENT, 'no per-project lessons were found to compare, so there is nothing to promote yet');
669
+
670
+ // EFFECT IN FORCE, not backlog remaining. REJECTED by both duelists 2026-07-24: the old rule was
671
+ // ON iff promotable.length === 0, so teaching two new lessons anywhere flipped a WORKING capability
672
+ // to OFF — permanently, since the backlog always re-arms. Measured on this machine: it read OFF
673
+ // while the promoted block was sitting in the user's global CLAUDE.md, put there the same day.
674
+ // Worse, the evidence string carries a live counter and stateHashOf() hashes that prose, so every
675
+ // tick minted a fresh "the world changed, you may speak again" token — a perpetual-nag engine.
676
+ // Dormant must mean INSTALLED, USABLE, NEVER USED. Promotion writes a marked block into the user's
677
+ // global instructions; the presence of that block is the only honest evidence it is in use.
678
+ let promotedInForce = false;
679
+ try {
680
+ promotedInForce = fs.readFileSync(path.join(HOME, '.claude', 'CLAUDE.md'), 'utf8')
681
+ .includes('BEGIN ruvnet-brain: promoted-lessons');
682
+ } catch { promotedInForce = false; }
683
+
684
+ if (promotedInForce) {
685
+ return row(STATE.ON, promotable.length
686
+ ? `cross-project promotion is in force in your global instructions; ${promotable.length} further process${promotable.length === 1 ? '' : 'es'} ${promotable.length === 1 ? 'has' : 'have'} since become eligible (from ${scanned.lessons} lessons across ${scanned.projects} projects)`
687
+ : `cross-project promotion is in force in your global instructions, and nothing further is waiting (from ${scanned.lessons} lessons across ${scanned.projects} projects)`);
688
+ }
689
+ if (promotable.length === 0) return row(STATE.ON, `${scanned.lessons} lessons across ${scanned.projects} projects scanned, and none are stuck at project level`);
690
+ return row(STATE.OFF, `promotion has never been applied on this machine, and ${promotable.length} process${promotable.length === 1 ? '' : 'es'} you have taught in multiple separate projects ${promotable.length === 1 ? 'is' : 'are'} still trapped at project level (from ${scanned.lessons} lessons across ${scanned.projects} projects)`);
691
+ },
692
+ },
693
+
694
+ {
695
+ key: 'lessons-in-force',
696
+ label: 'Lessons in force',
697
+ whatItBuysYou: 'The corrections you have given your AI actually constrain what it does next, rather than sitting in a file it never consults.',
698
+ scope: SCOPE.USER,
699
+ // VERIFIED NULL, BY DESIGN: ratification is deliberately withheld from the model (see header).
700
+ turnOn: null,
701
+ detect() {
702
+ if (!helpers.lessonStore) return row(STATE.UNKNOWN, `the lesson store could not be loaded (${helpers.lessonStoreErr}) — enforcement state not checked`);
703
+ const file = helpers.lessonStore.STORE_PATH;
704
+ if (!fs.existsSync(file)) return row(STATE.ABSENT, 'no lessons have been recorded yet on this machine');
705
+ let lessons;
706
+ try { lessons = helpers.lessonStore.loadLessons(); }
707
+ catch (e) { return row(STATE.UNKNOWN, `the lesson store could not be read (${String(e?.message || e).slice(0, 60)}) — enforcement state not checked`); }
708
+ if (!Array.isArray(lessons) || lessons.length === 0) return row(STATE.ABSENT, 'the lesson store exists but holds no lessons yet');
709
+
710
+ const S = helpers.lessonStore.STATUS || {};
711
+ const inForce = lessons.filter((l) => l?.status === S.RATIFIED || l?.status === S.ACTIVE).length;
712
+ const candidates = lessons.filter((l) => l?.status === S.CANDIDATE).length;
713
+ // A candidate can never block (lesson-store.mjs). Counting all 12 as "your lessons" would be
714
+ // the flattering number; the honest one is how many can actually affect a decision.
715
+ if (inForce > 0) return row(STATE.ON, `${inForce} of ${lessons.length} lessons are ratified and can affect what your AI does`);
716
+ return row(STATE.OFF, `all ${candidates} recorded lessons are still candidates awaiting your ratification — none of them can influence anything yet`);
717
+ },
718
+ },
719
+
720
+ {
721
+ key: 'harness-evolution',
722
+ label: 'Harness self-improvement',
723
+ // Issue #116: this was `turnOn: null`, justified by a "VERIFIED NULL: evolve is not among them"
724
+ // measurement that has since drifted — ruflo v3.34.0 ships evolve, bench and flywheel. The
725
+ // precondition is named in the human text because brain-score/SKILL.md:97 requires the WRITE
726
+ // layer's OPENROUTER_API_KEY to be disclosed rather than discovered on failure.
727
+ turnOn: {
728
+ human: 'Evolve the harness and keep only measured winners (needs OPENROUTER_API_KEY; without it, `--subcommand score` is the free read-only layer)',
729
+ cmd: 'ruflo metaharness --subcommand evolve',
730
+ },
731
+ whatItBuysYou: 'The rules your AI works by get tested against each other, and the version that measurably does better becomes the new default.',
732
+ scope: SCOPE.MACHINE,
733
+ // VERIFIED NULL: `ruflo metaharness --help` enumerates its subcommands and `evolve` is not among them.
734
+ turnOn: null,
735
+ detect({ project = process.cwd() } = {}) {
736
+ const policy = path.join(HOME, '.claude-flow/harness-active-policy.json');
737
+ // The archive is a per-project artifact even though the ACTIVE POLICY it feeds is machine-wide,
738
+ // so it is read from where the user stands. Reading it from REPO is what made a fresh machine
739
+ // appear to have run self-improvement it had never run.
740
+ const archive = path.join(project, '.metaharness/archive.json');
741
+ const p = readJSON(policy);
742
+ const haveArchive = fs.existsSync(archive);
743
+
744
+ if (p.err) return row(STATE.UNKNOWN, `the active-policy file exists but could not be parsed (${p.err}) — cannot tell whether an evolved policy is in force`);
745
+ if (!p.missing && p.value?.championId) {
746
+ const age = p.value.appliedAt ? daysSince(p.value.appliedAt) : null;
747
+ const tier = p.value.provenanceTier || 'unknown provenance';
748
+ return row(STATE.ON, `an evolved policy is active machine-wide (${String(p.value.championId).slice(0, 20)}…, provenance ${tier}${age === null ? '' : `, applied ${age} day${age === 1 ? '' : 's'} ago`})`);
749
+ }
750
+ // IDLE, not OFF: the sentence says it HAS RUN. Off means never used and points at turnOn;
751
+ // this ran and stopped, which is a wiring question. Same class as the learner fix above.
752
+ // Found by GPT-5.6-Sol in the ADR-047 duel, 2026-07-24.
753
+ if (haveArchive) return row(STATE.IDLE, 'self-improvement has run in this repo but no evolved policy is currently in force — nothing it discovered is being used');
754
+ return row(STATE.ABSENT, 'self-improvement has never run here and no evolved policy is active');
755
+ },
756
+ },
757
+
758
+ {
759
+ key: 'write-gates',
760
+ label: 'Write gates',
761
+ whatItBuysYou: 'Your AI is stopped before it writes something you have already told it not to, instead of you catching it in review.',
762
+ scope: SCOPE.PROJECT,
763
+ // Turning a gate on means hand-editing settings.json hook arrays — no single verified command.
764
+ turnOn: null,
765
+ detect({ project = process.cwd() } = {}) {
766
+ if (!helpers.gates) return row(STATE.UNKNOWN, `the gate survey could not be loaded (${helpers.gatesErr}) — gate state not checked`);
767
+ let survey;
768
+ try { survey = helpers.gates.gatesSurvey({ repo: project }); }
769
+ catch (e) { return row(STATE.UNKNOWN, `the gate survey failed (${String(e?.message || e).slice(0, 60)}) — gate state not checked`); }
770
+
771
+ const s = survey?.summary || {};
772
+ if (!s.armed) return row(STATE.ABSENT, 'no gates are wired on this machine or in this project');
773
+ // "1 gates are wired" shipped, because the plural on `refusal` was handled and the one on
774
+ // `gate` beside it was not. Small, but this surface is read by people deciding whether to
775
+ // trust it, and sloppy copy reads as sloppy measurement.
776
+ const gates = (n) => `${n} gate${n === 1 ? '' : 's'}`;
777
+ // ON, not OFF: gates ARE wired and ARE reading every move — they just cannot refuse one. That
778
+ // is a weaker MODE of running, not an absence of running. Reporting it OFF tells the user to
779
+ // switch on something already on, and hides that they have advisory coverage today.
780
+ // Found by GPT-5.6-Sol in the ADR-047 duel, 2026-07-24.
781
+ if (!s.blocking) return row(STATE.ON, `${gates(s.armed)} ${s.armed === 1 ? 'is' : 'are'} wired but ${s.armed === 1 ? 'it cannot' : 'none of them can'} actually refuse anything — ${s.armed === 1 ? 'it is' : 'they are'} advisory`);
782
+ // Receipts began only once the ledger was added, so "0 caught" is genuinely ambiguous between
783
+ // "never fired" and "fired before we were counting". Say armed, and say the caveat.
784
+ const caught = s.caughtTotal || 0;
785
+ return row(STATE.ON, caught > 0
786
+ ? `${gates(s.blocking)} can refuse a write, and ${caught} refusal${caught === 1 ? ' has' : 's have'} been recorded (${s.caughtThisWeek || 0} this week)`
787
+ : `${gates(s.blocking)} can refuse a write; no refusals are recorded yet, which may mean nothing has warranted one`);
788
+ },
789
+ },
790
+
791
+ {
792
+ key: 'session-capture',
793
+ label: 'Session capture',
794
+ whatItBuysYou: 'What you worked out in a long session survives when the conversation is compacted or ends, instead of being lost with the window.',
795
+ scope: SCOPE.MACHINE,
796
+ // Registering hooks means editing settings.json by hand — no single verified command.
797
+ turnOn: null,
798
+ detect() {
799
+ const r = readJSON(path.join(HOME, '.claude/settings.json'));
800
+ if (r.missing) return row(STATE.ABSENT, 'no Claude Code settings file exists on this machine yet');
801
+ if (r.err) return row(STATE.UNKNOWN, `the settings file could not be parsed (${r.err}) — capture hooks not checked`);
802
+ const hooksRoot = r.value?.hooks;
803
+ if (hooksRoot !== undefined && (!hooksRoot || typeof hooksRoot !== 'object' || Array.isArray(hooksRoot))) {
804
+ return row(STATE.UNKNOWN, 'the settings file has a hooks section this version cannot interpret — capture hooks not counted');
805
+ }
806
+ const hooks = hooksRoot || {};
807
+ // COUNT COMMANDS, NOT MATCHER GROUPS. See countHookCommands: `[{matcher:'.*',hooks:[]}]` has
808
+ // length 1 and executes nothing, and the old `.length` check called that "both boundaries are
809
+ // covered" — a fabricated ON on a machine that saves nothing.
810
+ //
811
+ // AND COUNT *CAPTURE* COMMANDS, NOT ANY COMMAND. The old count accepted whatever was wired at
812
+ // those two boundaries, so a shell logger and a terminal beep — neither of which saves a byte of
813
+ // session state — produced "Session capture: ON". MEASURED with exactly that pair. The boundary
814
+ // a command is attached to says WHEN it runs, never WHAT it does, and this row's whole claim is
815
+ // about what it does. A command is only counted when it names a mechanism known to persist state.
816
+ //
817
+ // A MALFORMED GROUP POISONS THE COUNT rather than being skipped — the same rule, and the same
818
+ // words, as learning-enable.readSettingsWiring, which documents at length why skipping an
819
+ // unparseable entry and reporting the remainder as a total is this project's signature lie.
820
+ // MEASURED: a PreCompact written as an object instead of an array was silently skipped and the
821
+ // row reported OFF — "nothing is saved when a session compacts" — about a machine whose capture
822
+ // hook we simply failed to parse. Identical structure to the bug fixed in that file, opposite
823
+ // treatment, same commit.
824
+ const pre = countCaptureCommands(hooks.PreCompact);
825
+ const end = countCaptureCommands(hooks.SessionEnd);
826
+ if (pre === null || end === null) {
827
+ return row(STATE.UNKNOWN, `the ${pre === null ? 'pre-compaction' : 'session-end'} hook list could not be parsed, so whether anything is registered there cannot be read — no conclusion is drawn from the half that did parse`);
828
+ }
829
+ // "registered", never "capturing" — the same standard the MCP row holds itself to twenty lines
830
+ // below. A settings entry proves a command is wired to fire; no local artifact proves it ever
831
+ // ran or that it succeeded when it did, and claiming captured state from a config file would be
832
+ // exactly the fabricated status this registry exists to refuse.
833
+ if (pre && end) return row(STATE.ON, 'a state-saving hook is registered at both boundaries: one before compaction and one at session end — registered, which is not the same as proven to have captured anything');
834
+ // ON, not OFF: one boundary IS covered. Partially configured is not never-used — half the
835
+ // sessions are being saved today, and calling that "off" both understates what they have and
836
+ // invites them to re-enable a thing already running. The gap is named in the evidence, which is
837
+ // where a real but partial shortfall belongs. Found by GPT-5.6-Sol, 2026-07-24.
838
+ if (pre || end) return row(STATE.ON, `a state-saving hook is registered only at ${pre ? 'the pre-compaction' : 'the session-end'} boundary — the other one loses its state`);
839
+ return row(STATE.OFF, 'no hook that saves session state is registered at either boundary, so nothing is kept when a session compacts or closes');
840
+ },
841
+ },
842
+
843
+ {
844
+ key: 'mcp-servers',
845
+ label: 'Connected tools (MCP)',
846
+ whatItBuysYou: 'Your AI can reach the services you have hooked up — your notes, your browser, your deployment host — instead of only what is in the chat.',
847
+ scope: SCOPE.USER,
848
+ // VERIFIED: `claude mcp --help` lists `add <name> <commandOrUrl> [args...]`.
849
+ turnOn: { human: 'Connect a tool', cmd: 'claude mcp add <name> <commandOrUrl>' },
850
+ detect() {
851
+ const r = readJSON(path.join(HOME, '.claude.json'));
852
+ if (r.missing) return row(STATE.ABSENT, 'no Claude Code config file exists on this machine yet');
853
+ if (r.err) return row(STATE.UNKNOWN, `the config file could not be parsed (${r.err}) — connected tools not counted`);
854
+ const names = Object.keys(r.value?.mcpServers || {});
855
+ if (!names.length) return row(STATE.OFF, 'no tools are configured');
856
+ // "configured", NEVER "connected". No local artifact proves a server answered, and claiming a
857
+ // live connection from a config entry would be a fabricated status.
858
+ return row(STATE.ON, `${names.length} tools are configured (${names.slice(0, 4).join(', ')}${names.length > 4 ? ', …' : ''}) — configured, which is not the same as currently reachable`);
859
+ },
860
+ },
861
+
862
+ {
863
+ key: 'nightly-refresh',
864
+ label: 'Nightly refresh',
865
+ whatItBuysYou: 'Your knowledge base updates itself overnight, so what your AI knows about your tools does not quietly go stale.',
866
+ scope: SCOPE.MACHINE,
867
+ // Loading a launchd job is machine mutation with no single verified command; global Rule 10.
868
+ turnOn: null,
869
+ detect() {
870
+ // launchd is macOS-only. On any other platform this is UNCHECKABLE, not off — this repo has
871
+ // already shipped a macOS-only assumption that went red the moment it met the Linux CI runner,
872
+ // and reporting "your nightly job is off" to a Linux user would be that same bug with worse
873
+ // consequences, because it reads as an actionable fault rather than a test failure.
874
+ if (process.platform !== 'darwin') return row(STATE.UNKNOWN, `scheduled jobs are managed by launchd, which does not exist on ${process.platform} — this cannot be checked here`);
875
+ let out;
876
+ try { out = execFileSync('launchctl', ['list'], { encoding: 'utf8', timeout: 15_000 }); }
877
+ catch (e) { return row(STATE.UNKNOWN, `could not list scheduled jobs (${String(e?.message || e).split('\n')[0].slice(0, 60)}) — nightly state not checked`); }
878
+
879
+ // THIS ROW IS ABOUT THE NIGHTLY KNOWLEDGE-BASE REFRESH, so it counts the nightly refresh — not
880
+ // every launchd job whose label happens to start com.ruvnet. MEASURED on this machine: that
881
+ // prefix match reported "11 refresh jobs are loaded and every one last exited cleanly" while
882
+ // sweeping in goldie-weekly, npx-witness, issue-fix, npm-token-renew, issue-watch,
883
+ // routing-flywheel, brain-gists, npx-72h-verdict and nightly-watchdog. Exactly ONE of the
884
+ // eleven (brain-nightly) was the thing the sentence claimed to describe. Ten unrelated jobs
885
+ // were being offered as evidence for a capability none of them implements.
886
+ //
887
+ // AND THE UNDER-COUNTING TWIN, which cost more (issue #113). The pattern below is a guess at
888
+ // what a refresh job is CALLED, and the one job this row is actually about is not called that:
889
+ // the installer loads `com.ruvnet.brain-update`, which contains neither "nightly" nor
890
+ // "refresh". So the console reported "no nightly refresh job is loaded" about a job that was
891
+ // loaded, scheduled for 03:47 and running nightly — a detector blind to its own installer.
892
+ // The label is now taken from nightly-controller.mjs, the module the console already uses to
893
+ // turn this job on and off, instead of being described a second time as a pattern here.
894
+ const NIGHTLY = /^com\.ruvnet\.[\w.-]*(nightly|refresh)/i;
895
+ const all = out.split('\n')
896
+ .map((l) => l.split('\t'))
897
+ .filter((c) => c.length >= 3 && /^com\.ruvnet\./.test(c[2] || ''))
898
+ .map((c) => ({ label: c[2].trim(), exit: c[1] }));
899
+ // The watchdog watches the refresh; it is not the refresh, and counting it inflates the answer.
900
+ const jobs = all.filter((j) => j.label === NIGHTLY_LABEL
901
+ || (NIGHTLY.test(j.label) && !/watchdog/i.test(j.label)));
902
+ if (!jobs.length) {
903
+ return row(STATE.ABSENT, all.length
904
+ ? `no nightly refresh job is loaded on this machine (${all.length} other RuvNet job${all.length === 1 ? '' : 's'} are scheduled, but none of them is the knowledge-base refresh)`
905
+ : 'no scheduled refresh jobs are loaded on this machine');
906
+ }
907
+
908
+ const name = (j) => j.label.replace('com.ruvnet.', '');
909
+ // FAILING IS NOT DORMANT. REJECTED by both duelists 2026-07-24: a job that is loaded, scheduled and
910
+ // has RUN is installed and IN USE — a non-zero exit is a HEALTH problem belonging to the alarm
911
+ // channel, never a "you should switch this on" offer. Reporting it OFF is a category error, and it
912
+ // fired here for the worst possible reason: brain-nightly exited non-zero because the publish guard
913
+ // CORRECTLY refused to release from a non-main branch. A working safety guard was being reported as
914
+ // a dormant capability the user should go turn on.
915
+ const failing = jobs.filter((j) => j.exit !== '0' && j.exit !== '-');
916
+ if (failing.length) return row(STATE.ON, `${jobs.length} nightly refresh job${jobs.length === 1 ? '' : 's'} loaded and running, but ${failing.length} last exited non-zero (${failing.slice(0, 3).map((j) => `${name(j)}=${j.exit}`).join(', ')}) — installed and in use, so this is a health problem to look into, not a capability to switch on`);
917
+
918
+ // "-" IS NOT "0". launchd prints "-" for a job that has never run in this boot, and the old
919
+ // check lumped it in with success — so "every one last exited cleanly" could describe a job
920
+ // that has never executed once. That is the silence-reads-as-health failure the positive-
921
+ // confirmation standing order exists to kill, stated on the surface that is supposed to enforce it.
922
+ const neverRan = jobs.filter((j) => j.exit === '-');
923
+ if (neverRan.length === jobs.length) {
924
+ return row(STATE.UNKNOWN, `${jobs.length} nightly refresh job${jobs.length === 1 ? ' is' : 's are'} loaded (${jobs.map(name).slice(0, 3).join(', ')}) but ${jobs.length === 1 ? 'it has' : 'none has'} run since this machine last booted, so whether the refresh actually works here has not been demonstrated`);
925
+ }
926
+ if (neverRan.length) {
927
+ return row(STATE.ON, `${jobs.length} nightly refresh jobs are loaded; ${jobs.length - neverRan.length} last exited cleanly and ${neverRan.length} (${neverRan.map(name).slice(0, 3).join(', ')}) have not run since boot`);
928
+ }
929
+ return row(STATE.ON, `${jobs.length} nightly refresh job${jobs.length === 1 ? '' : 's'} loaded (${jobs.map(name).slice(0, 3).join(', ')}), and every one last exited cleanly`);
930
+ },
931
+ },
932
+ ];
933
+
934
+ /**
935
+ * Run every detect() and return one row per capability. NEVER throws: this feeds an advisory
936
+ * surface, and a surface that can crash the page it advises on is worse than no surface. A detector
937
+ * that throws is reported as 'unknown' with the thrown message — the failure becomes visible data
938
+ * rather than a missing row, because a silently dropped capability is indistinguishable from one
939
+ * that does not exist.
940
+ */
941
+ export function auditAll({ project = process.cwd() } = {}) {
942
+ // The default is the CALLER'S directory, not this package's. See the note on REPO: taking no
943
+ // argument at all is what made every project-scoped row describe the wrong folder.
944
+ const ctx = { project: path.resolve(project), home: HOME };
945
+ return CAPABILITIES.map((c) => {
946
+ let r;
947
+ try { r = c.detect(ctx); }
948
+ catch (e) { r = row(STATE.UNKNOWN, `this check failed to run (${String(e?.message || e).split('\n')[0].slice(0, 70)})`); }
949
+ // A detector returning something malformed must not silently become 'undefined' on the page.
950
+ const state = Object.values(STATE).includes(r?.state) ? r.state : STATE.UNKNOWN;
951
+ const evidence = typeof r?.evidence === 'string' && r.evidence.trim()
952
+ ? r.evidence
953
+ : 'this check returned no evidence, so its state is unknown';
954
+ return {
955
+ key: c.key,
956
+ label: c.label,
957
+ whatItBuysYou: c.whatItBuysYou,
958
+ scope: c.scope,
959
+ turnOn: c.turnOn,
960
+ state,
961
+ evidence,
962
+ // WHICH project a project-scoped row is about, named rather than assumed. "no memory store
963
+ // exists for this project" is only checkable by a reader who can see which folder was read.
964
+ ...(c.scope === SCOPE.PROJECT ? { project: ctx.project } : {}),
965
+ };
966
+ });
967
+ }
968
+
969
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
970
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('capability-registry.mjs');
971
+ if (invokedDirectly) {
972
+ // --project mirrors capability-audit.mjs's --repo: the project-scoped rows are about a directory,
973
+ // and the person running this must be able to say which one rather than inferring it.
974
+ const pi = process.argv.indexOf('--project');
975
+ const project = pi >= 0 && process.argv[pi + 1] ? path.resolve(process.argv[pi + 1]) : process.cwd();
976
+ const rows = auditAll({ project });
977
+ if (process.argv.includes('--json')) { console.log(JSON.stringify(rows, null, 2)); process.exit(0); }
978
+
979
+ const MARK = { on: '●', off: '○', unknown: '?', absent: '·' };
980
+ const off = rows.filter((r) => r.state === STATE.OFF);
981
+ console.log(`\n ${rows.length} capabilities checked on this machine`);
982
+ console.log(` (project-scoped rows describe ${project.replace(HOME, '~')})\n`);
983
+ for (const r of rows) {
984
+ console.log(` ${MARK[r.state]} ${r.label.padEnd(24)} ${r.state.toUpperCase()} [${r.scope}]`);
985
+ console.log(` ${r.evidence}`);
986
+ if (r.state === STATE.OFF) {
987
+ console.log(` buys you: ${r.whatItBuysYou}`);
988
+ // No verified command is stated as exactly that. Silence would read as "nothing can be done".
989
+ console.log(r.turnOn ? ` turn on: ${r.turnOn.cmd}` : ` turn on: no verified one-line command exists for this`);
990
+ }
991
+ console.log('');
992
+ }
993
+ console.log(` ${off.length} of ${rows.length} are installed and switched off.\n`);
994
+ }