ruvnet-brain 3.9.85-dev → 3.9.129-dev

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.
@@ -0,0 +1,708 @@
1
+ // install-scope.mjs — per-user or per-project, asked once, in the user's own words.
2
+ //
3
+ // THE ONE IDEA. A power user told the owner: "I can't use your stuff because it has hooks and this
4
+ // and that. I just loaded the brain into my RuVector Brain and I don't get the rest of it." He was
5
+ // not complaining about the software. He was saying he could not SEE it — what the pieces are, which
6
+ // ones he had, and what taking only one of them cost him. Nobody could answer that, including us.
7
+ //
8
+ // So this module's job is not to install anything. It is to make one murky decision legible enough
9
+ // that a person can make it in ten seconds and be right. Everything here is therefore either (a)
10
+ // copy that names a real consequence, or (b) a function that DERIVES state from disk instead of
11
+ // assuming it.
12
+ //
13
+ // WHY THIS IS A NUDGE AND NOT A GATE — the correction that produced this file (owner, 2026-07-22):
14
+ //
15
+ // "Nudging somebody is very fair. Forcing them through a gate is not. More advanced people have
16
+ // different ways they implement it, and we need to be supportive of how they like to work.
17
+ // That respect for the individual and how they do it is a big part of the win."
18
+ //
19
+ // This lands on the same day two reviewers proved `enforcement: block` never blocked anything —
20
+ // lesson-gate.mjs exits 1 where the Claude Code contract requires 2, and every caller appends
21
+ // `|| true` anyway. The bug and the philosophy point the same direction, which is the useful part:
22
+ // we were never actually forcing, and we should stop pretending we wanted to. So there is no
23
+ // SCOPE_GATE in this file. There is a recommendation, stated plainly, with its downside attached,
24
+ // and a function that does what the user picked. `RECOMMENDED` is a const, not an enforcement.
25
+ //
26
+ // WHAT THIS FILE REFUSES TO DO. It does not run `claude plugin install`. Shelling out to a mutation
27
+ // we cannot back up and cannot undo would break the promise applyScope() makes three lines below its
28
+ // signature. What it cannot reverse, it PRINTS and hands to the user — see `manualSteps`. That
29
+ // asymmetry is deliberate and is the same one bin/install.mjs already lives by (its `manualInstall`
30
+ // fallback), not a new invention.
31
+
32
+ import fs from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+
36
+ // Reused, not reimplemented. These two were hardened against real, MEASURED failures — a
37
+ // copyFileSync that wedged a process at 100% CPU for 4m38s under concurrent saves, and a truncating
38
+ // in-place write that lost settings on a killed process. Writing a second, naive writer here would
39
+ // reintroduce both, in the one file whose whole subject is "we will not damage your machine".
40
+ import { withLock, writeAtomic } from './user-settings.mjs';
41
+
42
+ const HOME = os.homedir();
43
+
44
+ /**
45
+ * The plugin's marketplace id, read back from a real install record rather than guessed:
46
+ * `~/.claude/plugins/installed_plugins.json` → plugins["ruvnet-brain@ruvnet-brain"]. install.mjs:516
47
+ * installs exactly this string. It is the KEY we look ourselves up under, so a typo here reads as
48
+ * "not installed" — which is why it is one const and not four string literals.
49
+ */
50
+ export const PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
51
+
52
+ /** Where Claude Code records what is installed and at which scope. Probed, not assumed — schema v2. */
53
+ export const INSTALLED_PLUGINS = path.join(HOME, '.claude', 'plugins', 'installed_plugins.json');
54
+
55
+ /** Claude Code's own config. Top-level `mcpServers` = user scope; `projects[dir].mcpServers` = local. */
56
+ export const CLAUDE_JSON = path.join(HOME, '.claude.json');
57
+
58
+ /** User-level state, deliberately outside the bundle `--update` replaces. See lesson-store.mjs:230. */
59
+ export const USER_LESSONS = path.join(HOME, '.config', 'ruvnet-brain', 'lessons.json');
60
+ export const USER_SETTINGS = path.join(HOME, '.config', 'ruvnet-brain', 'settings.json');
61
+
62
+ /** The shared reference corpus. install.mjs:296 — `~/.cache/ruvnet-brain/kb`, $RUVNET_BRAIN_KB wins. */
63
+ export const USER_CORPUS = path.join(HOME, '.cache', 'ruvnet-brain', 'kb');
64
+
65
+ /**
66
+ * THE ENV KEYS THIS MODULE OWNS, and the exhaustive list of what applyScope may write.
67
+ *
68
+ * Each is a real override consumed by real code, cited so a future reader can check rather than
69
+ * trust me: RUVNET_LESSON_STORE (lesson-store.mjs:233), RUVNET_SETTINGS_FILE (user-settings.mjs:55).
70
+ *
71
+ * RUVNET_BRAIN_KB is CONSPICUOUSLY ABSENT and that is a finding, not an oversight — see
72
+ * SHARED_EITHER_WAY. Listing it here would let a well-meaning future edit isolate the corpus per
73
+ * project and silently cost the user gigabytes for nothing.
74
+ */
75
+ export const OWNED_ENV_KEYS = Object.freeze(['RUVNET_LESSON_STORE', 'RUVNET_SETTINGS_FILE']);
76
+
77
+ /** Where a project-scoped install keeps its own state. Sits beside .claude/, visible, greppable. */
78
+ export const PROJECT_STATE_DIR = '.ruvnet-brain';
79
+
80
+ /**
81
+ * THE RECOMMENDATION, as a value rather than a rule.
82
+ *
83
+ * Named `RECOMMENDED` and not `DEFAULT_ENFORCED` on purpose. Callers are free to ignore it; the
84
+ * console renders it as a highlighted option, never as a preselected radio the user must fight.
85
+ */
86
+ export const RECOMMENDED = 'user';
87
+
88
+ // ── What is actually true of each choice ─────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * WHAT DOES NOT CHANGE, and why this list exists at all.
92
+ *
93
+ * The user's real question is never "what are the two options" — it is "if I pick the small one,
94
+ * what do I lose?". A page that lists only differences implies everything is a difference, which is
95
+ * how a reversible ten-second choice starts feeling like a commitment. Naming the things that are
96
+ * identical either way is what makes the choice cheap.
97
+ *
98
+ * Each entry below was checked, not assumed. The corpus one is the load-bearing finding: `ls
99
+ * ~/.cache/ruvnet-brain/kb` is ruvector / agentdb / cognitum passages and .rvf indexes — PUBLIC
100
+ * RuvNet ecosystem source, not your code. Nothing about it is per-project, so isolating it would
101
+ * duplicate the measured size (see measureCorpus) per repo to obtain nothing. It stays shared under
102
+ * both choices, and that is a deliberate decision rather than a gap.
103
+ */
104
+ export const SHARED_EITHER_WAY = Object.freeze([
105
+ Object.freeze({
106
+ what: 'The knowledge itself',
107
+ detail: 'The corpus is public RuvNet source — ruvector, agentdb, cognitum and the rest of the ecosystem — not your project\'s code. It is reference material, identical for every project, so both choices read the same shared copy. Isolating it per project would duplicate it on disk and change nothing about the answers.',
108
+ where: USER_CORPUS,
109
+ }),
110
+ Object.freeze({
111
+ what: 'Answer quality',
112
+ detail: 'Search results are the same either way. Scope decides where what it LEARNS is written, never how well it retrieves.',
113
+ where: USER_CORPUS,
114
+ }),
115
+ Object.freeze({
116
+ what: 'Your ability to change your mind',
117
+ detail: 'Both directions are one command and are backed up before anything is written. Nothing here is a one-way door.',
118
+ where: null,
119
+ }),
120
+ ]);
121
+
122
+ /**
123
+ * THE TWO OPTIONS.
124
+ *
125
+ * `differences` are REAL and each carries `verifiedFrom` — the file and line that makes it true. The
126
+ * brief for this module said plainly: do not invent differences, and if one cannot be verified, do
127
+ * not claim it. That is enforced by structure, not by good intentions: the qualitative test asserts
128
+ * every difference carries a citation, so an unciteable claim cannot be added without going red.
129
+ *
130
+ * Copy follows the owner's own words closely (2026-07-22), because his phrasing already does the two
131
+ * things the previous version failed at — it recommends without cornering, and it says out loud who
132
+ * decides. Paraphrasing it into product-voice lost both.
133
+ */
134
+ export const SCOPES = Object.freeze([
135
+ Object.freeze({
136
+ id: 'user',
137
+ label: 'Per-user (recommended)',
138
+ oneLine: 'One install, every project you work on.',
139
+ recommended: true,
140
+
141
+ // The owner's sentence, near-verbatim. It survives review every time someone tries to tighten it
142
+ // because the clause people want to cut — "we always want YOU to be the arbiter" — is the clause
143
+ // doing the work.
144
+ summary:
145
+ 'Normally this happens on a per-user basis, which lets learning, intelligence, access and '
146
+ + 'software versions stay updated universally across all your projects. Our strong recommendation '
147
+ + 'is per-user — but we always want you to be the arbiter of how things run on your machine.',
148
+
149
+ whyItMatters:
150
+ 'A correction you make once is never repeated anywhere. One update moves every project forward '
151
+ + 'at the same time, so you are never wondering which repo has the current version. This is the '
152
+ + 'setting where the thing compounds.',
153
+
154
+ // Required field, and the reason this schema exists rather than a pair of marketing paragraphs.
155
+ // A page listing only benefits is a sales page: it makes the cautious choice feel timid, which is
156
+ // precisely the pressure the owner asked us to take off the user.
157
+ downside:
158
+ 'What it learns while you work on one client\'s repo can surface while you are working on '
159
+ + 'another\'s. If you need hard separation between two bodies of work on the same machine, that '
160
+ + 'is the real argument for per-project, and it is a good one.',
161
+
162
+ bestFor: 'Almost everyone — including anyone who has not thought about it yet and wants to stop thinking about it.',
163
+
164
+ claudeScope: 'user',
165
+ installCommand: 'claude plugin install ruvnet-brain@ruvnet-brain --scope user',
166
+
167
+ differences: Object.freeze([
168
+ Object.freeze({
169
+ component: 'Plugin (hooks, skills, commands, search_ruvnet)',
170
+ consequence: 'Loads in every project you open, with no per-repo setup.',
171
+ verifiedFrom: 'installed_plugins.json record scope:"user"; bin/install.mjs:516',
172
+ }),
173
+ Object.freeze({
174
+ component: 'What it learns',
175
+ consequence: `One lesson store at ${USER_LESSONS.replace(HOME, '~')} — a mistake corrected in any project is known in all of them.`,
176
+ verifiedFrom: 'scripts/lesson-store.mjs:233',
177
+ }),
178
+ Object.freeze({
179
+ component: 'Your settings',
180
+ consequence: `One file at ${USER_SETTINGS.replace(HOME, '~')} — answer the questions once.`,
181
+ verifiedFrom: 'scripts/user-settings.mjs:55',
182
+ }),
183
+ Object.freeze({
184
+ component: 'Updates',
185
+ consequence: 'One update covers every project. No repo is left on an old version because you forgot it existed.',
186
+ verifiedFrom: 'bin/install.mjs --update replaces the shared cache dir',
187
+ }),
188
+ ]),
189
+ }),
190
+
191
+ Object.freeze({
192
+ id: 'project',
193
+ label: 'Per-project (isolated)',
194
+ oneLine: 'This one directory only. Nothing outside it changes.',
195
+ recommended: false,
196
+
197
+ summary:
198
+ 'Only choose per-project if this is something you absolutely only use on a per-project basis. '
199
+ + 'It is the right answer when a repo genuinely needs to stand alone — but it is the narrower '
200
+ + 'setting, and you will be doing some of this again the next time.',
201
+
202
+ whyItMatters:
203
+ 'Nothing is written outside this directory. What it learns here stays here, which is what you '
204
+ + 'want when a codebase is under an agreement that says so, or when you are simply trying it out '
205
+ + 'and want a clean line around the experiment.',
206
+
207
+ downside:
208
+ 'It does not compound. The same correction has to be made again in your next project, updates '
209
+ + 'have to be run per repo, and it is easy to end up with one directory quietly running an old '
210
+ + 'version. This is the cost, and it is a real one.',
211
+
212
+ bestFor: 'A repo that must not share context with your other work, or a first cautious trial.',
213
+
214
+ // WHY "local" AND NOT "project". Verified live: `claude plugin install -s <scope>` and `claude mcp
215
+ // add -s <scope>` both accept user | project | local. They are not synonyms, and picking the
216
+ // wrong one would be a mutation the user did not consent to:
217
+ //
218
+ // local → recorded in ~/.claude.json under this directory. Private to you.
219
+ // project → written into a file inside the repo, which the user then COMMITS, imposing this
220
+ // choice on every teammate who clones it.
221
+ //
222
+ // "Isolated" must not mean "silently added to your colleagues' machines via git". So the
223
+ // user-facing id is `project` (their word for it) and the Claude Code scope is `local` (the one
224
+ // that actually keeps it to them). Anyone genuinely wanting the committed, team-wide variant can
225
+ // pass --scope project by hand, having decided that on purpose.
226
+ claudeScope: 'local',
227
+ installCommand: 'claude plugin install ruvnet-brain@ruvnet-brain --scope local',
228
+
229
+ differences: Object.freeze([
230
+ Object.freeze({
231
+ component: 'Plugin (hooks, skills, commands, search_ruvnet)',
232
+ consequence: 'Loads only in this directory. Your other projects are untouched and see nothing.',
233
+ verifiedFrom: 'installed_plugins.json records scope:"local" with projectPath',
234
+ }),
235
+ Object.freeze({
236
+ component: 'What it learns',
237
+ consequence: `Kept in ${PROJECT_STATE_DIR}/lessons.json inside this repo. Nothing learned here reaches your other work — and nothing learned elsewhere helps you here.`,
238
+ verifiedFrom: 'RUVNET_LESSON_STORE override, scripts/lesson-store.mjs:233',
239
+ }),
240
+ Object.freeze({
241
+ component: 'Your settings',
242
+ consequence: `Kept in ${PROJECT_STATE_DIR}/settings.json inside this repo, set per project.`,
243
+ verifiedFrom: 'RUVNET_SETTINGS_FILE override, scripts/user-settings.mjs:55',
244
+ }),
245
+ Object.freeze({
246
+ component: 'Updates',
247
+ consequence: 'Run per project. Each repo moves on its own schedule, which also means each repo can be forgotten on its own schedule.',
248
+ verifiedFrom: 'bin/install.mjs --update operates on the invoking install',
249
+ }),
250
+ ]),
251
+ }),
252
+ ]);
253
+
254
+ const BY_ID = new Map(SCOPES.map((s) => [s.id, s]));
255
+
256
+ /** Look up one option. Returns undefined rather than throwing — callers render, they do not crash. */
257
+ export function getScope(id) { return BY_ID.get(id); }
258
+
259
+ // ── Reading the machine as it actually is ────────────────────────────────────────────────────────
260
+
261
+ /**
262
+ * Measure the shared corpus, with a time budget and an honest `complete` flag.
263
+ *
264
+ * The measured tree is ~868 entries and multi-gigabyte; a full walk is not free, and a settings page
265
+ * that stalls for two seconds to print a number is a worse product than one that prints nothing. So
266
+ * the walk stops at `budgetMs` and SAYS it stopped — `{ bytes, complete: false }` renders as "at
267
+ * least 2.4 GB", never as a precise-looking lie.
268
+ *
269
+ * Returns `{ bytes: null, complete: false, exists: false }` when there is no corpus. That is a
270
+ * distinct state from "zero bytes" and callers must not collapse them: absent ≠ empty.
271
+ */
272
+ export function measureCorpus(dir = process.env.RUVNET_BRAIN_KB || USER_CORPUS, { budgetMs = 250 } = {}) {
273
+ if (!fs.existsSync(dir)) return { path: dir, exists: false, bytes: null, complete: false };
274
+ const deadline = Date.now() + budgetMs;
275
+ let bytes = 0;
276
+ let complete = true;
277
+ const stack = [dir];
278
+ while (stack.length) {
279
+ if (Date.now() > deadline) { complete = false; break; }
280
+ const cur = stack.pop();
281
+ let entries;
282
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
283
+ for (const e of entries) {
284
+ const p = path.join(cur, e.name);
285
+ if (e.isDirectory()) stack.push(p);
286
+ else if (e.isFile()) { try { bytes += fs.statSync(p).size; } catch { /* vanished mid-walk */ } }
287
+ }
288
+ }
289
+ return { path: dir, exists: true, bytes, complete };
290
+ }
291
+
292
+ /** Human bytes. Returns null for null so a caller can omit the number rather than print "0 B". */
293
+ export function formatBytes(bytes) {
294
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return null;
295
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
296
+ let n = bytes; let i = 0;
297
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
298
+ return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
299
+ }
300
+
301
+ /** How many projects Claude Code has seen. The honest denominator for "across all your projects". */
302
+ export function countKnownProjects(file = CLAUDE_JSON) {
303
+ try {
304
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
305
+ return j && typeof j.projects === 'object' && j.projects ? Object.keys(j.projects).length : null;
306
+ } catch { return null; }
307
+ }
308
+
309
+ function readJson(file) {
310
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
311
+ }
312
+
313
+ /**
314
+ * DETECT — what is on this machine right now, as evidence rather than as a verdict.
315
+ *
316
+ * `scope` is one of: 'user' | 'project' | 'both' | 'none' | 'unknown', and the last two are
317
+ * DIFFERENT AND MUST STAY DIFFERENT.
318
+ *
319
+ * none — we read the registry successfully and this plugin is genuinely not in it.
320
+ * unknown — we could not read the registry (absent, unreadable, corrupt, schema we don't know).
321
+ *
322
+ * Collapsing `unknown` into `none` is the exact class of bug this repo has a standing order about:
323
+ * an unreadable file would render as a confident "not installed", the user would install a second
324
+ * copy over a working one, and the surface that lied would look like it was working. Hence the
325
+ * separate `confident` flag, and hence the test that asserts an unreadable registry never reports
326
+ * 'none'.
327
+ *
328
+ * 'both' is not hypothetical. On this machine, `clangd-lsp@claude-plugins-official` and
329
+ * `skill-creator@claude-plugins-official` are each recorded at BOTH project and user scope —
330
+ * installed_plugins.json stores an ARRAY per plugin precisely because that is a legal state. A
331
+ * detector that returned the first record would have been quietly wrong for years.
332
+ */
333
+ export function detectCurrentScope({
334
+ projectDir = process.cwd(),
335
+ registry = INSTALLED_PLUGINS,
336
+ claudeJson = CLAUDE_JSON,
337
+ pluginId = PLUGIN_ID,
338
+ } = {}) {
339
+ const evidence = [];
340
+ const reg = readJson(registry);
341
+
342
+ if (reg === null || typeof reg !== 'object' || typeof reg.plugins !== 'object' || !reg.plugins) {
343
+ evidence.push({
344
+ source: 'plugin registry',
345
+ path: registry,
346
+ found: false,
347
+ detail: fs.existsSync(registry)
348
+ ? 'the file is there but could not be read as the expected shape — not treating that as "not installed"'
349
+ : 'no plugin registry on this machine yet',
350
+ });
351
+ return {
352
+ scope: 'unknown',
353
+ confident: false,
354
+ projectDir,
355
+ records: [],
356
+ envOverrides: readProjectEnvOverrides(projectDir).present,
357
+ evidence,
358
+ summary: 'Could not read how this is installed. Nothing has been assumed — and nothing will be changed until you say so.',
359
+ };
360
+ }
361
+
362
+ const records = Array.isArray(reg.plugins[pluginId]) ? reg.plugins[pluginId] : [];
363
+ const atUser = records.some((r) => r && r.scope === 'user');
364
+
365
+ // A local/project record only counts as THIS project's when its projectPath is this directory —
366
+ // otherwise a plugin scoped to a different repo would read as "installed here", which is how a
367
+ // detector starts telling people their setup is fine somewhere it is not present at all.
368
+ const here = (r) => !r || !r.projectPath || path.resolve(r.projectPath) === path.resolve(projectDir);
369
+ const atProject = records.some((r) => r && (r.scope === 'local' || r.scope === 'project') && here(r));
370
+
371
+ for (const r of records) {
372
+ evidence.push({
373
+ source: 'plugin registry',
374
+ path: registry,
375
+ found: true,
376
+ detail: `${pluginId} installed at scope "${r.scope}"${r.projectPath ? ` for ${r.projectPath}` : ''}${r.version ? ` (version ${r.version})` : ''}`,
377
+ });
378
+ }
379
+ if (!records.length) {
380
+ evidence.push({ source: 'plugin registry', path: registry, found: false, detail: `${pluginId} is not in the registry` });
381
+ }
382
+
383
+ const env = readProjectEnvOverrides(projectDir);
384
+ if (env.present.length) {
385
+ evidence.push({
386
+ source: 'project settings',
387
+ path: env.file,
388
+ found: true,
389
+ detail: `this project redirects ${env.present.join(' and ')} to its own directory`,
390
+ });
391
+ }
392
+
393
+ const cj = readJson(claudeJson);
394
+ if (cj && cj.projects && Object.hasOwn(cj.projects, path.resolve(projectDir))) {
395
+ const local = cj.projects[path.resolve(projectDir)]?.mcpServers;
396
+ if (local && Object.keys(local).length) {
397
+ evidence.push({
398
+ source: 'Claude Code local scope',
399
+ path: claudeJson,
400
+ found: true,
401
+ detail: `${Object.keys(local).length} MCP server(s) registered to this directory only`,
402
+ });
403
+ }
404
+ }
405
+
406
+ let scope = 'none';
407
+ if (atUser && atProject) scope = 'both';
408
+ else if (atUser) scope = 'user';
409
+ else if (atProject || env.present.length) scope = 'project';
410
+
411
+ const summary = {
412
+ user: 'Installed for you, across every project.',
413
+ project: 'Installed for this project only.',
414
+ both: 'Installed BOTH ways — for you globally AND pinned to this project. That is legal but usually accidental, and the project copy wins here.',
415
+ none: 'Not installed yet.',
416
+ }[scope];
417
+
418
+ return { scope, confident: true, projectDir, records, envOverrides: env.present, evidence, summary };
419
+ }
420
+
421
+ /** The project-scoped Claude Code settings file — the same one this repo already uses for its own hook. */
422
+ export function projectSettingsPath(projectDir = process.cwd()) {
423
+ return path.join(projectDir, '.claude', 'settings.json');
424
+ }
425
+
426
+ /** Which of OUR env keys this project currently overrides. Derived, never cached. */
427
+ export function readProjectEnvOverrides(projectDir = process.cwd()) {
428
+ const file = projectSettingsPath(projectDir);
429
+ const j = readJson(file);
430
+ const env = j && typeof j.env === 'object' && j.env ? j.env : {};
431
+ return { file, env, present: OWNED_ENV_KEYS.filter((k) => typeof env[k] === 'string' && env[k].length) };
432
+ }
433
+
434
+ // ── Changing it, reversibly ──────────────────────────────────────────────────────────────────────
435
+
436
+ function backupOf(file) {
437
+ // Millisecond stamps DO collide — measured elsewhere in this repo: six rapid saves produced five
438
+ // backups because one copy silently overwrote another. An undo history that drops a step without
439
+ // saying so is worse than none, so the name is made unique before the write and `wx` refuses to
440
+ // overwrite even if a racing writer slipped between the check and the write.
441
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
442
+ let b = `${file}.bak-${stamp}`;
443
+ for (let n = 2; fs.existsSync(b); n++) b = `${file}.bak-${stamp}-${String(n).padStart(2, '0')}`;
444
+ return b;
445
+ }
446
+
447
+ /**
448
+ * APPLY — write the chosen scope into config that already exists, without disturbing anything else.
449
+ *
450
+ * FOUR PROMISES, each of which is a test:
451
+ *
452
+ * 1. BACK UP FIRST, and refuse the write outright if the backup fails. A change you cannot undo is
453
+ * not a change, it is an overwrite, and the user cannot tell which one they got.
454
+ * 2. MERGE, NEVER CLOBBER. This file is read with JSON.parse and written back whole, so every key
455
+ * we do not own — this repo's own `RUFLO_HARNESS_LOOP` env var and its version-bump-gate hook
456
+ * are sitting in exactly this file — must survive untouched. Two Claude Code sessions genuinely
457
+ * do run against one machine at once; on 2026-07-12 that destroyed a checkpoint with a plain
458
+ * overwrite, no error, no evidence beyond a changed row id.
459
+ * 3. REVERSIBLE. The receipt carries everything revertScope() needs, including `existedBefore` so
460
+ * an undo of the first-ever write REMOVES the file rather than leaving a synthesised one.
461
+ * 4. SAY EXACTLY WHAT CHANGED. `changed[]` is derived by comparing before and after, not narrated
462
+ * by the function that intended the change — the intent and the outcome are allowed to differ,
463
+ * and when they do, the receipt must show the outcome.
464
+ *
465
+ * IDEMPOTENT BY CONSTRUCTION: if the merge produces bytes identical to what is on disk, nothing is
466
+ * written, no backup is taken, and the receipt says `changed: []`. Applying twice is not an error and
467
+ * does not litter the directory with identical backups.
468
+ *
469
+ * `dryRun` runs the whole computation and writes nothing, so a console can show the user the diff
470
+ * before asking. Read-only until you click, which is the promise the console already makes.
471
+ */
472
+ export function applyScope(scopeId, { projectDir = process.cwd(), dryRun = false } = {}) {
473
+ const scope = BY_ID.get(scopeId);
474
+ if (!scope) {
475
+ return { ok: false, scope: scopeId, changed: [], backup: null, log: `not a scope: ${JSON.stringify(scopeId)} — expected ${SCOPES.map((s) => s.id).join(' or ')}` };
476
+ }
477
+
478
+ const file = projectSettingsPath(projectDir);
479
+ const existedBefore = fs.existsSync(file);
480
+ const raw = existedBefore ? fs.readFileSync(file, 'utf8') : null;
481
+
482
+ let before;
483
+ if (raw === null) before = {};
484
+ else {
485
+ before = readJson(file);
486
+ if (before === null) {
487
+ // REFUSE. Unlike a settings file we own, this one belongs to Claude Code and may hold the
488
+ // user's hooks and permissions. Rewriting bytes we could not parse would discard them, and
489
+ // "we could not read it" is never a licence to replace it.
490
+ return { ok: false, scope: scopeId, changed: [], backup: null, log: `refusing to write — ${file} is not valid JSON; fix or move it first, nothing was changed` };
491
+ }
492
+ }
493
+
494
+ // Deep-enough clone: only `env` is touched, and it is one level of plain strings.
495
+ const after = { ...before, env: { ...(before.env && typeof before.env === 'object' ? before.env : {}) } };
496
+ const changed = [];
497
+
498
+ if (scope.id === 'project') {
499
+ const stateDir = path.join(projectDir, PROJECT_STATE_DIR);
500
+ const want = {
501
+ RUVNET_LESSON_STORE: path.join(stateDir, 'lessons.json'),
502
+ RUVNET_SETTINGS_FILE: path.join(stateDir, 'settings.json'),
503
+ };
504
+ for (const k of OWNED_ENV_KEYS) {
505
+ if (after.env[k] !== want[k]) {
506
+ changed.push({ key: k, from: after.env[k] ?? null, to: want[k] });
507
+ after.env[k] = want[k];
508
+ }
509
+ }
510
+ } else {
511
+ // Per-user is the ABSENCE of an override, not a competing value. Deleting the key lets the code
512
+ // fall through to its own default — one source of truth. Writing the global path in explicitly
513
+ // would pin this project to today's location and quietly break it if that location ever moves.
514
+ for (const k of OWNED_ENV_KEYS) {
515
+ if (Object.hasOwn(after.env, k)) {
516
+ changed.push({ key: k, from: after.env[k], to: null });
517
+ delete after.env[k];
518
+ }
519
+ }
520
+ }
521
+
522
+ // Do not leave an empty `env: {}` behind on a file we created — that is our litter, not their config.
523
+ if (!Object.keys(after.env).length && !(before.env && Object.keys(before.env).length)) delete after.env;
524
+
525
+ const body = `${JSON.stringify(after, null, 2)}\n`;
526
+ const noop = existedBefore && body === raw;
527
+
528
+ // The manual half, stated whether or not we wrote anything, because the user needs the whole
529
+ // picture to act. We do not run these: `claude plugin install` is a mutation we cannot back up or
530
+ // undo, and every promise above would be a lie the moment we shelled out to it.
531
+ const detected = detectCurrentScope({ projectDir });
532
+ const manualSteps = detected.scope === scope.id || detected.scope === 'both'
533
+ ? []
534
+ : [{ why: `register the plugin at ${scope.id} scope`, run: scope.installCommand }];
535
+
536
+ if (noop || (!changed.length && existedBefore)) {
537
+ return { ok: true, scope: scope.id, file, changed: [], backup: null, existedBefore, dryRun, manualSteps, log: `already set to ${scope.label.trim()} — nothing to change` };
538
+ }
539
+ if (dryRun) {
540
+ return { ok: true, scope: scope.id, file, changed, backup: null, existedBefore, dryRun: true, manualSteps, log: `would update ${file.replace(HOME, '~')} (${changed.length} change${changed.length === 1 ? '' : 's'}) — nothing written` };
541
+ }
542
+
543
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); }
544
+ catch (e) { return { ok: false, scope: scope.id, changed: [], backup: null, log: `refusing to write — could not create ${path.dirname(file)}: ${e.message}` }; }
545
+
546
+ let backup = null;
547
+ if (existedBefore) {
548
+ backup = backupOf(file);
549
+ // read-then-write rather than copyFileSync: under sustained concurrent saving copyFileSync
550
+ // WEDGED a process at 100% CPU for 4m38s with zero progress and never returned. A hung handler on
551
+ // a surface people are told to click is not survivable.
552
+ try { fs.writeFileSync(backup, raw, { flag: 'wx' }); }
553
+ catch (e) { return { ok: false, scope: scope.id, changed: [], backup: null, log: `refusing to write — backup failed: ${e.message}` }; }
554
+ }
555
+
556
+ const held = withLock(file, () => writeAtomic(file, body));
557
+ if (held.timedOut) {
558
+ return { ok: false, scope: scope.id, changed: [], backup, log: `another process is writing ${path.basename(file)} and did not finish in time — NOTHING was changed${backup ? `; your file is unchanged and a copy is at ${path.basename(backup)}` : ''}; try again` };
559
+ }
560
+
561
+ return {
562
+ ok: true,
563
+ scope: scope.id,
564
+ file,
565
+ changed,
566
+ backup,
567
+ existedBefore,
568
+ dryRun: false,
569
+ manualSteps,
570
+ log: [
571
+ `set to ${scope.label.trim()}`,
572
+ ...changed.map((c) => (c.to === null
573
+ ? ` removed ${c.key} (falls back to your user-level file)`
574
+ : ` ${c.from === null ? 'added' : 'changed'} ${c.key} → ${c.to.replace(HOME, '~')}`)),
575
+ backup ? ` previous file kept at ${path.basename(backup)}` : ' (this file did not exist before — reverting will remove it)',
576
+ ].join('\n'),
577
+ };
578
+ }
579
+
580
+ /**
581
+ * REVERT — the other half of applyScope's promise, and it must be able to fail loudly.
582
+ *
583
+ * Pass the receipt back. `existedBefore: false` means there was no file, so the honest undo is to
584
+ * REMOVE it rather than write a synthesised empty one — those are different states and
585
+ * detectCurrentScope reports them differently.
586
+ *
587
+ * The lock receipt is CHECKED. Elsewhere in this repo a revert discarded it and reported "restored"
588
+ * after writing nothing — measured: 5017ms elapsed, ok:true, file byte-for-byte unchanged. A failed
589
+ * undo the user is told succeeded is worse than no undo button at all.
590
+ */
591
+ export function revertScope({ file, backup = null, existedBefore = true } = {}) {
592
+ if (!file) return { ok: false, log: 'nothing to revert — no file was recorded in that receipt' };
593
+
594
+ if (!backup) {
595
+ if (existedBefore) return { ok: false, log: `no backup was taken for ${file} — cannot revert automatically` };
596
+ if (!fs.existsSync(file)) return { ok: true, log: 'nothing to revert — that file is already gone' };
597
+ try { fs.rmSync(file); return { ok: true, log: `removed ${file.replace(HOME, '~')} (there was no such file before)` }; }
598
+ catch (e) { return { ok: false, log: `could not remove ${file}: ${e.message}` }; }
599
+ }
600
+ if (!fs.existsSync(backup)) return { ok: false, log: `that backup is gone (${backup})` };
601
+
602
+ let held;
603
+ try { held = withLock(file, () => writeAtomic(file, fs.readFileSync(backup))); }
604
+ catch (e) { return { ok: false, log: `restore failed: ${e.message}` }; }
605
+ if (held.timedOut) return { ok: false, log: `another process is writing that file — NOTHING was restored and your backup at ${path.basename(backup)} is intact; try again` };
606
+ return { ok: true, restored: backup, log: `restored ${path.basename(file)} from ${path.basename(backup)}` };
607
+ }
608
+
609
+ // ── The text a human reads ───────────────────────────────────────────────────────────────────────
610
+
611
+ /**
612
+ * EXPLAIN — the whole conversation, as plain text, with every number derived at call time.
613
+ *
614
+ * "A core reason why you exist is to make murky and confusing things clear, tangible, accessible,
615
+ * and selectable. That has to apply to how we're implemented as well." (owner, 2026-07-22)
616
+ *
617
+ * Two rules hold this honest and both are tested:
618
+ *
619
+ * NO FABRICATED NUMBERS. Every figure comes from disk at the moment of the call. When a fact is
620
+ * unavailable the SENTENCE IS OMITTED — it never degrades into a plausible-looking default. That is
621
+ * why countKnownProjects returns null rather than 0, and why measureCorpus reports `complete` and
622
+ * the copy says "at least" when the walk was cut short.
623
+ *
624
+ * 'unknown' NEVER RENDERS AS 'off'. If we could not read how this is installed, the text says so
625
+ * in those words and offers no verdict. A confident wrong answer here sends someone to install a
626
+ * second copy over a working one.
627
+ */
628
+ export function explainChoice({ projectDir = process.cwd(), detected = null, facts = null } = {}) {
629
+ const state = detected ?? detectCurrentScope({ projectDir });
630
+ const f = facts ?? {
631
+ projects: countKnownProjects(),
632
+ corpus: measureCorpus(),
633
+ };
634
+ const L = [];
635
+
636
+ L.push('How would you like this installed?');
637
+ L.push('');
638
+
639
+ // ── Where you are now ──
640
+ if (!state.confident) {
641
+ // The unknown branch. No verdict, no guess, and explicitly no action taken.
642
+ L.push(`Right now: ${state.summary}`);
643
+ } else if (state.scope === 'none') {
644
+ L.push('Right now: not installed here yet.');
645
+ } else {
646
+ L.push(`Right now: ${state.summary}`);
647
+ }
648
+ L.push('');
649
+
650
+ // ── The two options ──
651
+ for (const s of SCOPES) {
652
+ L.push(`${s.label}${state.confident && state.scope === s.id ? ' ← this is what you have now' : ''}`);
653
+ L.push(` ${s.oneLine}`);
654
+ L.push('');
655
+ L.push(` ${s.summary}`);
656
+ L.push('');
657
+ L.push(` Why it matters: ${s.whyItMatters}`);
658
+ L.push(` The downside: ${s.downside}`);
659
+ L.push('');
660
+ L.push(' What it changes:');
661
+ for (const d of s.differences) L.push(` · ${d.component} — ${d.consequence}`);
662
+ L.push('');
663
+ }
664
+
665
+ // ── What you do NOT lose by going narrow ──
666
+ L.push('The same either way:');
667
+ for (const u of SHARED_EITHER_WAY) L.push(` · ${u.what} — ${u.detail}`);
668
+ L.push('');
669
+
670
+ // ── Derived numbers, each omitted entirely when unknown ──
671
+ const numbers = [];
672
+ if (typeof f.projects === 'number' && f.projects > 0) {
673
+ numbers.push(`Claude Code has seen ${f.projects} project${f.projects === 1 ? '' : 's'} on this machine — per-user covers all of them.`);
674
+ }
675
+ if (f.corpus && f.corpus.exists && typeof f.corpus.bytes === 'number') {
676
+ const size = formatBytes(f.corpus.bytes);
677
+ if (size) numbers.push(`The shared corpus is ${f.corpus.complete ? '' : 'at least '}${size}, read by every project. Neither choice duplicates it.`);
678
+ }
679
+ if (numbers.length) { L.push('On this machine:'); for (const n of numbers) L.push(` ${n}`); L.push(''); }
680
+
681
+ L.push(`Our strong recommendation is ${BY_ID.get(RECOMMENDED).label.replace(/\s*\(.*\)\s*/, '')} — but you are the arbiter of how things run on your machine.`);
682
+ L.push('Either way, this is backed up before anything is written, and reversible with one command.');
683
+
684
+ return L.join('\n');
685
+ }
686
+
687
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
688
+ // READ-ONLY unless you pass --apply, and --apply without a scope explains rather than guesses. A
689
+ // module about consent whose CLI mutated on a bare invocation would be arguing against itself.
690
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('install-scope.mjs');
691
+ if (invokedDirectly) {
692
+ const argv = process.argv.slice(2);
693
+ const wants = argv.find((a) => !a.startsWith('-'));
694
+ const apply = argv.includes('--apply');
695
+ const dryRun = argv.includes('--dry-run');
696
+
697
+ if (argv.includes('--json')) {
698
+ console.log(JSON.stringify({ detected: detectCurrentScope(), scopes: SCOPES, recommended: RECOMMENDED }, null, 2));
699
+ } else if (apply && wants) {
700
+ const r = applyScope(wants, { dryRun });
701
+ console.log(`\n${r.log}\n`);
702
+ for (const m of r.manualSteps ?? []) console.log(` still to do — ${m.why}:\n ${m.run}\n`);
703
+ if (!r.ok) process.exitCode = 1;
704
+ } else {
705
+ console.log(`\n${explainChoice()}\n`);
706
+ console.log(` To choose: node scripts/install-scope.mjs <${SCOPES.map((s) => s.id).join('|')}> --apply (add --dry-run to preview)\n`);
707
+ }
708
+ }