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.
- package/README.md +3 -3
- package/bin/install.mjs +109 -42
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/advocacy-outcomes.mjs +808 -0
- package/plugin/scripts/anticipate.sh +80 -14
- package/plugin/scripts/capability-registry.mjs +994 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
- package/plugin/scripts/continuation-gate.mjs +169 -1
- package/plugin/scripts/gates.mjs +146 -0
- package/plugin/scripts/goal-match.mjs +398 -0
- package/plugin/scripts/hijack-ruvnet.sh +69 -1
- package/plugin/scripts/hook-registry.mjs +616 -0
- package/plugin/scripts/hook-shim.mjs +13 -2
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/learning-enable.mjs +382 -0
- package/plugin/scripts/lesson-promote.mjs +262 -0
- package/plugin/scripts/lesson-provenance.mjs +43 -0
- package/plugin/scripts/lesson-store.mjs +67 -56
- package/plugin/scripts/memory-doctor.mjs +345 -0
- package/plugin/scripts/nightly-controller.mjs +98 -0
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/runtime-preferences.mjs +18 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/plugin/scripts/unprompted-runtime.mjs +22 -7
- package/plugin/scripts/user-settings.mjs +672 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/advocacy-outcomes.mjs +4 -808
- package/scripts/capability-registry.mjs +4 -876
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/corpus-qa.mjs +44 -6
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +45 -4
- package/scripts/gates.mjs +4 -146
- package/scripts/goal-match.mjs +4 -398
- package/scripts/health-repair.mjs +88 -11
- package/scripts/hook-registry.mjs +4 -567
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/issue-watch.mjs +108 -0
- package/scripts/learning-enable.mjs +4 -380
- package/scripts/lesson-promote.mjs +4 -262
- package/scripts/memory-doctor.mjs +4 -342
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/nightly-controller.mjs +4 -66
- package/scripts/nightly-wrapper.sh +23 -1
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/proactivity-metrics.mjs +8 -1
- package/scripts/publication-receipt.mjs +10 -1
- package/scripts/qe/ux-suite.mjs +72 -1
- package/scripts/release-abort-stale.mjs +111 -0
- package/scripts/release-convergence-watchdog.mjs +119 -0
- package/scripts/release-transaction-provider.mjs +76 -8
- package/scripts/release-transaction.mjs +55 -17
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/self-update.mjs +63 -10
- package/scripts/staged-host-verifier.mjs +27 -54
- package/scripts/sync-version.mjs +10 -10
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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 */ }
|