ruvnet-brain 3.9.134-dev → 4.0.2

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 (218) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +5 -5
  3. package/bin/install.mjs +382 -36
  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/kb/zip-extract.mjs +53 -14
  25. package/keys/ruvnet-brain-signing.pub.pem +3 -0
  26. package/package.json +14 -22
  27. package/plugin/.claude-plugin/marketplace.json +14 -0
  28. package/plugin/.claude-plugin/plugin.json +22 -0
  29. package/plugin/.codex-plugin/plugin.json +21 -0
  30. package/plugin/.mcp.json +8 -0
  31. package/plugin/commands/brain-console.md +16 -0
  32. package/plugin/commands/configure.md +33 -0
  33. package/plugin/commands/rvbc.md +79 -0
  34. package/plugin/commands/rvcb.md +16 -0
  35. package/plugin/commands/whats-new.md +57 -0
  36. package/plugin/hooks/codex-hooks.json +160 -0
  37. package/plugin/hooks/hook-contracts.json +77 -0
  38. package/plugin/hooks/hooks.json +202 -0
  39. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  40. package/plugin/mcp/server.mjs +56 -6
  41. package/plugin/scripts/anticipate.sh +534 -0
  42. package/plugin/scripts/codex-hook-adapter.mjs +96 -0
  43. package/plugin/scripts/continuation-gate.mjs +267 -0
  44. package/plugin/scripts/design-wall.sh +137 -0
  45. package/plugin/scripts/detach.mjs +182 -0
  46. package/plugin/scripts/first-session-worker.mjs +38 -0
  47. package/plugin/scripts/gate-receipt.sh +35 -0
  48. package/plugin/scripts/ground-before-write.sh +199 -0
  49. package/plugin/scripts/ground-ruvnet.sh +517 -0
  50. package/plugin/scripts/grounding-stamp.sh +113 -0
  51. package/plugin/scripts/grounding-substance.mjs +595 -0
  52. package/plugin/scripts/hijack-ruvnet.sh +81 -0
  53. package/plugin/scripts/hook-input.mjs +558 -0
  54. package/plugin/scripts/hook-shim-bash.mjs +55 -0
  55. package/plugin/scripts/hook-shim.mjs +303 -0
  56. package/plugin/scripts/host-update.mjs +58 -0
  57. package/plugin/scripts/kling-preflight.sh +146 -0
  58. package/plugin/scripts/learn-capture.sh +173 -0
  59. package/plugin/scripts/learn-flush.mjs +155 -0
  60. package/plugin/scripts/lesson-hooks.sh +213 -0
  61. package/plugin/scripts/md-stamp.mjs +219 -0
  62. package/plugin/scripts/protect-brain-state.sh +84 -0
  63. package/plugin/scripts/route-dispatch.sh +147 -0
  64. package/plugin/scripts/routing-outcome-capture.mjs +89 -0
  65. package/plugin/scripts/runtime-preferences.mjs +269 -0
  66. package/plugin/scripts/session-start-core.mjs +477 -0
  67. package/plugin/scripts/session-start.sh +13 -0
  68. package/plugin/scripts/signal-watch.mjs +193 -0
  69. package/plugin/scripts/unprompted-runtime.mjs +377 -0
  70. package/plugin/scripts/update-apply.mjs +419 -0
  71. package/plugin/scripts/verify-interface.sh +53 -0
  72. package/plugin/scripts/version-bump-gate.sh +112 -0
  73. package/plugin/skills/brain-build/SKILL.md +123 -0
  74. package/plugin/skills/brain-console/SKILL.md +22 -0
  75. package/plugin/skills/brain-prompt/SKILL.md +83 -0
  76. package/plugin/skills/brain-score/SKILL.md +101 -0
  77. package/plugin/skills/release-proof/SKILL.md +81 -0
  78. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  79. package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
  80. package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
  81. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
  82. package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
  83. package/plugin/skills/rvbc/SKILL.md +23 -0
  84. package/plugin/skills/savings/SKILL.md +46 -0
  85. package/plugin/skills/whats-new/SKILL.md +22 -0
  86. package/scripts/adr-backfill.mjs +107 -0
  87. package/scripts/advocacy-outcomes.mjs +808 -0
  88. package/scripts/agentdb-context.mjs +216 -0
  89. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  90. package/scripts/ascii-drift.mjs +236 -0
  91. package/scripts/behavioral-l1-l4.mjs +210 -0
  92. package/scripts/brain-capability-check.mjs +72 -0
  93. package/scripts/brain-grade-groundtruth.mjs +100 -0
  94. package/scripts/brain-latency-50.mjs +227 -0
  95. package/scripts/brain-novice-50.mjs +189 -0
  96. package/scripts/brain-stamp.mjs +94 -0
  97. package/scripts/brain-state.mjs +212 -0
  98. package/scripts/build-bundle.mjs +522 -0
  99. package/scripts/build-concepts.mjs +132 -0
  100. package/scripts/build-l2.mjs +71 -0
  101. package/scripts/build-primer.mjs +73 -0
  102. package/scripts/build-symbols.mjs +68 -0
  103. package/scripts/calibrate-router.mjs +97 -0
  104. package/scripts/capability-audit.mjs +321 -0
  105. package/scripts/capability-registry.mjs +876 -0
  106. package/scripts/check-indexation.mjs +108 -0
  107. package/scripts/check-legibility.mjs +189 -0
  108. package/scripts/ci/build-fixture-kb.mjs +67 -0
  109. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  110. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  111. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  112. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  113. package/scripts/ci/stranger-scenario.mjs +228 -0
  114. package/scripts/ci/stranger-timeout.mjs +25 -0
  115. package/scripts/ci-verdict.mjs +29 -0
  116. package/scripts/claims-verify.mjs +710 -0
  117. package/scripts/clear-claude-tmp.sh +31 -0
  118. package/scripts/console-engine.mjs +434 -0
  119. package/scripts/console-engine.test.mjs +125 -0
  120. package/scripts/corpus-qa.mjs +250 -0
  121. package/scripts/correction-detect-embed.mjs +346 -0
  122. package/scripts/correction-detect-measure.mjs +270 -0
  123. package/scripts/correction-detect.mjs +686 -0
  124. package/scripts/count-chunks.mjs +54 -0
  125. package/scripts/described-questions.json +30 -0
  126. package/scripts/design-grade.mjs +58 -0
  127. package/scripts/dev-plugin-link.sh +105 -0
  128. package/scripts/distill-project.mjs +200 -0
  129. package/scripts/doc-currency.mjs +801 -0
  130. package/scripts/eval-brain.mjs +244 -0
  131. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  132. package/scripts/full-hints.mjs +87 -0
  133. package/scripts/gate.sh +39 -0
  134. package/scripts/gates.mjs +146 -0
  135. package/scripts/gen-console-images.mjs +54 -0
  136. package/scripts/gen-images.mjs +47 -0
  137. package/scripts/git-clone-refresh.mjs +52 -0
  138. package/scripts/git-hooks/pre-push +126 -0
  139. package/scripts/goal-match.mjs +398 -0
  140. package/scripts/goldie-research.mjs +223 -0
  141. package/scripts/goldie-weekly.sh +67 -0
  142. package/scripts/health-repair.mjs +250 -0
  143. package/scripts/helix-scenario-questions.json +10 -0
  144. package/scripts/ingest-gists.mjs +230 -0
  145. package/scripts/ingest-meeting.mjs +115 -0
  146. package/scripts/ingest-repo.mjs +79 -0
  147. package/scripts/install-npx-witness.sh +49 -0
  148. package/scripts/issue-fix.mjs +639 -0
  149. package/scripts/issue-watch.mjs +276 -0
  150. package/scripts/issue4-close-note.md +31 -0
  151. package/scripts/key-canary.mjs +91 -0
  152. package/scripts/latency-to-surface.mjs +233 -0
  153. package/scripts/learning-enable.mjs +380 -0
  154. package/scripts/learning-replay.mjs +1570 -0
  155. package/scripts/learnings.mjs +62 -0
  156. package/scripts/lesson-gate.mjs +680 -0
  157. package/scripts/lesson-lifecycle.mjs +449 -0
  158. package/scripts/lesson-promote.mjs +262 -0
  159. package/scripts/lesson-ratify.mjs +98 -0
  160. package/scripts/lesson-seed.mjs +252 -0
  161. package/scripts/lesson-store.mjs +447 -0
  162. package/scripts/loop-checkpoint.mjs +86 -0
  163. package/scripts/memdb-health.sh +14 -0
  164. package/scripts/memory-doctor.mjs +271 -0
  165. package/scripts/model-catalog.mjs +79 -0
  166. package/scripts/nightly-controller.mjs +66 -0
  167. package/scripts/nightly-gists.sh +72 -0
  168. package/scripts/nightly-wrapper.sh +180 -0
  169. package/scripts/notify.sh +12 -0
  170. package/scripts/npx-witness.sh +56 -0
  171. package/scripts/onboarding-console.mjs +2749 -0
  172. package/scripts/private-fence.mjs +69 -0
  173. package/scripts/proactivity-metrics.mjs +118 -0
  174. package/scripts/proof-questions.json +56 -0
  175. package/scripts/prove.mjs +95 -0
  176. package/scripts/proxy/claude-proxied.sh +57 -0
  177. package/scripts/proxy/proxy-revert.sh +59 -0
  178. package/scripts/proxy/proxy-up.sh +60 -0
  179. package/scripts/proxy/proxy-verify.mjs +142 -0
  180. package/scripts/published-surface-probe.mjs +241 -0
  181. package/scripts/qe/card-lane-gate.mjs +162 -0
  182. package/scripts/qe/session-start-gate.mjs +229 -0
  183. package/scripts/qe/ux-suite.mjs +323 -0
  184. package/scripts/reconcile-project.mjs +0 -0
  185. package/scripts/record-lesson.mjs +113 -0
  186. package/scripts/refresh-model-catalog.mjs +99 -0
  187. package/scripts/release-proof.mjs +9 -0
  188. package/scripts/release-vector.mjs +281 -0
  189. package/scripts/release.mjs +395 -0
  190. package/scripts/remedy-registry.mjs +247 -0
  191. package/scripts/rerank-cap-eval.mjs +265 -0
  192. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  193. package/scripts/route-cheap.mjs +20 -15
  194. package/scripts/router-utilization.mjs +182 -0
  195. package/scripts/routing-flywheel.mjs +596 -0
  196. package/scripts/rvf-generation.mjs +104 -0
  197. package/scripts/rvf-index-audit.mjs +138 -0
  198. package/scripts/self-update.mjs +508 -0
  199. package/scripts/selfcheck.mjs +7 -1
  200. package/scripts/sign-bundle.mjs +69 -0
  201. package/scripts/signal-watch.mjs +171 -0
  202. package/scripts/stack-sync.mjs +469 -0
  203. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  204. package/scripts/stamp-sweep.mjs +144 -0
  205. package/scripts/status-honesty.mjs +102 -0
  206. package/scripts/sync-version.mjs +217 -0
  207. package/scripts/token-report.mjs +102 -0
  208. package/scripts/top100-benchmark.mjs +479 -0
  209. package/scripts/top100-corpus.mjs +112 -0
  210. package/scripts/top100-semantic-assertions.mjs +449 -0
  211. package/scripts/update-apply.mjs +9 -0
  212. package/scripts/upgrade-notice.mjs +14 -0
  213. package/scripts/verify-bundle.mjs +51 -0
  214. package/scripts/verify-channels.mjs +184 -0
  215. package/scripts/verify-model-catalog.mjs +104 -0
  216. package/scripts/verify-nightly-close-issue4.sh +31 -0
  217. package/scripts/version.mjs +40 -0
  218. package/scripts/wired-check.mjs +864 -0
@@ -0,0 +1,31 @@
1
+ #!/bin/sh
2
+ # clear-claude-tmp.sh — purge stale Claude Code task-output temp files.
3
+ #
4
+ # WHY IT IS A SCRIPT NOW (2026-07-13): this was an inline plist one-liner, and it carried a beautiful
5
+ # little bug. Its "log" line was:
6
+ # echo "Old Claude task logs cleared at Mon Apr 6 16:33:08 EDT 2026" >> /tmp/clear-claude-tmp.log
7
+ # The date was expanded ONCE, by whatever shell wrote the plist back in April, and then frozen into the
8
+ # argument forever. Every one of the 43 log lines since is byte-identical. The job ran fine for months;
9
+ # its log was a lie the entire time — it could not distinguish today's run from April's.
10
+ #
11
+ # It also never reported WHAT it deleted, so "ran and cleaned 200 files" and "ran and cleaned nothing"
12
+ # looked identical too. Both fixed: a real $(date), and a real count.
13
+ #
14
+ # Stuart nuked it on 2026-07-12 believing it was junk; it was not — the cleanup genuinely worked (this
15
+ # temp dir hits ~800MB). Restored, corrected, wrapped in job-heartbeat.sh, and put in the registry so it
16
+ # is supervised like everything else.
17
+ set -u
18
+
19
+ TMPDIR_CLAUDE="${CLAUDE_TMP_ROOT:-/private/tmp/claude-501}"
20
+ LOG="${CLEAR_TMP_LOG:-/tmp/clear-claude-tmp.log}"
21
+ AGE_DAYS="${CLEAR_TMP_AGE_DAYS:-2}"
22
+
23
+ [ -d "$TMPDIR_CLAUDE" ] || { echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] SKIP: $TMPDIR_CLAUDE does not exist" >> "$LOG"; exit 0; }
24
+
25
+ # Count first, then delete — so the log can state what actually happened rather than assert success.
26
+ COUNT=$(find "$TMPDIR_CLAUDE" -name "*.output" -mtime +"$AGE_DAYS" 2>/dev/null | wc -l | tr -d ' ')
27
+ find "$TMPDIR_CLAUDE" -name "*.output" -mtime +"$AGE_DAYS" -delete 2>/dev/null
28
+ BEFORE_MB=$(du -sm "$TMPDIR_CLAUDE" 2>/dev/null | cut -f1)
29
+
30
+ echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] cleared $COUNT stale .output file(s) older than ${AGE_DAYS}d; dir now ${BEFORE_MB}MB" >> "$LOG"
31
+ exit 0
@@ -0,0 +1,434 @@
1
+ // console-engine.mjs — the PURE core of the Onboarding Console (ADR-0013 / DDD-0002).
2
+ //
3
+ // No I/O. No network. No filesystem. No process.exit. Everything here is a pure function of its
4
+ // inputs, so it is testable by table (see console-engine.test.mjs) and can never, by construction,
5
+ // change the machine. The server (onboarding-console.mjs) does the reading and the writing; this
6
+ // file only DECIDES. That separation is DDD context 4 (Recommendation is the one context with no
7
+ // I/O) and context 8 (Presentation holds no domain logic) made literal.
8
+ //
9
+ // The one invariant that matters most: a Recommendation CANNOT be constructed without evidence,
10
+ // a cost, and an undo — and, if it touches the machine, a plain-English impact statement. This is
11
+ // the ADR's principle 4 ("every recommendation carries evidence, cost, and a reversal") and Stuart's
12
+ // directive ("never change the machine without explaining, in plain words, what it does") enforced
13
+ // by the type factory rather than by a code review that can be forgotten.
14
+
15
+ import { cmpVersion } from './stack-sync.mjs';
16
+
17
+ // ── Recommendation factory — the schema gate ─────────────────────────────────────────────────────
18
+ // Throws, loudly, on any recommendation that could become an irreversible or unexplained mutation.
19
+ // A throw here is a developer error caught at construction, not a runtime surprise for the user.
20
+ // Blast-radius of a recommendation, mirroring capability-registry.mjs's SCOPE (kept as literals here
21
+ // to avoid importing that whole module into the engine). `null` is the honest "scope not stated" —
22
+ // the console groups those into their own bucket rather than guessing which side they fall on.
23
+ const REC_SCOPES = new Set(['project', 'user', 'machine']);
24
+
25
+ export function makeRecommendation(spec) {
26
+ const { id, title, rationale, severity, touchesMachine, plainImpact, evidence, cost, change, undo, scope } = spec;
27
+ const err = (m) => { throw new Error(`Recommendation "${id ?? '?'}" invalid: ${m}`); };
28
+
29
+ if (scope != null && !REC_SCOPES.has(scope)) err(`bad scope ${scope} (expected project|user|machine or omitted)`);
30
+ if (!id || typeof id !== 'string') err('missing id');
31
+ if (!title) err('missing title');
32
+ if (!['INFO', 'SUGGESTED', 'IMPORTANT'].includes(severity)) err(`bad severity ${severity}`);
33
+ if (!Array.isArray(evidence) || evidence.length === 0) err('evidence[] must be non-empty (we may only suggest what we SAW)');
34
+ for (const e of evidence) if (!e || !e.observed) err('every evidence item needs an `observed`');
35
+ if (!cost || typeof cost !== 'object') err('cost is required (time/latency/$/risk)');
36
+ if (!change || !change.human) err('change is required and must be human-describable');
37
+ if (!undo || !undo.human) err('undo is required — a change with no recorded inverse may not be offered');
38
+ if (touchesMachine === true && (!plainImpact || plainImpact.length < 40)) {
39
+ err('touchesMachine:true requires a plain-English `plainImpact` (what happens to the computer + why it is safe/reversible)');
40
+ }
41
+ // Design law: nothing above SUGGESTED unless it was measured on THIS machine (evidence present ⇒
42
+ // measured). We allow IMPORTANT only when there is at least one concrete observation, which the
43
+ // non-empty evidence check above already guarantees. Freeze so Presentation cannot mutate it.
44
+ return Object.freeze({
45
+ id, title, rationale: rationale ?? '',
46
+ severity,
47
+ touchesMachine: touchesMachine === true,
48
+ plainImpact: plainImpact ?? null,
49
+ // The owner's "user-level vs per-project" question, made answerable: does applying this change
50
+ // just this project, or every project on the machine? null = we did not state it (grouped
51
+ // separately, never guessed). See the scope groups in addRecommendations().
52
+ scope: scope ?? null,
53
+ evidence, cost, change, undo,
54
+ });
55
+ }
56
+
57
+ // ── Health & learning recommendations ────────────────────────────────────────────────────────────
58
+ //
59
+ // WHY THIS EXISTS (2026-07-21, found by Stuart, not by us). The console detected that his AgentDB
60
+ // store was CORRUPT, computed a memory score of 49/100, rendered it into a card — and offered no
61
+ // fix. His words: "when it finds a problem, the fact that it didn't recommend a fix is
62
+ // unconscionable." He was right. Detection without a remedy is a nicer way of doing nothing.
63
+ //
64
+ // The same run exposed a second dormancy: the capture queue had grown to 1,884 undelivered events
65
+ // because the flush only fires on a clean SessionEnd, and sessions that compact, crash, or resume
66
+ // never reach it. Draining it took the learner from 5 trajectories / 7 patterns (last trained SIX
67
+ // DAYS earlier) to 412 / 412. The architecture was built, installed, active — and starving.
68
+ //
69
+ // Both become RECOMMENDATIONS here rather than console prose, so they inherit the schema gate above:
70
+ // nothing can be offered without evidence we actually observed, a stated cost, and a recorded undo.
71
+ // That is the difference between a card that worries you and a button that fixes it.
72
+ //
73
+ // @param {{ memory?: {dimensions?:Array}, learning?: {queueDepth?:number, lastTrainSeconds?:number|null, trajectories?:number} }} input
74
+ export function buildHealthRecommendations({ memory = null, learning = null } = {}) {
75
+ const recs = [];
76
+
77
+ // 1. Store corruption. SQLite "wrong # of entries in index" is INDEX damage, not data loss —
78
+ // REINDEX rebuilds indexes from the intact table. Measured live 2026-07-21 on the real store:
79
+ // 1193 rows before, 1193 after, integrity_check ok. That measured no-data-loss result is what
80
+ // makes this safe to offer as one click instead of a warning to go read about.
81
+ const corrupt = (memory?.dimensions || []).find(
82
+ (d) => d?.status === 'fail' && /corrupt|integrity/i.test(String(d?.detail || '')),
83
+ );
84
+ if (corrupt) {
85
+ recs.push(makeRecommendation({
86
+ id: 'repair:memory-index',
87
+ scope: 'project',
88
+ title: 'Repair your memory store',
89
+ rationale: 'A corrupt index makes counts and lookups wrong — it is why saved lessons can read as zero when they are still there.',
90
+ severity: 'IMPORTANT',
91
+ touchesMachine: true,
92
+ plainImpact:
93
+ 'Rebuilds the database indexes for your project memory. Your memories themselves are never touched — '
94
+ + 'index damage is not data loss — and a full backup is taken first, so this is reversible.',
95
+ evidence: [{ observed: String(corrupt.detail || 'integrity_check reported a corrupt index') }],
96
+ cost: { time: 'seconds', risk: 'low — indexes rebuilt from the intact table; backup taken first' },
97
+ change: { human: 'back up the store, REINDEX it, then re-run integrity_check to prove it is clean' },
98
+ undo: { human: 'restore the backup taken immediately before the repair' },
99
+ }));
100
+ }
101
+
102
+ // 2. A capture queue that fills but does not drain. The depth IS the evidence.
103
+ const depth = Number(learning?.queueDepth);
104
+ if (Number.isFinite(depth) && depth > 50) {
105
+ recs.push(makeRecommendation({
106
+ id: 'learning:flush',
107
+ scope: 'user',
108
+ title: 'Feed your captured work into the learner',
109
+ rationale: 'Your AI captured this work, but none of it has reached the learner yet — so none of it has taught it anything.',
110
+ severity: 'IMPORTANT',
111
+ touchesMachine: true,
112
+ plainImpact:
113
+ 'Sends events already captured on your own machine into the local learner so it improves from them. '
114
+ + 'Nothing leaves your computer, and the queue is kept intact if the feed fails.',
115
+ evidence: [{ observed: `${depth} captured events waiting, undelivered` }],
116
+ cost: { time: 'under a minute', risk: 'low — local only; queue preserved on failure' },
117
+ change: { human: 'drain the capture queue into the learner' },
118
+ undo: { human: 'nothing to reverse — this only adds observations; learned state can be cleared separately' },
119
+ }));
120
+ }
121
+
122
+ // 3. A learner that has not trained in days. Installed-but-dormant is a DEFECT, not a neutral
123
+ // state — the entire point of shipping a learning system is that it runs.
124
+ const STALE_TRAIN_SECONDS = 60 * 60 * 24 * 2; // two days
125
+ const age = Number(learning?.lastTrainSeconds);
126
+ if (Number.isFinite(age) && age > STALE_TRAIN_SECONDS) {
127
+ recs.push(makeRecommendation({
128
+ id: 'learning:train',
129
+ scope: 'user',
130
+ title: 'Your learner has gone quiet',
131
+ rationale: 'It is installed and switched on, but it has not learned anything recently — so it is not getting smarter.',
132
+ severity: 'SUGGESTED',
133
+ touchesMachine: true,
134
+ plainImpact:
135
+ 'Runs one local training cycle so recent work becomes patterns it can reuse next time. '
136
+ + 'Runs on your machine only and changes nothing about your projects.',
137
+ evidence: [{ observed: `last trained ${(age / 86400).toFixed(1)} days ago (${learning?.trajectories ?? 0} trajectories recorded)` }],
138
+ cost: { time: 'under a minute', risk: 'low — local, and the learned state is resettable' },
139
+ change: { human: 'run one training cycle so captured work becomes reusable patterns' },
140
+ undo: { human: 'the learned state can be reset, returning it to its pre-training condition' },
141
+ }));
142
+ }
143
+
144
+ // 4. THE ONE THAT SHOULD HAVE FIRED ON DAY ONE — storage without learning.
145
+ //
146
+ // Measured on the owner's machine 2026-07-21: 208 AgentDB stores, 156 with ZERO learns, and 87
147
+ // holding 154,106 memories between them while learning nothing at all. The console had
148
+ // `patterns` and `learns` on every fleet entry the entire time. It had the data and never said
149
+ // the sentence. His words: "you're basically storing a whole bunch of information that none of
150
+ // what you're using is changing how I do a damn thing... That should have been the first thing
151
+ // you noticed."
152
+ //
153
+ // This is the North Star case: the capability was OWNED, INSTALLED, and OFF, and a tool that
154
+ // could see it stayed quiet because nobody asked. Knowing which question to ask is the scarce
155
+ // thing; supplying it is the job.
156
+ // THE HONESTY SPLIT (2026-07-21, second pass). The first version of this said "turn on the
157
+ // learning loop" for every store with `learns === false`. That was two lies in one button.
158
+ //
159
+ // First, "the learning loop" is not a switch — memory-doctor has printed the ACTUAL fix since
160
+ // the day it was written: `embedded but never distilled — run: ruflo memory distill run`
161
+ // (rUv's ADR-174 pipeline: memory_entries → reasoning_patterns/episodes/causal_edges). We had
162
+ // the sentence and shipped a vaguer one.
163
+ //
164
+ // Second, `learns === false` has two causes and only ONE of them is fixable this way. A store
165
+ // whose rows were never embedded (cover < 50%) cannot be distilled at all — there are no
166
+ // vectors to cluster. Offering it a distill button would burn the user's time and then report
167
+ // success having changed nothing. So only the genuinely distillable stores are counted here,
168
+ // and the un-embedded ones are named separately rather than silently folded in.
169
+ const fleet = Array.isArray(learning?.fleet) ? learning.fleet : [];
170
+ const populated = fleet.filter((f) => Number(f?.total || 0) > 0 && !f?.unreadable);
171
+ const distillable = populated.filter(
172
+ (f) => !f?.learns && Number(f?.coverPct ?? 0) >= 50 && Number(f?.patterns ?? 0) === 0,
173
+ );
174
+ const unembedded = populated.filter((f) => !f?.learns && Number(f?.coverPct ?? 0) < 50);
175
+
176
+ if (distillable.length >= 3) {
177
+ const memories = distillable.reduce((n, f) => n + Number(f.total || 0), 0);
178
+ const evidence = [
179
+ { observed: `${distillable.length} project stores are embedded but have never been distilled — they hold memories and zero patterns` },
180
+ { observed: `${memories.toLocaleString()} memories sitting in those stores, teaching nothing` },
181
+ ];
182
+ // Never let the fixable count quietly absorb the unfixable ones. If some stores can't be helped
183
+ // by this button, the card says so on the card — not in a footnote nobody reads.
184
+ if (unembedded.length) {
185
+ evidence.push({ observed: `${unembedded.length} further store${unembedded.length === 1 ? '' : 's'} have too little embedded to distill — this fix does NOT cover them` });
186
+ }
187
+ recs.push(makeRecommendation({
188
+ id: 'learning:distill-fleet',
189
+ scope: 'machine',
190
+ title: 'You are storing memories that teach your AI nothing',
191
+ rationale:
192
+ 'These projects captured plenty and embedded it — but nothing has ever mined it into reusable '
193
+ + 'patterns. It is a filing cabinet, not experience.',
194
+ severity: 'IMPORTANT',
195
+ touchesMachine: true,
196
+ plainImpact:
197
+ 'Runs RuvNet\'s own distillation over the memory stores that are ready for it, turning stored '
198
+ + 'work into patterns your AI can reuse. Each store is snapshotted first, it runs entirely on '
199
+ + 'your machine at no cost, and the snapshots can be restored if you want it undone.',
200
+ evidence,
201
+ cost: { time: 'a few minutes for a large store', usd: 0, risk: 'low — every store is snapshotted before it is touched' },
202
+ change: { human: 'distill stored memories into reusable patterns (ruflo memory distill run)' },
203
+ undo: { human: 'restore the snapshot taken of each store immediately before it was distilled' },
204
+ }));
205
+ }
206
+
207
+ return recs;
208
+ }
209
+
210
+ // ── Capability recommendations — the capability-registry ⇄ "What we'd suggest" bridge ─────────────
211
+ //
212
+ // WHY THIS EXISTS. capability-registry.mjs answers "is X on?" and console.app.js's capabilities card
213
+ // renders that answer, but until this function existed nothing connected an OFF row to the one place
214
+ // this file already knows how to make a change safe: a schema-gated Recommendation with evidence,
215
+ // cost, a change, and a PROVEN undo. A capability could sit OFF on that card forever with no path from
216
+ // "here is the gap" to "here is the one-click fix" — the exact gap ADR-027 closed for health/stack/
217
+ // wiring findings, left open for capabilities.
218
+ //
219
+ // THE BAR IS HIGHER THAN "has a turnOn command". capability-registry.mjs's own header states turnOn
220
+ // is null unless the exact command was verified with --help — that proves the command EXISTS, not
221
+ // that its INVERSE has ever been run. Of the registry's rows, only `memory-distillation` clears both:
222
+ // distill-project.mjs's header records a live, round-tripped proof (644 → 648 patterns, restore →
223
+ // 644, re-run → 648, 2026-07-24) — an undo that has actually executed, not merely been promised in a
224
+ // comment (see that file's header for why "promised, never run" was this project's origin sin).
225
+ //
226
+ // So this is a small, explicit map, not "every row with a non-null turnOn". A second and third
227
+ // capability (most likely cross-project-lessons, then workflow-pattern-learning) join this map only
228
+ // once THEIR undo is independently proven the same way — never before (Rule 0: verify, don't assume).
229
+ const CAPABILITY_ELIGIBLE = {
230
+ 'memory-distillation': {
231
+ title: 'Turn on memory distillation',
232
+ scope: 'project',
233
+ cost: { time: '~10s', usd: 0, risk: 'low' },
234
+ undo: { human: 'restores the pre-distill snapshot exactly (proven 2026-07-24: 644→648 patterns, restore→644, re-run→648)' },
235
+ },
236
+ };
237
+
238
+ /**
239
+ * @param {{ capabilities?: Array<{key,label,state,scope,turnOn,evidence,whatItBuysYou}> }} input — the
240
+ * SAME rows capability-registry.mjs's auditAll() produces (or an equivalent shape in tests).
241
+ * @returns Recommendation[] — empty for every row that is ON/IDLE/UNKNOWN/ABSENT, not on the eligible
242
+ * map above, or whose turnOn command is missing/parameterised. An empty array is the expected,
243
+ * correct answer for most calls: this is deliberately a narrow allowlist, not a general-purpose
244
+ * "offer anything OFF" mechanism.
245
+ */
246
+ export function buildCapabilityRecommendations({ capabilities = [] } = {}) {
247
+ const recs = [];
248
+ for (const row of capabilities) {
249
+ const spec = CAPABILITY_ELIGIBLE[row?.key];
250
+ if (!spec) continue; // not on the proven-undo map
251
+ if (String(row.state || '').toLowerCase() !== 'off') continue; // ON/IDLE/UNKNOWN/ABSENT: never — see header
252
+ const cmd = row.turnOn && typeof row.turnOn.cmd === 'string' ? row.turnOn.cmd : '';
253
+ if (!cmd || /<[^>]+>/.test(cmd)) continue; // no verified command, or one with a blank to fill in
254
+ const evidence = typeof row.evidence === 'string' && row.evidence.trim()
255
+ ? [{ observed: row.evidence.trim() }] : [];
256
+ if (!evidence.length) continue; // schema gate: never fabricate evidence
257
+ recs.push(makeRecommendation({
258
+ id: `enable:${row.key}`,
259
+ scope: spec.scope,
260
+ title: spec.title,
261
+ rationale: typeof row.whatItBuysYou === 'string' ? row.whatItBuysYou : '',
262
+ severity: 'SUGGESTED',
263
+ touchesMachine: spec.scope !== 'project',
264
+ plainImpact: spec.scope !== 'project' ? `Runs ${row.turnOn.human} on your computer.` : null,
265
+ evidence,
266
+ cost: spec.cost,
267
+ change: { human: row.turnOn.human, cmd },
268
+ undo: spec.undo,
269
+ }));
270
+ }
271
+ return recs;
272
+ }
273
+
274
+ // ── Stack recommendations ────────────────────────────────────────────────────────────────────────
275
+ // Inputs come from stack-sync.auditModel(): rows[{name,installed,target,state,tag}], stale[{name,version,global,dir}].
276
+ // AHEAD is legal and produces NO recommendation — that modelling choice is what makes the
277
+ // alpha-vs-latest downgrade war structurally impossible (see stack-sync.mjs header).
278
+ export function buildStackRecommendations({ rows = [], stale = [] } = {}) {
279
+ const recs = [];
280
+ const behind = rows.filter((r) => r.state === 'BEHIND');
281
+ const broken = rows.filter((r) => r.state === 'BROKEN' && r.target);
282
+
283
+ for (const r of behind) {
284
+ recs.push(makeRecommendation({
285
+ id: `sync:${r.name}`,
286
+ scope: 'machine',
287
+ title: `Update ${r.name} — ${r.installed} → ${r.target}`,
288
+ rationale: `A newer version is available on the @${r.tag} track you follow.`,
289
+ severity: 'SUGGESTED',
290
+ touchesMachine: true,
291
+ plainImpact: `This updates ${r.name} on your computer from version ${r.installed} to ${r.target}. ` +
292
+ `It's the very same tool you already use — just the current version, like updating an app. Your ` +
293
+ `existing work and settings are left alone, it never installs an older version, and if anything ` +
294
+ `looks off you can put ${r.installed} back with one click.`,
295
+ evidence: [{ observed: `${r.name} installed at ${r.installed}; @${r.tag} is ${r.target}`, source: 'stack-sync.auditModel' }],
296
+ cost: { time: '~10–40s', latency: 'none after', usd: 0, risk: 'low' },
297
+ change: { kind: 'run-script', human: `install ${r.name}@${r.target} into your one global copy`, cmd: 'stack-sync --sync', target: r.name, to: r.target },
298
+ undo: { kind: 'reinstall-version', human: `reinstall ${r.name}@${r.installed}`, target: r.name, to: r.installed },
299
+ }));
300
+ }
301
+ for (const r of broken) {
302
+ recs.push(makeRecommendation({
303
+ id: `repair:${r.name}`,
304
+ scope: 'machine',
305
+ title: `Repair ${r.name} — installed copy is unreadable`,
306
+ rationale: `A copy is present but has no readable version, usually a half-finished install.`,
307
+ severity: 'IMPORTANT',
308
+ touchesMachine: true,
309
+ plainImpact: `Your computer has a broken, half-installed copy of ${r.name}. This cleanly reinstalls ` +
310
+ `the current version (${r.target}) so it works again. Nothing else is affected, and the previous ` +
311
+ `broken files are backed up first.`,
312
+ evidence: [{ observed: `${r.name} present on disk with no readable version; registry has ${r.target}`, source: 'stack-sync.auditModel' }],
313
+ cost: { time: '~10–40s', latency: 'none after', usd: 0, risk: 'low' },
314
+ change: { kind: 'run-script', human: `reinstall ${r.name}@${r.target}`, cmd: 'stack-sync --sync', target: r.name, to: r.target },
315
+ undo: { kind: 'none-needed', human: `the broken copy is backed up; a reinstall is safe to leave in place` },
316
+ }));
317
+ }
318
+ if (stale.length) {
319
+ const names = [...new Set(stale.map((s) => s.name))];
320
+ recs.push(makeRecommendation({
321
+ id: 'purge:shadows',
322
+ scope: 'machine',
323
+ title: `Remove ${stale.length} stale duplicate cop${stale.length === 1 ? 'y' : 'ies'}`,
324
+ rationale: `Older duplicate copies in a temporary cache can preempt your up-to-date global copy.`,
325
+ severity: 'IMPORTANT',
326
+ touchesMachine: true,
327
+ plainImpact: `Your computer has ${stale.length} extra, older cop${stale.length === 1 ? 'y' : 'ies'} of ` +
328
+ `RuvNet tools (${names.slice(0, 3).join(', ')}${names.length > 3 ? '…' : ''}) tucked away in a temporary ` +
329
+ `folder. Those stale copies can quietly get used instead of your current ones. This removes them — your ` +
330
+ `main, newer copies stay exactly as they are, and the temporary folder rebuilds itself automatically if ` +
331
+ `it's ever needed. Nothing you use stops working.`,
332
+ evidence: stale.slice(0, 8).map((s) => ({ observed: `${s.name}@${s.version} in npx cache while global is ${s.global}`, source: 'stack-sync.findShadows' })),
333
+ cost: { time: '~1s', latency: 'none', usd: 0, risk: 'low' },
334
+ change: { kind: 'run-script', human: 'delete the stale temporary copies', cmd: 'stack-sync --sync' },
335
+ undo: { kind: 'auto-rebuild', human: 'the temporary cache re-fills itself on next use; no manual step needed' },
336
+ }));
337
+ }
338
+ return recs;
339
+ }
340
+
341
+ // ── Wiring recommendations ───────────────────────────────────────────────────────────────────────
342
+ // Input: wiring survey sites[{project, mechanism, ...}]. We recommend de-npx-ing a project only when
343
+ // it has NPX resolution sites. This reuses reconcile-project.mjs (which backs up + is idempotent).
344
+ export function buildWiringRecommendations({ sites = [] } = {}) {
345
+ const byProject = new Map();
346
+ for (const s of sites) {
347
+ if (s.mechanism !== 'NPX') continue;
348
+ if (!byProject.has(s.project)) byProject.set(s.project, []);
349
+ byProject.get(s.project).push(s);
350
+ }
351
+ const recs = [];
352
+ for (const [project, npxSites] of byProject) {
353
+ recs.push(makeRecommendation({
354
+ id: `reconcile:${project}`,
355
+ scope: 'project',
356
+ title: `Speed up “${project}” — ${npxSites.length} tool call${npxSites.length === 1 ? '' : 's'} download a fresh copy each time`,
357
+ rationale: `These launch RuvNet tools via npx, which re-downloads on every run and can silently use a stale copy.`,
358
+ severity: 'SUGGESTED',
359
+ touchesMachine: true,
360
+ plainImpact: `Right now the project “${project}” starts its RuvNet tools by fetching a fresh copy every ` +
361
+ `single time they run — that's slower, and it can quietly run an out-of-date version without telling you. ` +
362
+ `This switches it to use the one copy already installed on your computer: faster, and always the version ` +
363
+ `you expect. We back up the project's settings files first, and you can restore them with one click.`,
364
+ evidence: npxSites.slice(0, 8).map((s) => ({ observed: `${s.file} · ${s.event}: ${String(s.spec).slice(0, 80)}`, source: 'wiring survey' })),
365
+ cost: { time: '~1s', latency: 'faster after (no per-call download)', usd: 0, risk: 'low' },
366
+ change: { kind: 'run-script', human: `rewire “${project}” to the global binary`, cmd: `reconcile-project --apply --project ${project}`, project },
367
+ undo: { kind: 'restore-backup', human: `restore the .bak-reconcile-* settings files written before the change` },
368
+ }));
369
+ }
370
+ return recs;
371
+ }
372
+
373
+ // ── Memory-health scoring ────────────────────────────────────────────────────────────────────────
374
+ // DDD context 6. A dimension that was NOT probed is listed in notTested[] and contributes to NEITHER
375
+ // the numerator nor the denominator — it can never inflate OR deflate the score. A tested dimension
376
+ // that is FAIL caps the whole score below "healthy" (house rule: a known-broken dimension caps it,
377
+ // no inflated scores). Nothing is ever scored from an assumption.
378
+ export const MEMORY_DIMENSIONS = [
379
+ { key: 'liveness', label: 'Liveness', weight: 25, why: 'a real store→search round-trip on the path actually in use' },
380
+ { key: 'coverage', label: 'Coverage', weight: 20, why: 'a project checkpoint exists and is fresh' },
381
+ { key: 'recallQuality', label: 'Recall quality', weight: 25, why: 'a synthetic question actually surfaces the checkpoint in top-k' },
382
+ { key: 'compactionSurvival', label: 'Compaction survival', weight: 15, why: 'a PreCompact snapshot was written' },
383
+ { key: 'sessionSurfacing', label: 'Session surfacing', weight: 15, why: 'session start puts project state in front of the model' },
384
+ ];
385
+
386
+ // probes: { [key]: { status: 'ok'|'warn'|'fail'|'notTested', detail } }. A key absent from probes,
387
+ // or explicitly 'notTested', is treated as not probed.
388
+ export function scoreMemoryHealth({ project, probes = {} }) {
389
+ const earnedFor = (status, weight) => (status === 'ok' ? weight : status === 'warn' ? weight * 0.5 : 0);
390
+ const dimensions = [];
391
+ const notTested = [];
392
+ let earned = 0, testedWeight = 0, anyFail = false;
393
+
394
+ for (const d of MEMORY_DIMENSIONS) {
395
+ const p = probes[d.key];
396
+ const status = p?.status ?? 'notTested';
397
+ if (status === 'notTested') {
398
+ notTested.push(d.key);
399
+ dimensions.push({ key: d.key, label: d.label, status: 'notTested', detail: p?.detail ?? `not checked this session — ${d.why}`, deduction: 0, weight: d.weight });
400
+ continue;
401
+ }
402
+ const e = earnedFor(status, d.weight);
403
+ earned += e; testedWeight += d.weight;
404
+ if (status === 'fail') anyFail = true;
405
+ dimensions.push({ key: d.key, label: d.label, status, detail: p.detail ?? d.why, deduction: +(d.weight - e).toFixed(1), weight: d.weight });
406
+ }
407
+
408
+ // No tested dimension ⇒ no score. We refuse to emit a number we did not measure (ADR verification #4).
409
+ const raw = testedWeight === 0 ? null : Math.round((earned / testedWeight) * 100);
410
+ const score = raw === null ? null : (anyFail ? Math.min(raw, 49) : raw);
411
+ const summary = score === null
412
+ ? 'not enough probed to score — nothing measured this session'
413
+ : `${score}/100 across ${MEMORY_DIMENSIONS.length - notTested.length} probed dimension${MEMORY_DIMENSIONS.length - notTested.length === 1 ? '' : 's'}` +
414
+ (notTested.length ? `; ${notTested.length} not checked` : '') + (anyFail ? '; capped by a broken dimension' : '');
415
+
416
+ return { project: project ?? null, score, dimensions, notTested, cappedByFailure: anyFail, summary };
417
+ }
418
+
419
+ // ── Wiring summary (pure) ───────────────────────────────────────────────────────────────────────
420
+ export function summarizeWiring(sites = []) {
421
+ const s = { npx: 0, global: 0, mcp: 0, plugin: 0, projectsWithNpx: 0 };
422
+ const npxProjects = new Set();
423
+ for (const site of sites) {
424
+ if (site.mechanism === 'NPX') { s.npx++; npxProjects.add(site.project); }
425
+ else if (site.mechanism === 'GLOBAL_BINARY') s.global++;
426
+ else if (site.mechanism === 'MCP') s.mcp++;
427
+ else if (site.mechanism === 'PLUGIN') s.plugin++;
428
+ }
429
+ s.projectsWithNpx = npxProjects.size;
430
+ return s;
431
+ }
432
+
433
+ // Re-export the single comparator so nothing downstream is tempted to compare versions itself.
434
+ export { cmpVersion };
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // Tests for the pure console engine. The point of these is the SAFETY invariants: a recommendation
3
+ // that could become an irreversible or unexplained machine change must be impossible to construct.
4
+ import assert from 'node:assert/strict';
5
+ import { makeRecommendation, buildStackRecommendations, buildWiringRecommendations, buildCapabilityRecommendations, scoreMemoryHealth, summarizeWiring } from './console-engine.mjs';
6
+
7
+ let pass = 0;
8
+ const t = (name, fn) => { fn(); pass++; console.log(` ok ${name}`); };
9
+ const throws = (fn, re) => assert.throws(fn, re);
10
+
11
+ const valid = {
12
+ id: 'x', title: 'T', severity: 'SUGGESTED',
13
+ evidence: [{ observed: 'saw a thing' }], cost: { time: '1s' },
14
+ change: { human: 'do it' }, undo: { human: 'undo it' },
15
+ };
16
+
17
+ t('makeRecommendation accepts a fully-specified rec', () => {
18
+ const r = makeRecommendation(valid);
19
+ assert.equal(r.id, 'x'); assert.equal(Object.isFrozen(r), true);
20
+ });
21
+ t('rejects empty evidence (may only suggest what we SAW)', () => throws(() => makeRecommendation({ ...valid, evidence: [] }), /evidence/));
22
+ t('rejects missing cost', () => throws(() => makeRecommendation({ ...valid, cost: undefined }), /cost/));
23
+ t('rejects missing undo (no change without a recorded inverse)', () => throws(() => makeRecommendation({ ...valid, undo: undefined }), /undo/));
24
+ t('rejects touchesMachine:true without a plain-English impact', () => throws(() => makeRecommendation({ ...valid, touchesMachine: true }), /plainImpact/));
25
+ t('accepts touchesMachine:true WITH a real plainImpact', () => {
26
+ const r = makeRecommendation({ ...valid, touchesMachine: true, plainImpact: 'This updates a tool on your computer to the current version; your work is untouched and it is reversible.' });
27
+ assert.equal(r.touchesMachine, true);
28
+ });
29
+
30
+ t('BEHIND package → one sync rec that touches the machine and explains itself', () => {
31
+ const recs = buildStackRecommendations({ rows: [{ name: 'ruflo', installed: '3.29.0', target: '3.30.2', tag: 'alpha', state: 'BEHIND' }], stale: [] });
32
+ assert.equal(recs.length, 1);
33
+ assert.equal(recs[0].touchesMachine, true);
34
+ assert.ok(recs[0].plainImpact.length > 40);
35
+ assert.ok(recs[0].undo.human.includes('3.29.0')); // inverse names the version we came from
36
+ });
37
+ t('AHEAD package → NO recommendation (AHEAD is a legal state, never "fix" it)', () => {
38
+ const recs = buildStackRecommendations({ rows: [{ name: 'ruflo', installed: '3.31.0', target: '3.30.2', tag: 'alpha', state: 'AHEAD' }], stale: [] });
39
+ assert.equal(recs.length, 0);
40
+ });
41
+ t('stale shadows → one purge rec with per-shadow evidence', () => {
42
+ const recs = buildStackRecommendations({ rows: [], stale: [{ name: '@ruvector/rvf', version: '0.1.9', global: '0.2.3', dir: '/x' }] });
43
+ assert.equal(recs.length, 1);
44
+ assert.ok(recs[0].evidence.length >= 1);
45
+ });
46
+ t('npx wiring sites → a per-project de-npx rec; global-only projects → none', () => {
47
+ const recs = buildWiringRecommendations({ sites: [
48
+ { project: 'a', mechanism: 'NPX', file: '.mcp.json', event: 'MCP', spec: 'npx ruflo mcp start' },
49
+ { project: 'b', mechanism: 'GLOBAL_BINARY', file: '.claude/settings.json', event: 'PreToolUse', spec: 'node handler' },
50
+ ] });
51
+ assert.equal(recs.length, 1);
52
+ assert.equal(recs[0].id, 'reconcile:a');
53
+ });
54
+
55
+ t('memory score: all ok → 100', () => {
56
+ const p = Object.fromEntries(['liveness', 'coverage', 'recallQuality', 'compactionSurvival', 'sessionSurfacing'].map((k) => [k, { status: 'ok' }]));
57
+ assert.equal(scoreMemoryHealth({ probes: p }).score, 100);
58
+ });
59
+ t('memory score: notTested dims are excluded, not counted as pass OR fail', () => {
60
+ const r = scoreMemoryHealth({ probes: { liveness: { status: 'ok' }, coverage: { status: 'ok' } } });
61
+ assert.equal(r.score, 100); // 2 tested, both ok
62
+ assert.equal(r.notTested.length, 3);
63
+ });
64
+ t('memory score: a single FAIL caps the score below healthy (≤49)', () => {
65
+ const p = { liveness: { status: 'fail' }, coverage: { status: 'ok' }, recallQuality: { status: 'ok' }, compactionSurvival: { status: 'ok' }, sessionSurfacing: { status: 'ok' } };
66
+ assert.ok(scoreMemoryHealth({ probes: p }).score <= 49);
67
+ });
68
+ t('memory score: nothing probed → null (never a number we did not measure)', () => {
69
+ assert.equal(scoreMemoryHealth({ probes: {} }).score, null);
70
+ });
71
+
72
+ t('buildCapabilityRecommendations: OFF + verified command + evidence -> one rec, bound to enable:<key>', () => {
73
+ const recs = buildCapabilityRecommendations({ capabilities: [{
74
+ key: 'memory-distillation', label: 'Memory distillation', scope: 'project', state: 'off',
75
+ whatItBuysYou: 'Loose notes get mined into reusable patterns.',
76
+ turnOn: { human: "Mine this project's stored memories into reusable patterns", cmd: 'node /x/distill-project.mjs' },
77
+ evidence: '10 memories stored and 80.0% embedded, but 0 have been distilled into patterns',
78
+ }] });
79
+ assert.equal(recs.length, 1);
80
+ assert.equal(recs[0].id, 'enable:memory-distillation');
81
+ assert.equal(recs[0].scope, 'project');
82
+ assert.equal(recs[0].touchesMachine, false); // project scope -> never the machine-touch banner
83
+ assert.ok(recs[0].undo.human.length > 0); // schema gate: undo always present
84
+ assert.ok(recs[0].evidence[0].observed.includes('80.0% embedded'));
85
+ });
86
+ t('buildCapabilityRecommendations: ON, IDLE, UNKNOWN, and ABSENT are never offered', () => {
87
+ for (const state of ['on', 'idle', 'unknown', 'absent']) {
88
+ const recs = buildCapabilityRecommendations({ capabilities: [{
89
+ key: 'memory-distillation', scope: 'project', state,
90
+ turnOn: { human: 'do it', cmd: 'node /x/distill-project.mjs' }, evidence: 'something observed',
91
+ }] });
92
+ assert.equal(recs.length, 0, `state=${state} must not produce a recommendation`);
93
+ }
94
+ });
95
+ t('buildCapabilityRecommendations: a capability off the proven-undo map is never offered, even OFF with a verified command', () => {
96
+ const recs = buildCapabilityRecommendations({ capabilities: [{
97
+ key: 'workflow-pattern-learning', scope: 'user', state: 'off',
98
+ turnOn: { human: 'Bootstrap the learner from this repository', cmd: 'ruflo hooks pretrain' },
99
+ evidence: 'the learner file exists and genuinely records 0 trajectories and 0 patterns',
100
+ }] });
101
+ assert.equal(recs.length, 0);
102
+ });
103
+ t('buildCapabilityRecommendations: a command with a blank to fill in is never offered', () => {
104
+ const recs = buildCapabilityRecommendations({ capabilities: [{
105
+ key: 'memory-distillation', scope: 'project', state: 'off',
106
+ turnOn: { human: 'do it', cmd: 'node /x/distill-project.mjs --task "<text>"' }, evidence: 'something observed',
107
+ }] });
108
+ assert.equal(recs.length, 0);
109
+ });
110
+ t('buildCapabilityRecommendations: no evidence -> never offered (the schema gate, not fabricated)', () => {
111
+ const recs = buildCapabilityRecommendations({ capabilities: [{
112
+ key: 'memory-distillation', scope: 'project', state: 'off',
113
+ turnOn: { human: 'do it', cmd: 'node /x/distill-project.mjs' }, evidence: '',
114
+ }] });
115
+ assert.equal(recs.length, 0);
116
+ });
117
+
118
+ t('summarizeWiring counts by mechanism and unique npx projects', () => {
119
+ const s = summarizeWiring([
120
+ { project: 'a', mechanism: 'NPX' }, { project: 'a', mechanism: 'NPX' }, { project: 'b', mechanism: 'GLOBAL_BINARY' }, { project: 'c', mechanism: 'MCP' },
121
+ ]);
122
+ assert.equal(s.npx, 2); assert.equal(s.projectsWithNpx, 1); assert.equal(s.global, 1); assert.equal(s.mcp, 1);
123
+ });
124
+
125
+ console.log(`\n ${pass} passed\n`);