ruvnet-brain 4.0.8 → 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 (63) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +109 -42
  3. package/console/app.js +32 -11
  4. package/console/style.css +5 -0
  5. package/package.json +1 -1
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  9. package/plugin/scripts/anticipate.sh +80 -14
  10. package/plugin/scripts/capability-registry.mjs +994 -0
  11. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  12. package/plugin/scripts/continuation-gate.mjs +169 -1
  13. package/plugin/scripts/gates.mjs +146 -0
  14. package/plugin/scripts/goal-match.mjs +398 -0
  15. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  16. package/plugin/scripts/hook-registry.mjs +616 -0
  17. package/plugin/scripts/hook-shim.mjs +13 -2
  18. package/plugin/scripts/learn-flush.mjs +37 -2
  19. package/plugin/scripts/learning-enable.mjs +382 -0
  20. package/plugin/scripts/lesson-promote.mjs +262 -0
  21. package/plugin/scripts/lesson-provenance.mjs +43 -0
  22. package/plugin/scripts/lesson-store.mjs +67 -56
  23. package/plugin/scripts/memory-doctor.mjs +345 -0
  24. package/plugin/scripts/nightly-controller.mjs +98 -0
  25. package/plugin/scripts/project-identity.mjs +89 -0
  26. package/plugin/scripts/ruflo-bin.mjs +81 -0
  27. package/plugin/scripts/runtime-preferences.mjs +18 -0
  28. package/plugin/scripts/session-snapshot-hook.mjs +4 -1
  29. package/plugin/scripts/session-start-core.mjs +19 -2
  30. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  31. package/plugin/scripts/user-settings.mjs +672 -0
  32. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  33. package/scripts/advocacy-outcomes.mjs +4 -808
  34. package/scripts/capability-registry.mjs +4 -876
  35. package/scripts/console-runtime-identity.mjs +74 -0
  36. package/scripts/corpus-qa.mjs +44 -6
  37. package/scripts/distill-project.mjs +9 -1
  38. package/scripts/doc-currency.mjs +45 -4
  39. package/scripts/gates.mjs +4 -146
  40. package/scripts/goal-match.mjs +4 -398
  41. package/scripts/health-repair.mjs +88 -11
  42. package/scripts/hook-registry.mjs +4 -567
  43. package/scripts/host-install-matrix.mjs +155 -0
  44. package/scripts/issue-watch.mjs +108 -0
  45. package/scripts/learning-enable.mjs +4 -380
  46. package/scripts/lesson-promote.mjs +4 -262
  47. package/scripts/memory-doctor.mjs +4 -342
  48. package/scripts/model-router-catalog.mjs +34 -0
  49. package/scripts/nightly-controller.mjs +4 -66
  50. package/scripts/nightly-wrapper.sh +23 -1
  51. package/scripts/onboarding-console.mjs +34 -12
  52. package/scripts/proactivity-metrics.mjs +8 -1
  53. package/scripts/publication-receipt.mjs +10 -1
  54. package/scripts/qe/ux-suite.mjs +72 -1
  55. package/scripts/release-abort-stale.mjs +111 -0
  56. package/scripts/release-convergence-watchdog.mjs +119 -0
  57. package/scripts/release-transaction-provider.mjs +76 -8
  58. package/scripts/release-transaction.mjs +55 -17
  59. package/scripts/rvf-generation.mjs +17 -0
  60. package/scripts/self-update.mjs +63 -10
  61. package/scripts/staged-host-verifier.mjs +27 -54
  62. package/scripts/sync-version.mjs +10 -10
  63. package/scripts/user-settings.mjs +4 -640
@@ -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
+ }
@@ -5,6 +5,20 @@ export const SOURCE_CLASS = Object.freeze({
5
5
  DEMONSTRATION: 'demonstration',
6
6
  });
7
7
 
8
+ /**
9
+ * STATUS — the ratification ladder. A lesson does not become policy by existing.
10
+ * candidate → ratified (a human agreed) → active (in force at its trigger).
11
+ *
12
+ * It lives HERE, beside the provenance it is read with, because `isUntouchedOwnerSeedRow` below
13
+ * needs both and neither may import the other's module. lesson-store re-exports it, so every
14
+ * existing importer is unaffected.
15
+ */
16
+ export const STATUS = Object.freeze({
17
+ CANDIDATE: 'candidate',
18
+ RATIFIED: 'ratified',
19
+ ACTIVE: 'active',
20
+ });
21
+
8
22
  export const BUNDLED_OWNER_SEED_IDS = new Set([
9
23
  'L01-verify-with-a-capable-channel',
10
24
  'L02-check-before-you-assert',
@@ -19,3 +33,32 @@ export const BUNDLED_OWNER_SEED_IDS = new Set([
19
33
  'L11-retrieval-without-volition-is-broken',
20
34
  'L12-efficiency-seeking-is-the-tell',
21
35
  ]);
36
+
37
+ /**
38
+ * Is this stored row STILL an untouched bundled maintainer seed row?
39
+ *
40
+ * THE FACT BELONGS TO THE WRITERS, NOT TO THE READER. Issue #111: loadLessons decided it by
41
+ * fingerprinting the store's ID SET — "exactly these twelve IDs, nothing more, nothing less" — and
42
+ * that predicate is invariant under every legitimate change it needed to detect. A store seeded from
43
+ * the bundle carries those twelve IDs forever, so the quarantine overwrite re-ran on EVERY load,
44
+ * forcing `origin`/`sourceClass`/`status`/`demoted`/`ratifiedBy` back to imported-candidate-demoted
45
+ * and discarding ratification the console itself had recorded. Rows the user had promoted to
46
+ * `enforcement: block` then failed makeLesson's own trust boundary ("enforcement:block requires
47
+ * origin:user-stated") against the origin the overwrite had just invented, and were dropped with a
48
+ * warning that read like a schema change.
49
+ *
50
+ * So ask it the way the writers answer it, per row. Ratification is stamped by `ratify()` — the one
51
+ * human action in this store — as `status` plus `ratifiedBy`. A row carrying either has been through
52
+ * a person, and a person's decision is not maintainer history to be re-quarantined. An untouched
53
+ * seed row carries neither, because the seed ships every lesson as an unratified candidate on
54
+ * purpose ("the model does not get to ratify its own rules").
55
+ *
56
+ * Dropping the whole-store fingerprint also fixes its under-counting twin: a legacy store holding
57
+ * the twelve bundled rows PLUS the user's own lessons matched nothing at all, so the maintainer rows
58
+ * in it were never quarantined. Per row, they are.
59
+ */
60
+ export function isUntouchedOwnerSeedRow(stored) {
61
+ if (!stored || !BUNDLED_OWNER_SEED_IDS.has(stored.id)) return false;
62
+ if (stored.status === STATUS.RATIFIED || stored.status === STATUS.ACTIVE) return false;
63
+ return !stored.ratifiedBy;
64
+ }
@@ -23,9 +23,9 @@
23
23
  import fs from 'node:fs';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
- import { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
26
+ import { SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
27
27
 
28
- export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
28
+ export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
29
29
 
30
30
  /** Resolve fixture/plugin configuration without mutating the child process account HOME. */
31
31
  export function resolveConfigRoot(env = process.env, home = os.homedir()) {
@@ -104,15 +104,8 @@ const ORIGIN_VALUES = new Set(Object.values(ORIGIN));
104
104
 
105
105
  const SOURCE_CLASS_VALUES = new Set(Object.values(SOURCE_CLASS));
106
106
 
107
- /**
108
- * STATUS — the ratification ladder. A lesson does not become policy by existing.
109
- * candidate → ratified (a human agreed) → active (in force at its trigger).
110
- */
111
- export const STATUS = Object.freeze({
112
- CANDIDATE: 'candidate',
113
- RATIFIED: 'ratified',
114
- ACTIVE: 'active',
115
- });
107
+ // STATUS — the ratification ladder (candidate → ratified → active) — lives in lesson-provenance.mjs
108
+ // beside the seed identity it is read with, and is re-exported above for every existing importer.
116
109
  const STATUS_VALUES = new Set(Object.values(STATUS));
117
110
 
118
111
  /**
@@ -262,50 +255,55 @@ export function unenforceable(lessons) {
262
255
  export const STORE_PATH = process.env.RUVNET_LESSON_STORE
263
256
  || path.join(CONFIG_ROOT, 'lessons.json');
264
257
 
265
- export function loadLessons(file = STORE_PATH) {
266
- try {
267
- const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
268
- // Re-validate on READ, not just on write. A hand-edited store is expected (the user must be able
269
- // to edit and delete these); a malformed entry must be dropped loudly rather than acted upon.
270
- const out = [];
271
- const dropped = [];
272
- const rows = Array.isArray(raw.lessons) ? raw.lessons : [];
273
- const ids = new Set(rows.map((lesson) => lesson?.id));
274
- const legacyOwnerSeed = rows.length === BUNDLED_OWNER_SEED_IDS.size
275
- && ids.size === BUNDLED_OWNER_SEED_IDS.size
276
- && [...BUNDLED_OWNER_SEED_IDS].every((id) => ids.has(id));
277
- for (const stored of rows) {
278
- // SKIP THE BAD ROW, BUT NEVER SILENTLY. An adversarial review proved that a schema change
279
- // (ADR-035 proposes new enforcement values the current enum rejects) would take this store
280
- // from 16 lessons to 0 with NO error and exit 0 — output indistinguishable from "no lessons
281
- // apply". Every ratified rule the owner had personally approved would vanish, and the first
282
- // symptom would be the model quietly misbehaving again.
283
- //
284
- // A store that empties itself quietly is the worst possible failure here, because the whole
285
- // product promise is "you should never have to tell me twice."
286
- const l = legacyOwnerSeed ? {
287
- ...stored,
288
- origin: ORIGIN.IMPORTED,
289
- sourceClass: SOURCE_CLASS.IMPORTED_OWNER,
290
- status: STATUS.CANDIDATE,
291
- demoted: true,
292
- ratifiedBy: null,
293
- } : stored;
294
- try { out.push(makeLesson(l)); } catch (e) {
295
- dropped.push({ id: l && l.id, why: String(e && e.message || e) });
296
- }
297
- }
298
- if (dropped.length) {
299
- // stderr, not stdout: a hook's stdout may be a JSON protocol channel, and corrupting it would
300
- // turn a data-integrity warning into a broken tool call.
301
- process.stderr.write(
302
- `\n ⚠ lesson store: ${dropped.length} of ${(raw.lessons || []).length} lesson(s) could not be loaded and were IGNORED.\n`
303
- + dropped.slice(0, 5).map((d) => ` ${d.id || '(no id)'} — ${d.why.slice(0, 120)}\n`).join('')
304
- + ` Your rules are still in the file; they are not being applied. This is usually a schema change.\n\n`,
305
- );
258
+ /**
259
+ * Read the store, returning BOTH what parsed and the raw rows that did not.
260
+ *
261
+ * `loadLessons` exposes only the valid rows, which is the right contract for every reader — but a
262
+ * WRITER that sees only them will write only them, and the rows this version could not parse are
263
+ * gone forever. See updateLessons: that is the second half of issue #111.
264
+ */
265
+ function readStore(file) {
266
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
267
+ // Re-validate on READ, not just on write. A hand-edited store is expected (the user must be able
268
+ // to edit and delete these); a malformed entry must be dropped loudly rather than acted upon.
269
+ const out = [];
270
+ const dropped = [];
271
+ const rows = Array.isArray(raw.lessons) ? raw.lessons : [];
272
+ for (const stored of rows) {
273
+ // SKIP THE BAD ROW, BUT NEVER SILENTLY. An adversarial review proved that a schema change
274
+ // (ADR-035 proposes new enforcement values the current enum rejects) would take this store
275
+ // from 16 lessons to 0 with NO error and exit 0 — output indistinguishable from "no lessons
276
+ // apply". Every ratified rule the owner had personally approved would vanish, and the first
277
+ // symptom would be the model quietly misbehaving again.
278
+ //
279
+ // A store that empties itself quietly is the worst possible failure here, because the whole
280
+ // product promise is "you should never have to tell me twice."
281
+ const l = isUntouchedOwnerSeedRow(stored) ? {
282
+ ...stored,
283
+ origin: ORIGIN.IMPORTED,
284
+ sourceClass: SOURCE_CLASS.IMPORTED_OWNER,
285
+ status: STATUS.CANDIDATE,
286
+ demoted: true,
287
+ ratifiedBy: null,
288
+ } : stored;
289
+ try { out.push(makeLesson(l)); } catch (e) {
290
+ dropped.push({ row: stored, id: l && l.id, why: String(e && e.message || e) });
306
291
  }
307
- return out;
308
- } catch { return []; }
292
+ }
293
+ if (dropped.length) {
294
+ // stderr, not stdout: a hook's stdout may be a JSON protocol channel, and corrupting it would
295
+ // turn a data-integrity warning into a broken tool call.
296
+ process.stderr.write(
297
+ `\n ⚠ lesson store: ${dropped.length} of ${rows.length} lesson(s) could not be loaded and were IGNORED.\n`
298
+ + dropped.slice(0, 5).map((d) => ` ${d.id || '(no id)'} — ${d.why.slice(0, 120)}\n`).join('')
299
+ + ` Your rules are still in the file; they are not being applied. This is usually a schema change.\n\n`,
300
+ );
301
+ }
302
+ return { lessons: out, dropped };
303
+ }
304
+
305
+ export function loadLessons(file = STORE_PATH) {
306
+ try { return readStore(file).lessons; } catch { return []; }
309
307
  }
310
308
 
311
309
  /**
@@ -412,7 +410,20 @@ export function updateLessons(transform, file = STORE_PATH) {
412
410
  if (fd === null) throw new Error('lesson store is locked by another writer — nothing was saved, try again');
413
411
 
414
412
  try {
415
- const fresh = loadLessons(file); // INSIDE the lock, which is what the old comment promised
413
+ // READ THE WHOLE FILE, NOT JUST THE PART THIS VERSION UNDERSTANDS. Second half of issue #111:
414
+ // the shrink guard below was computed against `loadLessons()`, which silently omits every row
415
+ // that failed validation — so a store whose rows this version cannot parse presented a SMALLER
416
+ // baseline, nothing appeared to shrink, and the write erased those rows for good. The guard's own
417
+ // reader was the deletion path it exists to refuse, and the file's own comment says an
418
+ // unparseable row is the EXPECTED case ("this is usually a schema change").
419
+ //
420
+ // A row we cannot parse is still the user's rule. It is carried through the write byte-for-byte,
421
+ // so a future version that understands it finds it intact.
422
+ let fresh = [];
423
+ let dropped = [];
424
+ // Same tolerance loadLessons has always had: no store yet (a first write) is not an error.
425
+ try { ({ lessons: fresh, dropped } = readStore(file)); } catch { /* absent or unreadable — treated as empty, exactly as before */ }
426
+
416
427
  const next = transform(fresh);
417
428
  if (!Array.isArray(next)) throw new Error('updateLessons: transform must return an array of lessons');
418
429
  if (next.length < fresh.length) {
@@ -420,7 +431,7 @@ export function updateLessons(transform, file = STORE_PATH) {
420
431
  // Deletion has its own path (demote), so refuse rather than lose a rule silently.
421
432
  throw new Error(`updateLessons refused: would drop ${fresh.length - next.length} lesson(s). Use demote() to retire one.`);
422
433
  }
423
- return saveLessons(next, file, { lockHeld: true });
434
+ return saveLessons(dropped.length ? [...next, ...dropped.map((d) => d.row)] : next, file, { lockHeld: true });
424
435
  } finally {
425
436
  try { fs.closeSync(fd); } catch { /* already closed */ }
426
437
  try { fs.rmSync(lock, { force: true }); } catch { /* best effort */ }