ruvnet-brain 4.0.1 → 4.0.4

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