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,449 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lesson-lifecycle.mjs — the two ends of a lesson's life that were never built: when it stops
4
+ * mattering, and when it turns out to matter everywhere.
5
+ *
6
+ * WHY THIS FILE EXISTS. ADR-029 shipped promotion and then said, in its own "Deliberately NOT in
7
+ * this round":
8
+ *
9
+ * "Demotion. A promoted rule that stops being useful should fall back out... We have no outcome
10
+ * signal yet, so we cannot honestly implement it — and a promotion system with no demotion
11
+ * accumulates cruft forever. Tracked, not pretended."
12
+ *
13
+ * That was the right call then and it is the reason this file is shaped the way it is. There are two
14
+ * opposite ways to get retirement wrong, and only one of them is ever discussed:
15
+ *
16
+ * the loud failure the store fills with dead rules nobody reads, and a gate nobody reads is
17
+ * prose with extra latency — the exact thing lesson-store.mjs exists to escape
18
+ * the silent failure a safety rule is deleted because it HAPPENED NOT TO FIRE. A rule about not
19
+ * leaking credentials fires once a year. Its silence is the rule WORKING.
20
+ *
21
+ * The second failure is unrecoverable and invisible, so the whole module is biased against it:
22
+ *
23
+ * 1. NOTHING HERE DELETES. Both functions return a PROPOSAL for a human. This module exports no
24
+ * writer at all — no save, no apply, no prune — and `tests/unit/lesson-lifecycle.test.mjs`
25
+ * asserts that by enumerating the exports. Removal stays where the user already controls it:
26
+ * `lesson-ratify.mjs --demote`, which is sticky (ADR-030 §5).
27
+ * 2. A high-severity ratified rule can NEVER be auto-retired, under any signal, including a decade
28
+ * of silence. Rarity is what high severity MEANS; treating rarity as irrelevance inverts it.
29
+ * 3. NO SIGNAL → NO PROPOSAL. "We never observed this" and "we watched and it never fired" are
30
+ * different facts, and only the second is evidence. Absent an outcome signal this module stays
31
+ * silent, which is ADR-029's position, still honoured rather than quietly abandoned.
32
+ *
33
+ * AND ON GENERALIZATION — the honest limit, stated up front. This does NOT rewrite a project rule
34
+ * into a universal one. There is no model in this process, and a regex that strips the project noun
35
+ * out of "always run scripts/gate.sh before shipping" yields "always run before shipping" — a
36
+ * sentence that survives the filter and means nothing. So generalization here is a VERIFIER, not an
37
+ * author: it takes a statement that is ALREADY free of project nouns, checks it was independently
38
+ * rediscovered elsewhere (ADR-G008 "win twice", the same bar lesson-promote.mjs uses), and proposes
39
+ * promoting it verbatim. Anything carrying a path, filename, repo, host, env var or product name is
40
+ * refused and named. That refusal is the feature: a wrongly-promoted rule misdirects every project
41
+ * at once, so this errs toward refusing good generalizations rather than accepting one bad one.
42
+ */
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ import crypto from 'node:crypto';
46
+ import { ORIGIN, STATUS, ENFORCEMENT, loadLessons } from './lesson-store.mjs';
47
+
48
+ // ── The bars, in one place, as numbers a human can argue with ────────────────────────────────────
49
+ export const RETIREMENT = Object.freeze({
50
+ // How long the outcome system must have WATCHED a lesson before its silence counts as evidence,
51
+ // and how long since its last fire before that silence is called dormancy. One number for both,
52
+ // because they are the same claim: "we looked for a quarter and nothing happened."
53
+ SILENCE_DAYS: 90,
54
+ // A lesson must have actually fired this many times before an override RATE means anything.
55
+ // Two overrides out of two fires is a coin landing heads twice, not a verdict on the rule.
56
+ MIN_FIRES_FOR_OVERRIDE: 5,
57
+ // ...and then it must be overridden essentially always. A rule obeyed 1 time in 4 is still working
58
+ // 25% of the time, and retiring it converts a partial win into a total loss.
59
+ OVERRIDE_RATE: 0.8,
60
+ });
61
+
62
+ /** The minimum independent projects for generalization. ADR-G008's "win twice" — a floor, never a dial. */
63
+ export const MIN_PROJECTS = 2;
64
+
65
+ // ── Retirement ───────────────────────────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Lessons that are NEVER auto-retired, no matter what the signals say.
69
+ *
70
+ * Both cases below are rules a human has already looked at and agreed to. The system does not get to
71
+ * un-agree on their behalf because a counter stayed at zero — that is the model overturning the user
72
+ * silently, which is the failure mode the whole trust boundary in lesson-store.mjs was built to stop,
73
+ * pointed in the other direction.
74
+ */
75
+ export function protectedFrom(lesson) {
76
+ const ratified = lesson?.status === STATUS.RATIFIED || lesson?.status === STATUS.ACTIVE;
77
+ if (!ratified) return null;
78
+ if (lesson.severity === 'high') {
79
+ return 'high-severity and ratified by a human — a rule that fires rarely is what "rare catastrophe" '
80
+ + 'means, not evidence it stopped mattering. Only you can remove it: lesson-ratify --demote';
81
+ }
82
+ if (lesson.enforcement === ENFORCEMENT.BLOCK) {
83
+ return 'a ratified blocking rule — blocking is reserved for non-negotiables, and a non-negotiable '
84
+ + 'does not expire because it was not tested lately. Only you can remove it: lesson-ratify --demote';
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Read the outcome signals, refusing anything we cannot defend.
91
+ *
92
+ * Returns `{ ok: false, why }` for missing, incomplete, impossible or too-young data. Every one of
93
+ * those is a case where the honest output is silence — a wrong retirement proposal costs the user
94
+ * trust in the whole surface, and a user who stops trusting the list stops reading it.
95
+ *
96
+ * Expected shape (all counted within one observation window):
97
+ * { observedDays, fires, overrides, lastFiredDaysAgo? }
98
+ */
99
+ export function readSignals(signals) {
100
+ if (!signals || typeof signals !== 'object') {
101
+ return { ok: false, why: 'no outcome signal has been recorded for this lesson — nothing to judge it on' };
102
+ }
103
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
104
+ const observedDays = num(signals.observedDays);
105
+ const fires = num(signals.fires);
106
+ const overrides = num(signals.overrides ?? 0);
107
+ const lastFiredDaysAgo = signals.lastFiredDaysAgo == null ? null : num(signals.lastFiredDaysAgo);
108
+
109
+ if (observedDays === null || fires === null || overrides === null) {
110
+ return { ok: false, why: 'outcome signal is incomplete (observedDays and fires are both required)' };
111
+ }
112
+ // Impossible readings mean the collector is broken. Acting on a broken collector is how the
113
+ // read-only-connection failure (L01) happened: the measurement could not see what it claimed to.
114
+ if (observedDays < 0 || fires < 0 || overrides < 0) {
115
+ return { ok: false, why: 'outcome signal is malformed (negative counts) — the collector is wrong, not the lesson' };
116
+ }
117
+ if (overrides > fires) {
118
+ return { ok: false, why: `outcome signal is impossible (${overrides} overrides of ${fires} fires) — a lesson cannot be overridden more often than it fired` };
119
+ }
120
+ if (observedDays < RETIREMENT.SILENCE_DAYS && fires === 0) {
121
+ return {
122
+ ok: false,
123
+ why: `watched for only ${observedDays} of the ${RETIREMENT.SILENCE_DAYS} days required before silence means anything — not observed, which is not the same as not needed`,
124
+ };
125
+ }
126
+ return { ok: true, observedDays, fires, overrides, lastFiredDaysAgo };
127
+ }
128
+
129
+ /**
130
+ * Should this lesson be PROPOSED for retirement?
131
+ *
132
+ * `retire: true` never means "removed". It means "put this in front of the human with its numbers".
133
+ * The returned `action` is `propose-to-human` in every branch that can reach it; there is no branch
134
+ * that returns anything else, because there is no code here that removes anything.
135
+ *
136
+ * @param {object} lesson a lesson from lesson-store.mjs
137
+ * @param {object} signals { observedDays, fires, overrides, lastFiredDaysAgo? }
138
+ * @returns {{retire: boolean, why: string, action: string, rule: string|null, protected: boolean, evidence: Array}}
139
+ */
140
+ export function shouldRetire(lesson, signals) {
141
+ const no = (why, extra = {}) => ({ retire: false, why, action: 'none', rule: null, protected: false, evidence: [], ...extra });
142
+
143
+ if (!lesson || typeof lesson !== 'object') return no('not a lesson');
144
+ // The user already said no to this one. Retirement has nothing to add, and re-surfacing a rejected
145
+ // rule as "shall we reject it again?" is how a control starts feeling like noise.
146
+ if (lesson.demoted) return no('already demoted by you — it never fires, so there is nothing to retire');
147
+
148
+ const shield = protectedFrom(lesson);
149
+ const read = readSignals(signals);
150
+
151
+ // Protection is checked BEFORE the signals are even consulted, so that no arrangement of counters
152
+ // — however extreme — can reach a retirement proposal for a rule a human ratified as critical.
153
+ if (shield) return no(`never auto-retired: ${shield}`, { protected: true });
154
+ if (!read.ok) return no(read.why);
155
+
156
+ const { observedDays, fires, overrides, lastFiredDaysAgo } = read;
157
+
158
+ // RULE 1 — dormancy. Deliberately unavailable to high-severity lessons even when unratified: their
159
+ // whole point is a failure that is rare, so counting rarity against them is a category error.
160
+ if (lesson.severity !== 'high') {
161
+ if (fires === 0) {
162
+ return {
163
+ retire: true, action: 'propose-to-human', rule: 'dormant', protected: false,
164
+ why: `never fired once in ${observedDays} days of observation at "${lesson.trigger}"`,
165
+ evidence: [{ observed: `watched ${observedDays} days (bar: ${RETIREMENT.SILENCE_DAYS}); fired 0 times` }],
166
+ };
167
+ }
168
+ if (lastFiredDaysAgo !== null && lastFiredDaysAgo >= RETIREMENT.SILENCE_DAYS && observedDays >= RETIREMENT.SILENCE_DAYS) {
169
+ return {
170
+ retire: true, action: 'propose-to-human', rule: 'dormant', protected: false,
171
+ why: `last fired ${lastFiredDaysAgo} days ago, over ${observedDays} days of observation at "${lesson.trigger}"`,
172
+ evidence: [{ observed: `fired ${fires} times total, none in the last ${lastFiredDaysAgo} days (bar: ${RETIREMENT.SILENCE_DAYS})` }],
173
+ };
174
+ }
175
+ }
176
+
177
+ // Reached only by a high-severity lesson, since the non-severe case returned above. Said out loud
178
+ // rather than falling through to a generic "too few to judge", because the reason matters here.
179
+ if (fires === 0) {
180
+ return no(`no fires in ${observedDays} days, but this is high-severity — rarity is what high severity MEANS, so silence is not evidence against it`);
181
+ }
182
+
183
+ // RULE 2 — it fires and you ignore it. This one IS available to high-severity lessons that no human
184
+ // has ratified, because here the user is actively voting against it every time it appears; silence
185
+ // is absence of evidence, but a standing override is evidence.
186
+ if (fires >= RETIREMENT.MIN_FIRES_FOR_OVERRIDE) {
187
+ const rate = overrides / fires;
188
+ if (rate >= RETIREMENT.OVERRIDE_RATE) {
189
+ return {
190
+ retire: true, action: 'propose-to-human', rule: 'always-overridden', protected: false,
191
+ why: `fired ${fires} times and you proceeded anyway ${overrides} of those times (${Math.round(rate * 100)}%) — it is interrupting without changing anything`,
192
+ evidence: [{ observed: `${overrides}/${fires} overrides = ${Math.round(rate * 100)}% (bar: ${Math.round(RETIREMENT.OVERRIDE_RATE * 100)}% over at least ${RETIREMENT.MIN_FIRES_FOR_OVERRIDE} fires)` }],
193
+ };
194
+ }
195
+ return no(`fired ${fires} times and was obeyed ${fires - overrides} of them — still working`);
196
+ }
197
+
198
+ return no(`fired ${fires} time${fires === 1 ? '' : 's'} in ${observedDays} days — too few to judge either way (bar: ${RETIREMENT.MIN_FIRES_FOR_OVERRIDE})`);
199
+ }
200
+
201
+ /** Roll retirement up for a whole store. Read-only: returns a report, writes nothing. */
202
+ export function retirementReport(lessons = [], signalsById = {}) {
203
+ const proposals = [];
204
+ let shielded = 0;
205
+ let unobserved = 0;
206
+ for (const l of lessons) {
207
+ const r = shouldRetire(l, signalsById[l.id]);
208
+ if (r.retire) proposals.push({ id: l.id, statement: l.statement, ...r });
209
+ else if (r.protected) shielded++;
210
+ else if (!l.demoted && !signalsById[l.id]) unobserved++;
211
+ }
212
+ return {
213
+ proposals,
214
+ shielded,
215
+ unobserved,
216
+ scanned: lessons.length,
217
+ headline: proposals.length
218
+ ? `${proposals.length} lesson(s) have stopped earning their interruption`
219
+ : 'nothing has met the retirement bar — no lesson is proposed for removal',
220
+ };
221
+ }
222
+
223
+ // ── Generalization ───────────────────────────────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * PROJECT NOUNS — the tokens that must never be dragged into a universal rule.
227
+ *
228
+ * Each detector is narrow and named, so a refusal can say WHICH token blocked it and the user can
229
+ * disagree with a specific thing rather than with a black box. Two deliberate omissions, stated
230
+ * rather than hidden:
231
+ *
232
+ * • Bare ALL-CAPS words are NOT treated as acronyms. The lessons in this very repo write
233
+ * "read a live source THIS TURN" — emphasis in caps is house style, and a detector that refused
234
+ * every emphatic sentence would refuse everything. Env-var shapes (underscored or digit-bearing)
235
+ * are still caught.
236
+ * • Lowercase hyphenated slugs (`ruvnet-brain`) are only caught via `knownProjects`, because the
237
+ * generic form is indistinguishable from ordinary English ("cross-project", "read-write").
238
+ *
239
+ * Both gaps are covered in practice by passing the project names, which the caller always has.
240
+ */
241
+ const DETECTORS = [
242
+ { kind: 'path', why: 'a filesystem path', re: /(?:^|[\s"'`([])(?:~|\.{1,2})?\/[A-Za-z0-9_@.-]+(?:\/[A-Za-z0-9_@.-]+)*/g },
243
+ { kind: 'path', why: 'a Windows path', re: /\b[A-Za-z]:\\[^\s"']+/g },
244
+ { kind: 'filename', why: 'a filename', re: /\b[\w-]+\.(?:mjs|cjs|jsx?|tsx?|json|jsonl|md|py|rs|sh|ya?ml|toml|sql|txt|html?|css|env|lock|db|rvf|ini|cfg|log)\b/gi },
245
+ { kind: 'package', why: 'a scoped package name', re: /@[A-Za-z0-9-]+\/[A-Za-z0-9._-]+/g },
246
+ { kind: 'repo', why: 'an owner/repo or vendor/module slug', re: /\b[A-Za-z0-9]*[-_0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+|\b[A-Za-z]+\/[A-Za-z0-9._-]*[-_0-9][A-Za-z0-9._-]*/g },
247
+ { kind: 'url', why: 'a URL', re: /(?:https?:\/\/|www\.)\S+/gi },
248
+ { kind: 'host', why: 'a hostname', re: /\b[A-Za-z0-9-]+\.(?:com|io|dev|ai|org|net|sh|app|co|xyz|cloud)\b/gi },
249
+ { kind: 'env-var', why: 'an environment variable', re: /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g },
250
+ { kind: 'port', why: 'a port or address', re: /\bport\s*:?\s*\d{2,5}\b|\b\d{1,3}(?:\.\d{1,3}){3}\b/gi },
251
+ { kind: 'product', why: 'a product or CamelCase proper noun', re: /\b[A-Z]?[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/g },
252
+ { kind: 'client', why: 'a client or company name', re: /\bclient\s+[A-Z][A-Za-z]+|\b[A-Z][A-Za-z]+\s+(?:Corp|Inc|LLC|Ltd|GmbH|PLC)\b/g },
253
+ ];
254
+
255
+ /** Normalize a project directory name to the slug a statement would mention. */
256
+ function projectSlug(p) {
257
+ return String(p || '')
258
+ .replace(/^-Users-[^-]+-/, '')
259
+ .replace(/^Code-/, '')
260
+ .trim();
261
+ }
262
+
263
+ /**
264
+ * Every project-specific noun in a text, as `{kind, why, token}`.
265
+ * Exported because a refusal the user cannot inspect is a refusal they cannot argue with.
266
+ */
267
+ export function projectNouns(text, { knownProjects = [] } = {}) {
268
+ const s = String(text ?? '');
269
+ const found = [];
270
+ const seen = new Set();
271
+ const add = (kind, why, token) => {
272
+ const t = String(token).trim();
273
+ const key = `${kind}:${t.toLowerCase()}`;
274
+ if (!t || seen.has(key)) return;
275
+ seen.add(key);
276
+ found.push({ kind, why, token: t });
277
+ };
278
+
279
+ for (const d of DETECTORS) {
280
+ for (const m of s.matchAll(d.re)) add(d.kind, d.why, m[0]);
281
+ }
282
+
283
+ // The strongest detector, because it needs no heuristic at all: the caller knows the project names.
284
+ // Short slugs are skipped — a project called "brain" would match the word in any sentence about one.
285
+ for (const p of knownProjects) {
286
+ const slug = projectSlug(p);
287
+ if (slug.length < 5) continue;
288
+ for (const variant of new Set([slug, slug.replace(/-/g, ' '), slug.replace(/-/g, '')])) {
289
+ const re = new RegExp(`\\b${variant.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
290
+ const m = s.match(re);
291
+ if (m) add('project-name', 'the name of a project', m[0]);
292
+ }
293
+ }
294
+ return found;
295
+ }
296
+
297
+ /** Collapse a statement to compare wording across projects. */
298
+ const normalizeText = (t) => String(t ?? '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
299
+
300
+ /**
301
+ * The full generalization decision WITH its reason, including on refusal.
302
+ *
303
+ * `proposeGeneralization` returns `null` on refusal per its contract; this is the version that says
304
+ * why, so the console and the tests can show the user which specific thing blocked promotion instead
305
+ * of a silent nothing.
306
+ */
307
+ export function explainGeneralization(lesson, otherProjects = [], { minProjects = MIN_PROJECTS } = {}) {
308
+ // A caller may demand MORE evidence, never less — the same clamp lesson-promote.mjs applies, for
309
+ // the same reason: the bar is a property of the design, not a parameter of the call site.
310
+ const bar = Math.max(MIN_PROJECTS, Number.isFinite(minProjects) ? minProjects : MIN_PROJECTS);
311
+ const refuse = (why, extra = {}) => ({ ok: false, why, proposal: null, ...extra });
312
+
313
+ if (!lesson || typeof lesson.statement !== 'string') return refuse('not a lesson');
314
+ if (lesson.demoted) {
315
+ return refuse('you demoted this lesson — generalization must never resurrect a rule you rejected, in any scope');
316
+ }
317
+ if (lesson.statement.trim().length < 15) return refuse('statement is too short to stand alone as a rule');
318
+
319
+ // 1. INDEPENDENT REDISCOVERY. Counted from the corroborating projects the caller observed, NOT from
320
+ // the lesson's own `projects[]` — that array is written by whoever wrote the lesson, and the
321
+ // adversarial review's exact scenario was a planted lesson claiming its own cross-project
322
+ // provenance. Self-reported breadth is not evidence of breadth.
323
+ const home = new Set((lesson.projects || []).map((p) => projectSlug(p).toLowerCase()));
324
+ const corroborators = [];
325
+ const seenProjects = new Set();
326
+ for (const entry of Array.isArray(otherProjects) ? otherProjects : []) {
327
+ const project = typeof entry === 'string' ? entry : entry?.project;
328
+ const slug = projectSlug(project);
329
+ if (!slug) continue;
330
+ const key = slug.toLowerCase();
331
+ if (seenProjects.has(key)) continue; // the same project twice is once
332
+ seenProjects.add(key);
333
+ if (home.has(key)) continue; // its own project cannot corroborate itself
334
+ corroborators.push({ project: slug, statement: typeof entry === 'string' ? null : entry?.statement ?? null });
335
+ }
336
+
337
+ const independent = 1 + corroborators.length;
338
+ if (independent < bar) {
339
+ return refuse(
340
+ `learned in ${independent} project${independent === 1 ? '' : 's'}; the bar is ${bar} independent ones `
341
+ + '(ruflo ADR-G008 "win twice"). One project finding a rule useful means that project is hard, not that the rule is universal.',
342
+ { independent, bar },
343
+ );
344
+ }
345
+
346
+ // 2. TEMPLATE CONTAMINATION. Two people who independently arrive at the same rule do not phrase it
347
+ // identically. Byte-identical wording across projects is the signature of one file copied twice
348
+ // — which is precisely the forged-rediscovery path the adversarial review found, and it would
349
+ // otherwise read as the STRONGEST possible evidence.
350
+ const mine = normalizeText(lesson.statement);
351
+ const twin = corroborators.find((c) => c.statement && normalizeText(c.statement) === mine);
352
+ if (twin) {
353
+ return refuse(
354
+ `wording in "${twin.project}" is identical to this one — that is a copied template, not independent rediscovery. `
355
+ + 'Forged breadth is the one thing that must never clear this bar.',
356
+ { contaminatedBy: twin.project },
357
+ );
358
+ }
359
+
360
+ // 3. PROJECT NOUNS. The single outcome promotion must never produce.
361
+ const known = [...(lesson.projects || []), ...corroborators.map((c) => c.project)];
362
+ const nouns = projectNouns(lesson.statement, { knownProjects: known });
363
+ if (nouns.length) {
364
+ const n = nouns[0];
365
+ return refuse(
366
+ `the statement names ${n.why} ("${n.token}") — a rule carrying a project-specific noun is not universal, `
367
+ + 'and this does not rewrite statements (a regex that deletes the noun produces a sentence that means nothing).',
368
+ { nouns },
369
+ );
370
+ }
371
+
372
+ // 4. The proposal. Verbatim statement, quarantined provenance, its own namespaced id.
373
+ const projects = [...new Set([...(lesson.projects || []).map(projectSlug).filter(Boolean), ...corroborators.map((c) => c.project)])].sort();
374
+ const digest = crypto.createHash('sha256').update(mine).digest('hex').slice(0, 8);
375
+
376
+ const proposal = {
377
+ // A DISTINCT id, namespaced. Ratification maps over every row matching an id, so a generalization
378
+ // sharing its parent's id would be silently ratified by one click meant for the parent — one
379
+ // human decision, two rules in force, one of them never read.
380
+ id: `G-${digest}-${lesson.id}`,
381
+ statement: lesson.statement,
382
+ trigger: lesson.trigger,
383
+ // CONSTRAINT: anything this function produces is the MODEL's inference that a rule is universal.
384
+ // The user stated the rule; nobody stated its scope. These three fields are last in any spread of
385
+ // `{...lesson, ...proposal}`, so the result cannot block even if the source lesson was ratified
386
+ // and blocking — the trust boundary is enforced by the data, not by the caller remembering.
387
+ origin: ORIGIN.MODEL_INFERRED,
388
+ status: STATUS.CANDIDATE,
389
+ enforcement: ENFORCEMENT.CHECKLIST,
390
+ intendedEnforcement: null,
391
+ ratifiedBy: null,
392
+ severity: lesson.severity === 'high' ? 'high' : 'normal',
393
+ projects,
394
+ repeatCount: lesson.repeatCount || 0,
395
+ evidence: [
396
+ { observed: `independently learned in ${independent} projects that cannot see each other: ${projects.join(', ')}` },
397
+ // Only claimable when wording was actually supplied to compare. Asserting "phrased differently"
398
+ // without having seen the other phrasings would be a fabricated piece of evidence in the
399
+ // audit trail of a rule that governs every project — the exact class of claim L04 forbids.
400
+ ...(corroborators.some((c) => c.statement)
401
+ ? [{ observed: 'each project phrased it differently, so this is rediscovery rather than one template copied twice' }]
402
+ : []),
403
+ { observed: `statement checked for project-specific nouns (paths, filenames, repos, hosts, env vars, ports, product and project names) — none found` },
404
+ { observed: `promotion bar: ruflo ADR-G008 "win twice" — ${independent} independent projects, bar ${bar}` },
405
+ ],
406
+ };
407
+ return { ok: true, why: `universal on ${independent} independent projects, and free of project-specific nouns`, proposal, independent, bar };
408
+ }
409
+
410
+ /**
411
+ * Propose promoting a project lesson to a universal one — or `null` if it has not earned it.
412
+ * See `explainGeneralization` for the reason behind a `null`.
413
+ */
414
+ export function proposeGeneralization(lesson, otherProjects = [], opts = {}) {
415
+ return explainGeneralization(lesson, otherProjects, opts).proposal;
416
+ }
417
+
418
+ // ── CLI — read-only, by construction: this file contains no write of any kind ─────────────────────
419
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('lesson-lifecycle.mjs');
420
+ if (invokedDirectly) {
421
+ const argv = process.argv.slice(2);
422
+ const arg = (f, d = null) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
423
+ let signalsById = {};
424
+ const sigFile = arg('--signals');
425
+ if (sigFile) { try { signalsById = JSON.parse(fs.readFileSync(sigFile, 'utf8')); } catch { signalsById = {}; } }
426
+
427
+ const lessons = loadLessons();
428
+ const report = retirementReport(lessons, signalsById);
429
+
430
+ if (argv.includes('--json')) { console.log(JSON.stringify(report, null, 2)); process.exit(0); }
431
+
432
+ console.log(`\n ${report.scanned} lessons examined. ${report.headline}.\n`);
433
+ if (!Object.keys(signalsById).length) {
434
+ console.log(' No outcome signal is recorded yet, so nothing can be proposed — that is the honest');
435
+ console.log(' answer, not a bug. ADR-029 refused to implement demotion without one, and until a');
436
+ console.log(' fire/override counter exists, "it never fired" and "we never looked" are the same');
437
+ console.log(' observation. Pass --signals <file> once that counter is real.\n');
438
+ }
439
+ for (const p of report.proposals) {
440
+ console.log(` ○ ${p.id} [${p.rule}]`);
441
+ console.log(` ${p.statement.slice(0, 110)}${p.statement.length > 110 ? '…' : ''}`);
442
+ console.log(` ${p.why}`);
443
+ }
444
+ if (report.shielded) {
445
+ console.log(`\n ${report.shielded} ratified critical rule(s) were not even considered — those are yours to remove, not mine.`);
446
+ }
447
+ console.log('\n This was a PROPOSAL. Nothing was changed; this file cannot change anything.');
448
+ console.log(' To act on one: node scripts/lesson-ratify.mjs --demote <id>\n');
449
+ }