ruvnet-brain 4.0.12 → 4.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +2 -2
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  6. package/plugin/scripts/anticipate.sh +80 -14
  7. package/plugin/scripts/capability-registry.mjs +994 -0
  8. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  9. package/plugin/scripts/continuation-gate.mjs +129 -1
  10. package/plugin/scripts/gates.mjs +146 -0
  11. package/plugin/scripts/goal-match.mjs +398 -0
  12. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  13. package/plugin/scripts/hook-registry.mjs +616 -0
  14. package/plugin/scripts/hook-shim.mjs +13 -2
  15. package/plugin/scripts/learning-enable.mjs +382 -0
  16. package/plugin/scripts/lesson-promote.mjs +262 -0
  17. package/plugin/scripts/lesson-provenance.mjs +43 -0
  18. package/plugin/scripts/lesson-store.mjs +67 -56
  19. package/plugin/scripts/memory-doctor.mjs +345 -0
  20. package/plugin/scripts/nightly-controller.mjs +98 -0
  21. package/plugin/scripts/runtime-preferences.mjs +18 -0
  22. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  23. package/plugin/scripts/user-settings.mjs +672 -0
  24. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  25. package/scripts/advocacy-outcomes.mjs +4 -808
  26. package/scripts/capability-registry.mjs +4 -876
  27. package/scripts/corpus-qa.mjs +44 -6
  28. package/scripts/doc-currency.mjs +30 -2
  29. package/scripts/gates.mjs +4 -146
  30. package/scripts/goal-match.mjs +4 -398
  31. package/scripts/hook-registry.mjs +4 -567
  32. package/scripts/issue-watch.mjs +108 -0
  33. package/scripts/learning-enable.mjs +4 -380
  34. package/scripts/lesson-promote.mjs +4 -262
  35. package/scripts/memory-doctor.mjs +4 -345
  36. package/scripts/nightly-controller.mjs +4 -66
  37. package/scripts/nightly-wrapper.sh +23 -1
  38. package/scripts/proactivity-metrics.mjs +8 -1
  39. package/scripts/qe/ux-suite.mjs +72 -1
  40. package/scripts/release-abort-stale.mjs +111 -0
  41. package/scripts/release-convergence-watchdog.mjs +119 -0
  42. package/scripts/release-transaction-provider.mjs +46 -6
  43. package/scripts/release-transaction.mjs +55 -17
  44. package/scripts/self-update.mjs +63 -10
  45. package/scripts/user-settings.mjs +4 -640
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * learning-enable.mjs — answers "is rUv learning actually ON?" with EVIDENCE, and refuses to
4
+ * pretend there is a switch when there isn't one.
5
+ *
6
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
7
+ * THE FAILURE THIS FILE EXISTS TO PREVENT (2026-07-21, caught before it shipped)
8
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
9
+ * A survey of this machine reported, as the headline finding of the night: "26 rUv learning hooks
10
+ * are installed and ZERO are enabled — learning is off." Stuart had been asking "is learning
11
+ * actually on?" all evening and was told no.
12
+ *
13
+ * That finding was FALSE, in both directions, and it was read straight off `ruflo hooks list`:
14
+ *
15
+ * | pre-edit | PreToolUse | No | | | Never | ← "Enabled: No", 26 times
16
+ *
17
+ * Reading the actual installed source rather than trusting the table
18
+ * (~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
19
+ * mcp-tools/hooks-tools.js, `export const hooksList`) shows the handler is a HARDCODED STATIC
20
+ * ARRAY. It takes no input (`inputSchema: { properties: {} }`), opens no file, reads no database,
21
+ * and stamps every one of the 26 entries with the literal `status: 'active'`. Confirmed live via
22
+ * `ruflo hooks list --format json` — the payload contains `"status": "active"` and has NO `enabled`
23
+ * key at all.
24
+ *
25
+ * The CLI renderer (commands/hooks.js, `listCommand`) then draws a column keyed `enabled`:
26
+ *
27
+ * { key: 'enabled', ..., format: (v) => v ? output.success('Yes') : output.dim('No') }
28
+ *
29
+ * `v` is `undefined` for every row, so it prints "No" 26 times. The same mismatch empties the
30
+ * Priority and Executions columns and makes Last Executed read "Never" for everything. Proof the
31
+ * table is inert: `ruflo hooks list --enabled`, which is documented as "show only enabled hooks",
32
+ * returns all 26 rows unchanged — the CLI passes the filter to a handler that accepts no arguments.
33
+ *
34
+ * So BOTH readings of that table are worthless as an answer to "is learning on?":
35
+ * • "Enabled: No" → a field-name bug. Not a state. Never was.
36
+ * • `status: "active"` → a hardcoded literal in a catalog of which subcommands exist. Also not a state.
37
+ *
38
+ * `ruflo hooks list` is a MENU, not a dashboard. It cannot answer the question in either direction.
39
+ *
40
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
41
+ * WHAT "ENABLED" ACTUALLY MEANS
42
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
43
+ * There is no ruflo-side enable flag: `ruflo hooks enable` and `ruflo hooks disable` do not exist
44
+ * (both fall through to the help text, verified live). There is no per-hook config file under
45
+ * ~/.ruflo or ~/.claude-flow — the only file there with an `enabled` key is first-run-enabled.json,
46
+ * which is `{"enabled":{"spinner":true}}`, about a CLI spinner, and a trap for any detector that
47
+ * greps for the word.
48
+ *
49
+ * rUv's learner is driven by the ruflo DAEMON and the `mcp__ruflo__hooks_*` MCP tools that agents
50
+ * call. It is NOT driven by entries in Claude Code's ~/.claude/settings.json. That distinction is
51
+ * the whole ballgame, because it is the second way to get this wrong: this machine has ZERO ruflo
52
+ * learning hooks wired into settings.json (the only `ruflo` command there is a statusline helper)
53
+ * and yet the learner had recorded 457 trajectories and 457 patterns, last adapted 67 minutes
54
+ * before this was written. A detector that concluded "no ruflo hooks in settings.json → learning is
55
+ * off" would be just as wrong as the one that read the table, and far more convincing.
56
+ *
57
+ * The ONLY honest signal is the learner's own accumulated state, which is a real file with real
58
+ * counters that move: ~/.claude-flow/neural/stats.json. scripts/learnings.mjs already treats it as
59
+ * the source of truth for the console's "What I've learned" panel; this agrees with it rather than
60
+ * inventing a second, disagreeing answer.
61
+ *
62
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
63
+ * WHY --enable REFUSES
64
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
65
+ * You cannot enable something that has no enabled state. There is no verified command that turns
66
+ * those 26 rows to "Yes", because nothing reads them. The tempting move — wiring `ruflo hooks
67
+ * post-edit` into every PostToolUse in ~/.claude/settings.json — would be a GUESS dressed as a fix:
68
+ * it edits a file that affects every project on this machine, to solve a problem that does not
69
+ * exist, duplicating capture the daemon is already doing. So --enable writes nothing and prints
70
+ * what actually drives the learner instead.
71
+ *
72
+ * This whole file is therefore READ-ONLY BY CONSTRUCTION. It never opens a file for writing, which
73
+ * is a stronger guarantee than backing one up: there is no write path to get wrong, and the tests
74
+ * assert ~/.claude/settings.json is byte-identical afterwards.
75
+ *
76
+ * Usage:
77
+ * node scripts/learning-enable.mjs [--status] report real state with evidence (default)
78
+ * node scripts/learning-enable.mjs --enable refuse, and explain what genuinely drives learning
79
+ * node scripts/learning-enable.mjs --disable refuse, same reason, inverted
80
+ * node scripts/learning-enable.mjs --json machine-readable state
81
+ *
82
+ * Exit codes: 0 = reported. 2 = refused (deliberate non-action, distinct from 1 = error).
83
+ */
84
+ import fs from 'node:fs';
85
+ import os from 'node:os';
86
+ import path from 'node:path';
87
+
88
+ /**
89
+ * Resolve HOME honestly. os.homedir() consults $HOME on POSIX, but reading it explicitly first
90
+ * means the tests can point this at a temp HOME without depending on that implementation detail
91
+ * — and means a caller can audit a different machine's dotfiles without lying about whose they are.
92
+ */
93
+ const homeOf = (home) => home || process.env.HOME || os.homedir();
94
+
95
+ /**
96
+ * Staleness threshold: a learner that hasn't adapted in a week is idle, not off.
97
+ *
98
+ * EXPORTED because capability-registry.mjs needs the same number and used to have no staleness check
99
+ * at all — it called a learner last adapted 400 days ago "on", while this file called the identical
100
+ * file "IDLE — nothing in 400 days". One constant, one definition; a second copy is a future
101
+ * disagreement with a delay fuse on it.
102
+ */
103
+ export const STALE_DAYS = 7;
104
+
105
+ /**
106
+ * Read the learner's accumulated state — the real signal.
107
+ *
108
+ * Rule: if the file is missing or unparseable we return null counters, NEVER 0. "0 patterns" is a
109
+ * claim that the learner ran and learned nothing; "not checked" is the truth when we never found
110
+ * the file. Shipping the former as the latter is how a detector starts lying.
111
+ */
112
+ export function readLearnerState({ home, now = Date.now() } = {}) {
113
+ const HOME = homeOf(home);
114
+ const statsPath = path.join(HOME, '.claude-flow', 'neural', 'stats.json');
115
+
116
+ let raw = null;
117
+ let parsed = null;
118
+ try { raw = fs.readFileSync(statsPath, 'utf8'); } catch { /* no learner on this machine yet */ }
119
+ if (raw !== null) { try { parsed = JSON.parse(raw); } catch { /* present but corrupt */ } }
120
+
121
+ // `Number(v)` was the bug, not the guard around it. Number(null) === 0, Number('') === 0 and
122
+ // Number(false) === 0 are all finite, so the previous `Number.isFinite(Number(v))` accepted three
123
+ // non-numbers as a MEASURED ZERO — the precise substitution this function's docstring above forbids.
124
+ // MEASURED: `{"trajectoriesRecorded": null}` produced the verdict "the learner file exists and
125
+ // GENUINELY records 0 trajectories", the word "genuinely" attached to a value nobody ever counted.
126
+ // A counter is a counter only when it arrives as a JSON number.
127
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
128
+
129
+ // Timestamps are NOT counters and must not share their reader. `lastAdaptation` is written as epoch
130
+ // milliseconds today, but an upstream switch to ISO-8601 is the single most ordinary schema change
131
+ // there is — and under num() an ISO string returns null, which nulls `days`, which makes the IDLE
132
+ // branch unreachable. MEASURED: a learner idle 400 days reported "ON — the learner is accumulating"
133
+ // purely because its timestamp was a string. The staleness check evaporated on exactly the drift it
134
+ // was written to survive, so this reader accepts both shapes and still refuses everything else.
135
+ const ts = (v) => {
136
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
137
+ if (typeof v === 'string' && v.trim()) {
138
+ const ms = Date.parse(v);
139
+ if (Number.isFinite(ms)) return ms;
140
+ }
141
+ return null;
142
+ };
143
+
144
+ const trajectories = parsed ? num(parsed.trajectoriesRecorded) : null;
145
+ const patterns = parsed ? num(parsed.patternsLearned) : null;
146
+ const lastMs = parsed ? ts(parsed.lastAdaptation) : null;
147
+
148
+ return {
149
+ statsPath,
150
+ present: raw !== null,
151
+ corrupt: raw !== null && parsed === null,
152
+ trajectories,
153
+ patterns,
154
+ lastAdaptationMs: lastMs,
155
+ // `lastMs ? …` treated epoch 0 as "no timestamp"; `=== null` is the only honest test for absence.
156
+ ageMinutes: lastMs === null ? null : Math.floor((now - lastMs) / 60000),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Is the ruflo daemon alive? This is the process that feeds the learner, so its liveness is
162
+ * corroborating evidence — but NOT proof on its own, and not required: a dead daemon with fresh
163
+ * counters still means learning ran. Reported as a separate line, never folded into the verdict.
164
+ */
165
+ export function readDaemonState({ home } = {}) {
166
+ const HOME = homeOf(home);
167
+ const pidPath = path.join(HOME, '.claude-flow', 'daemon.pid');
168
+ let pid = null;
169
+ try { pid = Number(String(fs.readFileSync(pidPath, 'utf8')).trim()); } catch { return { pidPath, pid: null, alive: null }; }
170
+ if (!Number.isInteger(pid) || pid <= 0) return { pidPath, pid: null, alive: null };
171
+ // Signal 0 probes existence without touching the process.
172
+ try { process.kill(pid, 0); return { pidPath, pid, alive: true }; }
173
+ catch (e) { return { pidPath, pid, alive: e.code === 'EPERM' }; }
174
+ }
175
+
176
+ /**
177
+ * Count ruflo LEARNING hooks wired into Claude Code's settings.json.
178
+ *
179
+ * Reported for completeness and explicitly labelled NOT REQUIRED, because the honest finding is
180
+ * that this number was 0 on a machine that was actively learning. It is here so nobody re-derives
181
+ * the wrong conclusion from its absence later. A statusline helper is not a learning hook, so
182
+ * matching bare "ruflo" would overcount — we match a `ruflo hooks <subcommand>` invocation.
183
+ */
184
+ export function readSettingsWiring({ home } = {}) {
185
+ const HOME = homeOf(home);
186
+ const settingsPath = path.join(HOME, '.claude', 'settings.json');
187
+ let cfg = null;
188
+ try { cfg = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { return { settingsPath, present: false, learningHooks: null }; }
189
+
190
+ // TWO REAL CRASHES, both from a hand-edited settings.json, both reproduced with exit code 1 and a
191
+ // raw stack trace — which is the worst possible outcome for a command whose entire job is to tell
192
+ // a worried person whether learning is on. It left them unable to find out at all.
193
+ //
194
+ // `null` is valid JSON, so the try above succeeds and cfg is null → `cfg.hooks` threw
195
+ // "TypeError: Cannot read properties of null (reading 'hooks')".
196
+ // `hooks: {command: …}` written as an object instead of the array the schema wants → the inner
197
+ // for..of threw "TypeError: object is not iterable".
198
+ //
199
+ // This file invites hand-editing (it prints the path), so malformed shapes are an EXPECTED state,
200
+ // not an exceptional one. A structural surprise degrades to "not checked" — null, never 0, because
201
+ // a count of 0 is a claim about their configuration and null is the truth about our reading of it.
202
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { settingsPath, present: true, learningHooks: null };
203
+ if (cfg.hooks !== undefined && (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks))) {
204
+ return { settingsPath, present: true, learningHooks: null };
205
+ }
206
+ const groups = cfg.hooks || {};
207
+
208
+ // AN UNREADABLE SUB-STRUCTURE POISONS THE COUNT, and it must. Guarding the iteration with
209
+ // `Array.isArray(x) ? x : []` stops the crash and then quietly SKIPS the malformed entry — so a
210
+ // settings.json with one hook group written as an object came back as a confident `0`, meaning
211
+ // "we looked everywhere and found no ruflo learning hooks." We did not look everywhere; we
212
+ // skipped the part we could not parse. That is this file's own thesis violated three lines below
213
+ // where it is stated, and its regression test caught it.
214
+ //
215
+ // A partially-read structure yields null ("not checked"), never a total. Losing the count of the
216
+ // entries we COULD read is the correct trade: an incomplete count presented as a complete one is
217
+ // the failure mode, and there is no honest way to render "at least 3" in a field documented as
218
+ // an exact number.
219
+ let unreadable = false;
220
+ let count = 0;
221
+ for (const entries of Object.values(groups)) {
222
+ if (!Array.isArray(entries)) { unreadable = true; continue; }
223
+ for (const entry of entries) {
224
+ if (entry?.hooks !== undefined && !Array.isArray(entry.hooks)) { unreadable = true; continue; }
225
+ for (const h of Array.isArray(entry?.hooks) ? entry.hooks : []) {
226
+ if (/\bruflo\b[^"]*\bhooks\b/.test(String(h?.command || ''))) count += 1;
227
+ }
228
+ }
229
+ }
230
+ return { settingsPath, present: true, learningHooks: unreadable ? null : count };
231
+ }
232
+
233
+ /** Derive the verdict from the evidence. Never asserted, never cached, never guessed. */
234
+ export function verdict(learner) {
235
+ if (!learner.present) {
236
+ return { code: 'NO_LEARNER_STATE', headline: 'NOT LEARNING YET — no learner state on this machine' };
237
+ }
238
+ if (learner.corrupt) {
239
+ return { code: 'CORRUPT', headline: 'UNKNOWN — learner state file exists but is unreadable' };
240
+ }
241
+ const t = learner.trajectories;
242
+ const p = learner.patterns;
243
+ if (t === null && p === null) {
244
+ return { code: 'UNKNOWN_SHAPE', headline: 'UNKNOWN — learner state file has no recognisable counters' };
245
+ }
246
+
247
+ // PARTIAL DRIFT IS STILL DRIFT. Upstream does not rename both counters in the same release, and the
248
+ // half-renamed case is the one that reaches users. Every line below needs BOTH numbers — to compare
249
+ // them against zero, and to print them — so one unread counter is one unanswerable question.
250
+ //
251
+ // The two failures this replaces were mirror images of the same coercion, and both were MEASURED on
252
+ // real bytes. `{trajectories_recorded: 457, patternsLearned: 0}` fell through `(t || 0) === 0` —
253
+ // null coerced to 0 — and reported "genuinely records 0 trajectories and 0 patterns" to a machine
254
+ // holding 457. Read the other way, `{trajectoriesRecorded: null, patternsLearned: 457}` reached the
255
+ // ON branch and rendered the literal string "null work sessions recorded and 457 patterns learned".
256
+ // A confident OFF and a printed "null" from one missing field.
257
+ //
258
+ // This is the guard num() exists to enable, placed where it survives: the `|| 0` three lines below
259
+ // it discarded null the instant it was produced, so the correct reader upstream bought nothing.
260
+ if (t === null || p === null) {
261
+ const missing = t === null ? 'trajectoriesRecorded' : 'patternsLearned';
262
+ const seen = t === null ? `patternsLearned=${p}` : `trajectoriesRecorded=${t}`;
263
+ return {
264
+ code: 'UNKNOWN_PARTIAL',
265
+ missingField: missing,
266
+ headline: `UNKNOWN — learner state file is half-readable (${seen}, but ${missing} is not a number this version can read)`,
267
+ };
268
+ }
269
+
270
+ if (t === 0 && p === 0) {
271
+ return { code: 'INITIALISED_EMPTY', headline: 'INITIALISED BUT EMPTY — learner exists, has recorded nothing' };
272
+ }
273
+ const days = learner.ageMinutes === null ? null : Math.floor(learner.ageMinutes / 1440);
274
+ if (days !== null && days >= STALE_DAYS) {
275
+ return { code: 'IDLE', headline: `IDLE — learned before, but nothing in ${days} days` };
276
+ }
277
+ return { code: 'ON', headline: 'ON — the learner is accumulating' };
278
+ }
279
+
280
+ /**
281
+ * Gather everything, and NEVER throw doing it.
282
+ *
283
+ * The guards inside readSettingsWiring cover the two shapes that actually crashed, but the outer
284
+ * belt matters independently: this function is the entry point for the CLI *and* for
285
+ * capability-registry's learning row, so an unanticipated structural surprise anywhere below must
286
+ * degrade to an honest UNKNOWN rather than take down the page that was asked the question. A
287
+ * crashed probe and a probe reporting "off" are equally useless to the reader; only one of them is
288
+ * also a lie, and neither is acceptable when "I could not tell" is available and true.
289
+ */
290
+ export function gatherState(opts = {}) {
291
+ const learner = readLearnerState(opts);
292
+ const safe = (fn, fallback) => { try { return fn(); } catch { return fallback; } };
293
+ return {
294
+ learner,
295
+ daemon: safe(() => readDaemonState(opts), { pidPath: null, pid: null, alive: null }),
296
+ settings: safe(() => readSettingsWiring(opts), { settingsPath: null, present: false, learningHooks: null }),
297
+ verdict: safe(() => verdict(learner), { code: 'CORRUPT', headline: 'UNKNOWN — learner state could not be interpreted' }),
298
+ };
299
+ }
300
+
301
+ /** "not checked" beats a confident 0. Every rendered number passes through here. */
302
+ const show = (v) => (v === null || v === undefined ? 'not checked' : String(v));
303
+
304
+ const tilde = (p, HOME) => (p.startsWith(HOME) ? p.replace(HOME, '~') : p);
305
+
306
+ function renderStatus(state, HOME) {
307
+ const { learner, daemon, settings, verdict: v } = state;
308
+ const L = [];
309
+ L.push('');
310
+ L.push('rUv learning — actual state');
311
+ L.push('');
312
+ L.push(` ${v.headline}`);
313
+ L.push('');
314
+ L.push(' Evidence (all read at render time):');
315
+ L.push(` trajectories recorded : ${show(learner.trajectories)}`);
316
+ L.push(` patterns learned : ${show(learner.patterns)}`);
317
+ L.push(` last adaptation : ${learner.ageMinutes === null ? 'not checked' : `${learner.ageMinutes} min ago`}`);
318
+ L.push(` source : ${tilde(learner.statsPath, HOME)}${learner.present ? '' : ' (absent)'}`);
319
+ L.push(` ruflo daemon : ${daemon.alive === null ? 'no pid file' : daemon.alive ? `running (pid ${daemon.pid})` : `pid ${daemon.pid} not running`}`);
320
+ L.push('');
321
+ L.push(' Not evidence, despite appearances:');
322
+ L.push(' `ruflo hooks list` prints "Enabled: No" for all 26 hooks. That column is a');
323
+ L.push(' field-name bug, not a state — the handler returns `status: "active"` from a');
324
+ L.push(' hardcoded array and has no `enabled` key for the renderer to read. Proof:');
325
+ L.push(' `ruflo hooks list --enabled` still returns all 26. Ignore that table.');
326
+ L.push('');
327
+ L.push(` ruflo learning hooks in settings.json: ${show(settings.learningHooks)} — and this is NOT required.`);
328
+ L.push(' The learner is fed by the ruflo daemon and the mcp__ruflo__hooks_* tools, not by');
329
+ L.push(' Claude Code hook entries. A count of 0 here does not mean learning is off.');
330
+ L.push('');
331
+ return L.join('\n');
332
+ }
333
+
334
+ function renderRefusal(which) {
335
+ const L = [];
336
+ L.push('');
337
+ L.push(`REFUSING --${which}: there is nothing to ${which}.`);
338
+ L.push('');
339
+ L.push(' The 26 hooks in `ruflo hooks list` have no enabled state to toggle. The "Enabled"');
340
+ L.push(' column is a field-name bug (handler returns `status: "active"`; renderer reads a');
341
+ L.push(' nonexistent `enabled` key), and `ruflo hooks enable` / `ruflo hooks disable` do not');
342
+ L.push(' exist — both fall through to the help text.');
343
+ L.push('');
344
+ L.push(' This command will not edit ~/.claude/settings.json to fake it. That file affects every');
345
+ L.push(' project on this machine, and wiring `ruflo hooks ...` into it would duplicate capture');
346
+ L.push(' the daemon already performs — a guess dressed as a fix.');
347
+ L.push('');
348
+ L.push(' What actually drives the learner (verified commands, run them yourself):');
349
+ L.push(' ruflo hooks intelligence --status initialise / inspect the intelligence system');
350
+ L.push(' ruflo hooks intelligence --train force one training cycle');
351
+ L.push(' ruflo hooks metrics learning metrics dashboard');
352
+ L.push('');
353
+ L.push(' To see whether learning is on, with evidence:');
354
+ L.push(' node scripts/learning-enable.mjs --status');
355
+ L.push('');
356
+ return L.join('\n');
357
+ }
358
+
359
+ function main(argv) {
360
+ const has = (f) => argv.includes(f);
361
+ const HOME = homeOf();
362
+
363
+ if (has('--enable') || has('--disable')) {
364
+ process.stdout.write(renderRefusal(has('--enable') ? 'enable' : 'disable'));
365
+ return 2;
366
+ }
367
+
368
+ const state = gatherState();
369
+ if (has('--json')) {
370
+ process.stdout.write(`${JSON.stringify(state, null, 2)}\n`);
371
+ return 0;
372
+ }
373
+ process.stdout.write(`${renderStatus(state, HOME)}\n`);
374
+ return 0;
375
+ }
376
+
377
+ // BASENAME, not path identity — see the same note in hook-registry.mjs. The strict form stopped
378
+ // firing once `scripts/learning-enable.mjs` became a re-export shim over this payload copy, turning
379
+ // the documented `node scripts/learning-enable.mjs --status` into a silent no-op.
380
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith(`${path.sep}learning-enable.mjs`)) {
381
+ process.exit(main(process.argv.slice(2)));
382
+ }
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lesson-promote.mjs — mine project-scoped lessons, find the UNIVERSAL ones, promote them.
4
+ *
5
+ * THE PROBLEM, MEASURED (2026-07-22, on the owner's own machine — this is not hypothetical):
6
+ *
7
+ * 736 lessons across 48 project memory stores.
8
+ * 284 of them are `type: feedback` — "how I want you to WORK", which is almost never
9
+ * project-specific — and they are scattered across 33 separate stores.
10
+ *
11
+ * "Test before claiming done" taught 87 times across 19 projects
12
+ * "Versioning / release discipline" taught 52 times across 14 projects
13
+ * "Never fabricate / be honest" taught 37 times across 14 projects
14
+ *
15
+ * The owner did not repeat himself because he forgot. He repeated himself because a lesson learned
16
+ * in project A physically cannot reach project B: Claude Code scopes memory to
17
+ * ~/.claude/projects/<project>/memory/, and nothing promotes upward. His words: "I shouldn't ever
18
+ * have to tell you twice." He has had to tell us 87 times.
19
+ *
20
+ * THE PROMOTION RULE IS NOT OURS. It is rUv's, from ruflo ADR-G008 ("Win Twice to Promote",
21
+ * Accepted/implemented): a rule may not enter the constitution on one good result, because one
22
+ * result is noise. We apply the same test with the strongest evidence available here — INDEPENDENT
23
+ * REDISCOVERY. A lesson the user taught in two or more separate projects has already won twice, in
24
+ * the only arena that matters: he needed it more than once, in places that could not see each other.
25
+ *
26
+ * That is deliberately NOT a similarity score or an LLM judgment call. It is a count of how many
27
+ * times a human independently arrived at the same instruction. Cheap, explainable, and impossible
28
+ * to fudge — which matters, because a promotion engine that guesses will pollute the global rules
29
+ * that govern every project, and a bad global rule is far more expensive than a missing one.
30
+ *
31
+ * READ-ONLY BY DEFAULT. Promotion writes to the user's global instructions, which is the highest
32
+ * blast-radius write this project performs. It requires --apply, backs up first, and is reversible.
33
+ *
34
+ * Usage:
35
+ * node scripts/lesson-promote.mjs # report only — what WOULD be promoted, and why
36
+ * node scripts/lesson-promote.mjs --json # machine-readable, for the console
37
+ * node scripts/lesson-promote.mjs --apply # write the promotion block (backs up first)
38
+ * node scripts/lesson-promote.mjs --min-projects 3
39
+ */
40
+ import fs from 'node:fs';
41
+ import path from 'node:path';
42
+ import os from 'node:os';
43
+
44
+ const HOME = os.homedir();
45
+ const PROJECTS = path.join(HOME, '.claude', 'projects');
46
+ const argv = process.argv.slice(2);
47
+ const has = (f) => argv.includes(f);
48
+ const arg = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
49
+
50
+ // A lesson must have been independently learned in at least this many DISTINCT projects to be
51
+ // considered universal. 2 is ADR-G008's "win twice"; the flag exists so a cautious user can demand
52
+ // more evidence, never less — the floor is enforced below.
53
+ const MIN_PROJECTS = Math.max(2, parseInt(arg('--min-projects', '2'), 10) || 2);
54
+
55
+ /**
56
+ * Themes are the unit of promotion, not individual files.
57
+ *
58
+ * Promoting 87 near-identical "test first" lessons verbatim would be worse than promoting none —
59
+ * it would bury the global instructions under duplicates and make them unreadable, which is how a
60
+ * constitution stops being read. We cluster to the PROCESS, then promote one canonical statement of
61
+ * it, citing the projects that independently discovered it as the evidence.
62
+ *
63
+ * Deliberately keyword-based rather than embedding-based. An embedding cluster is a black box the
64
+ * user cannot audit, and this writes to the file that governs every project he owns. He must be able
65
+ * to read the rule that decided, disagree with it, and edit it. Legibility beats cleverness here.
66
+ */
67
+ const THEMES = [
68
+ { key: 'release-discipline', label: 'Versioning and release discipline',
69
+ match: /version|semver|bump|release|ship|deploy|publish|rollback/i },
70
+ { key: 'proof-before-done', label: 'Prove it works before calling it done',
71
+ match: /test|verify|prove|validat|\bqa\b|gate|green|passes/i },
72
+ { key: 'honesty', label: 'Never fabricate, never assume, never inflate',
73
+ match: /honest|lie|fabricat|assum|guess|placeholder|inflat|real data|made up/i },
74
+ { key: 'docs-upkeep', label: 'Keep docs and README current with the code',
75
+ match: /readme|document|changelog|\bdocs?\b|narrative/i },
76
+ { key: 'people', label: 'How to communicate with people',
77
+ match: /thank|contributor|personal|tone|nudge|deferential|communicat/i },
78
+ { key: 'tooling-discipline', label: 'Use the real tool; never hand-roll a substitute',
79
+ match: /hand-roll|impersonat|substitut|reinvent|use the tool|existing tool|ruvnet wins/i },
80
+ { key: 'cost-routing', label: 'Route work to the cheapest capable model',
81
+ match: /cheap|cost|route|routing|model selection|budget|spend/i },
82
+ ];
83
+
84
+ /** Every lesson file on this machine, with its project, type, and text. */
85
+ export function collectLessons(root = PROJECTS) {
86
+ const out = [];
87
+ let dirs = [];
88
+ try { dirs = fs.readdirSync(root); } catch { return out; }
89
+ for (const p of dirs) {
90
+ const md = path.join(root, p, 'memory');
91
+ if (!fs.existsSync(md)) continue;
92
+ let files = [];
93
+ try { files = fs.readdirSync(md); } catch { continue; }
94
+ for (const f of files) {
95
+ if (!f.endsWith('.md') || f === 'MEMORY.md') continue;
96
+ let s = '';
97
+ try { s = fs.readFileSync(path.join(md, f), 'utf8'); } catch { continue; }
98
+ const type = (s.match(/^\s*type:\s*(\w+)/m) || [])[1] || 'unknown';
99
+ const desc = (s.match(/^description:\s*"?(.*?)"?\s*$/m) || [])[1] || '';
100
+ out.push({
101
+ project: p.replace(/^-Users-[^-]+-/, ''),
102
+ file: f.replace(/\.md$/, ''),
103
+ type, desc,
104
+ // name + description only — never the body. The body can hold project specifics (paths,
105
+ // client names, URLs); the identity of a PROCESS lives in its title. Classifying on the body
106
+ // would drag project facts into a global rule, which is the one thing promotion must not do.
107
+ text: `${f} ${desc}`,
108
+ });
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Cluster lessons into themes and decide which have won often enough to be universal.
116
+ *
117
+ * Only `feedback` lessons are eligible. `project` lessons are, by their own declared type, about one
118
+ * codebase; promoting them would be a category error and would leak one client's details into every
119
+ * other project's context.
120
+ */
121
+ /**
122
+ * Themes the user has explicitly rejected. Read from the lesson store's demoted rows.
123
+ *
124
+ * WITHOUT THIS, DEMOTION WAS THEATRE. `lesson-ratify.mjs --demote` set a flag the miner never
125
+ * looked at, so the next mining run would re-propose the exact rule the user had just deleted.
126
+ * ADR-030 §5 states the requirement plainly — "a one-click demote that the next nightly silently
127
+ * undoes is worse than no demote at all, because the user stops trusting the control and, correctly,
128
+ * stops using it" — and the code did not implement it. Verified 2026-07-22: zero references to
129
+ * `demoted` in this file.
130
+ *
131
+ * Read defensively: the store may be absent, locked, or from a newer schema. A miner that throws
132
+ * because it could not read an optional file is worse than one that proposes a rejected theme.
133
+ */
134
+ function demotedThemeKeys() {
135
+ try {
136
+ const file = process.env.RUVNET_LESSON_STORE
137
+ || path.join(os.homedir(), '.config', 'ruvnet-brain', 'lessons.json');
138
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
139
+ return new Set(
140
+ (raw.lessons || [])
141
+ .filter((l) => l && l.demoted === true && typeof l.themeKey === 'string')
142
+ .map((l) => l.themeKey),
143
+ );
144
+ } catch { return new Set(); }
145
+ }
146
+
147
+ export function analyze(lessons, { minProjects = MIN_PROJECTS, rejected = null } = {}) {
148
+ // Injectable for tests; defaults to the real store so the CLI honours real demotions.
149
+ const demoted = rejected instanceof Set ? rejected : demotedThemeKeys();
150
+ const eligible = lessons.filter((l) => l.type === 'feedback');
151
+ const themes = [];
152
+ for (const t of THEMES) {
153
+ const hits = eligible.filter((l) => t.match.test(l.text));
154
+ if (!hits.length) continue;
155
+ const projects = [...new Set(hits.map((h) => h.project))].sort();
156
+ // A theme the user has demoted is NEVER re-proposed. Sticky across every future run.
157
+ if (demoted.has(t.key)) continue;
158
+ themes.push({
159
+ key: t.key,
160
+ label: t.label,
161
+ lessons: hits.length,
162
+ projects,
163
+ projectCount: projects.length,
164
+ // The whole verdict, in one line anyone can check by hand.
165
+ universal: projects.length >= minProjects,
166
+ evidence: `taught ${hits.length} time${hits.length === 1 ? '' : 's'} across ${projects.length} independent project${projects.length === 1 ? '' : 's'}`,
167
+ examples: hits.slice(0, 4).map((h) => `${h.project}: ${h.file}`),
168
+ });
169
+ }
170
+ themes.sort((a, b) => b.projectCount - a.projectCount || b.lessons - a.lessons);
171
+
172
+ const promotable = themes.filter((t) => t.universal);
173
+ return {
174
+ scanned: { projects: new Set(lessons.map((l) => l.project)).size, lessons: lessons.length, feedback: eligible.length },
175
+ minProjects,
176
+ themes,
177
+ promotable,
178
+ // The headline the console should say out loud, computed rather than written.
179
+ headline: promotable.length
180
+ ? `${promotable.length} process${promotable.length === 1 ? '' : 'es'} you have taught in ${minProjects}+ separate projects are still trapped at project level`
181
+ : 'no cross-project process has met the promotion bar yet',
182
+ };
183
+ }
184
+
185
+ /** Render the promotion block. Idempotent, fenced, and safe to regenerate. */
186
+ export function renderBlock(result, now) {
187
+ const lines = [];
188
+ lines.push(BEGIN);
189
+ lines.push('<!-- Generated by scripts/lesson-promote.mjs. Regeneration REPLACES this fenced block');
190
+ lines.push(' wholesale on the next --apply — do NOT hand-edit between the markers, those changes');
191
+ lines.push(' are overwritten. Everything OUTSIDE the markers is left untouched. -->');
192
+ lines.push('');
193
+ lines.push(`## Cross-project lessons (promoted ${now})`);
194
+ lines.push('');
195
+ lines.push('These processes were learned independently in multiple projects. Per ruflo ADR-G008');
196
+ lines.push('("win twice to promote"), independent rediscovery IS the evidence — each one below was');
197
+ lines.push('needed more than once, in places that could not see each other.');
198
+ lines.push('');
199
+ for (const t of result.promotable) {
200
+ lines.push(`- **${t.label}** — ${t.evidence}.`);
201
+ lines.push(` <sub>projects: ${t.projects.slice(0, 6).join(', ')}${t.projects.length > 6 ? `, +${t.projects.length - 6} more` : ''}</sub>`);
202
+ }
203
+ lines.push('');
204
+ lines.push(END);
205
+ return lines.join('\n');
206
+ }
207
+
208
+ const BEGIN = '<!-- BEGIN ruvnet-brain: promoted-lessons -->';
209
+ const END = '<!-- END ruvnet-brain: promoted-lessons -->';
210
+
211
+ /** Write the block into the user's global CLAUDE.md, backing up first. Reversible by design. */
212
+ export function applyPromotion(result, { file, now }) {
213
+ if (!result.promotable.length) return { ok: true, noop: true, log: 'nothing met the promotion bar — nothing written' };
214
+ let existing = '';
215
+ try { existing = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, log: `cannot read ${file}` }; }
216
+
217
+ const backup = `${file}.bak-promote-${now.replace(/[:.]/g, '-')}`;
218
+ try { fs.copyFileSync(file, backup); } catch (e) { return { ok: false, log: `refusing to write — backup failed: ${e.message}` }; }
219
+
220
+ const block = renderBlock(result, now);
221
+ const next = existing.includes(BEGIN)
222
+ ? existing.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}`), block) // replace ONLY our fence
223
+ : `${existing.trimEnd()}\n\n${block}\n`; // first run: append
224
+
225
+ try { fs.writeFileSync(file, next); } catch (e) { return { ok: false, log: `write failed: ${e.message}; backup at ${backup}` }; }
226
+ return { ok: true, backup, promoted: result.promotable.length, log: `promoted ${result.promotable.length} process(es) into ${file.replace(HOME, '~')}` };
227
+ }
228
+
229
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
230
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('lesson-promote.mjs');
231
+ if (invokedDirectly) {
232
+ const result = analyze(collectLessons());
233
+ if (has('--json')) { console.log(JSON.stringify(result, null, 2)); process.exit(0); }
234
+
235
+ console.log(`\n Scanned ${result.scanned.lessons} lessons across ${result.scanned.projects} projects `
236
+ + `(${result.scanned.feedback} are about how you want work done).\n`);
237
+ console.log(` ${result.headline}.\n`);
238
+ const w = 42;
239
+ for (const t of result.themes) {
240
+ const mark = t.universal ? ' ⬆ PROMOTE ' : ' · project ';
241
+ console.log(`${mark}${t.label.padEnd(w)} ${String(t.lessons).padStart(3)} lessons · ${t.projectCount} projects`);
242
+ }
243
+ if (result.promotable.length) {
244
+ console.log(`\n Evidence for each (independent rediscovery — ADR-G008 "win twice"):`);
245
+ for (const t of result.promotable) {
246
+ console.log(`\n ${t.label}`);
247
+ console.log(` ${t.evidence}`);
248
+ for (const ex of t.examples) console.log(` · ${ex}`);
249
+ }
250
+ }
251
+
252
+ if (has('--apply')) {
253
+ const file = arg('--file', path.join(HOME, '.claude', 'CLAUDE.md'));
254
+ const res = applyPromotion(result, { file, now: new Date().toISOString().slice(0, 10) });
255
+ console.log(`\n ${res.ok ? '✓' : '✗'} ${res.log}`);
256
+ if (res.backup) console.log(` backup: ${res.backup.replace(HOME, '~')}`);
257
+ process.exit(res.ok ? 0 : 1);
258
+ } else {
259
+ console.log(`\n This was a REPORT — nothing was written.`);
260
+ console.log(` To promote these into your global instructions: node scripts/lesson-promote.mjs --apply\n`);
261
+ }
262
+ }