ruvnet-brain 4.0.12 → 4.0.28

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 (46) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +2 -2
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  6. package/plugin/scripts/anticipate.sh +80 -14
  7. package/plugin/scripts/capability-registry.mjs +994 -0
  8. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  9. package/plugin/scripts/continuation-gate.mjs +129 -1
  10. package/plugin/scripts/gates.mjs +146 -0
  11. package/plugin/scripts/goal-match.mjs +398 -0
  12. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  13. package/plugin/scripts/hook-registry.mjs +616 -0
  14. package/plugin/scripts/hook-shim.mjs +13 -2
  15. package/plugin/scripts/learning-enable.mjs +382 -0
  16. package/plugin/scripts/lesson-promote.mjs +262 -0
  17. package/plugin/scripts/lesson-provenance.mjs +43 -0
  18. package/plugin/scripts/lesson-store.mjs +67 -56
  19. package/plugin/scripts/memory-doctor.mjs +345 -0
  20. package/plugin/scripts/nightly-controller.mjs +98 -0
  21. package/plugin/scripts/runtime-preferences.mjs +18 -0
  22. package/plugin/scripts/session-start-core.mjs +3 -3
  23. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  24. package/plugin/scripts/user-settings.mjs +672 -0
  25. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  26. package/scripts/advocacy-outcomes.mjs +4 -808
  27. package/scripts/capability-registry.mjs +4 -876
  28. package/scripts/corpus-qa.mjs +44 -6
  29. package/scripts/doc-currency.mjs +30 -2
  30. package/scripts/gates.mjs +4 -146
  31. package/scripts/goal-match.mjs +4 -398
  32. package/scripts/hook-registry.mjs +4 -567
  33. package/scripts/issue-watch.mjs +108 -0
  34. package/scripts/learning-enable.mjs +4 -380
  35. package/scripts/lesson-promote.mjs +4 -262
  36. package/scripts/memory-doctor.mjs +4 -345
  37. package/scripts/nightly-controller.mjs +4 -66
  38. package/scripts/nightly-wrapper.sh +23 -1
  39. package/scripts/proactivity-metrics.mjs +8 -1
  40. package/scripts/qe/ux-suite.mjs +72 -1
  41. package/scripts/release-abort-stale.mjs +111 -0
  42. package/scripts/release-convergence-watchdog.mjs +119 -0
  43. package/scripts/release-transaction-provider.mjs +61 -7
  44. package/scripts/release-transaction.mjs +63 -17
  45. package/scripts/self-update.mjs +63 -10
  46. package/scripts/user-settings.mjs +4 -640
@@ -1,640 +1,4 @@
1
- // user-settings.mjs — how THIS user wants the brain to behave, stored where an update cannot eat it.
2
- //
3
- // THE ONE IDEA. Everybody uses this differently: some people want it to learn across every repo they
4
- // own, some want it to forget the moment they leave the directory, some want it to act, most want it
5
- // to shut up unless something is actually wrong. None of those are wrong, so none of them can be
6
- // hardcoded — but "make it configurable" is where products usually go murky, because a settings model
7
- // with vague labels and optimistic defaults is worse than no settings at all: the user believes they
8
- // are in control while the machine does something else.
9
- //
10
- // So this file holds three invariants, and each exists because of a specific way this project has
11
- // already been burned:
12
- //
13
- // 1. NOTHING THAT ACTS BEYOND THE CURRENT PROJECT MAY DEFAULT TO ON. Encoded as `escalates` per
14
- // entry — a machine-checkable list of the values that write outside the directory you invoked us
15
- // in. The default is asserted, by test, never to be one of them. This is the same move as
16
- // lesson-store.makeLesson() and console-engine.makeRecommendation(): put the invariant in the
17
- // type, not in a reviewer's memory, because a reviewer forgets and a constructor does not.
18
- //
19
- // 2. EVERY SETTING STATES ITS DOWNSIDE. `whyItMatters` explains the tradeoff and `downside` names
20
- // the concrete cost of turning it up — required fields, both. A settings page that lists only
21
- // benefits is a sales page, and it makes the safe choice feel like the timid one. If we cannot
22
- // articulate what turning something on costs the user, we have not understood it well enough to
23
- // offer it.
24
- //
25
- // 3. A SETTING DESTROYED BY AN UPDATE IS NOT A SETTING. Hence the storage location below, which is
26
- // a checked fact rather than a hopeful one — see STORE_PATH.
27
- //
28
- // WHAT THIS FILE DELIBERATELY DOES NOT DO. It does not execute anything. It records intent and hands
29
- // back a validated object. Whether a given intent is HONOURED is the caller's job to prove, and per
30
- // house rule 2 a console must not render one of these as a live toggle until it has a real executor
31
- // and a real undo behind it — otherwise we have built a light switch wired to nothing, which is the
32
- // most expensive kind of lie because the user stops looking for the real problem.
33
-
34
- import fs from 'node:fs';
35
- import os from 'node:os';
36
- import path from 'node:path';
37
-
38
- const HOME = os.homedir();
39
-
40
- /**
41
- * WHERE IT LIVES, and why this exact directory — verified against bin/install.mjs, not assumed.
42
- *
43
- * `--update` extracts a fresh bundle and overwrites the cache directory entry-by-entry with
44
- * `fs.rmSync(to, { recursive: true, force: true })` (install.mjs:410); `--uninstall` rmSync's the
45
- * whole kb dir (install.mjs:1526). Anything under ~/.cache/ruvnet-brain is therefore transient by
46
- * design. Grepping install.mjs for `.config/ruvnet-brain` returns ZERO hits — the installer has no
47
- * code path that reads, writes, or deletes here at all.
48
- *
49
- * That is the entire argument for this path: not "it feels more permanent", but "the only program
50
- * that deletes things cannot see it". Same reasoning, same directory, as lesson-store.STORE_PATH —
51
- * a preference that does not survive the next release never compounds, and compounding is the point.
52
- *
53
- * Env override matches the RUVNET_LESSON_STORE idiom so tests never touch the real user's file.
54
- */
55
- export const STORE_PATH = process.env.RUVNET_SETTINGS_FILE
56
- || path.join(HOME, '.config', 'ruvnet-brain', 'settings.json');
57
-
58
- /** Bumped only when the on-disk shape changes incompatibly. Unknown/newer versions degrade to defaults. */
59
- export const SETTINGS_VERSION = 1;
60
-
61
- /**
62
- * THE SCHEMA.
63
- *
64
- * `escalates` is the load-bearing field and the reason this is a data structure rather than four
65
- * `if` statements: it lists the values of this setting that cause work OUTSIDE the project you are
66
- * standing in. A value in `escalates` may be chosen, but may never be the default, and a test asserts
67
- * exactly that. It also gives the console something honest to render — "this one reaches past this
68
- * repo" is the sentence a user actually needs before clicking.
69
- *
70
- * `type` reuses the vocabulary already in onboarding-console.mjs CONFIG_SCHEMA ('enum' | 'bool') so a
71
- * single renderer can handle both schemas without a translation layer.
72
- *
73
- * `options` for enums are ordered LEAST active → MOST active. That ordering is not cosmetic: it is
74
- * what makes "conservative" mean something checkable rather than a claim in a comment.
75
- */
76
- export const SETTINGS_SCHEMA = Object.freeze([
77
- Object.freeze({
78
- key: 'brainEnabled',
79
- label: 'Is the brain switched on',
80
- type: 'bool',
81
- default: true,
82
- // NOT an escalation in either direction. `escalates` lists values that cause work OUTSIDE the
83
- // project you are standing in, and this setting only ever REMOVES work: false stops retrieval,
84
- // stops the advocacy hooks and stops the learning capture. true is the shipped state every
85
- // existing install is already in, so it cannot be the value that "reaches past this repo".
86
- escalates: Object.freeze([]),
87
- help: 'Whether the brain is working at all — retrieval, grounding hooks and everything it volunteers.',
88
- // ─────────────────────────────────────────────────────────────────────────────────────────────
89
- // THIS KEY IS A MIRROR, NOT THE SWITCH (ADR-054 §2). Read scripts/brain-state.mjs before
90
- // wiring anything to it.
91
- //
92
- // The enforcement artifact is the sentinel file ~/.config/ruvnet-brain/brain-off. This entry
93
- // exists so the choice is VISIBLE where a user goes looking for their choices, and so the
94
- // console has something to render — but nothing enforces off by reading this value, and nothing
95
- // ever should. The reason is mechanical and was measured, not theorised: validate() below DROPS
96
- // unknown keys, so any older release that saves ANY setting deletes this one and the machine
97
- // silently comes back on. tests/unit/brain-off.test.mjs reproduces that exact sequence and
98
- // asserts the sentinel survives it.
99
- //
100
- // Consequence for callers: when this value and the sentinel disagree, the SENTINEL is in force.
101
- // brain-state.disagreement() returns the fact so a surface can show it rather than pick a side.
102
- // ─────────────────────────────────────────────────────────────────────────────────────────────
103
- whyItMatters: 'On, the brain retrieves from rUv\'s real source before answering, and its hooks watch the write path. Off, it stops retrieving, stops volunteering, and stops learning from your work — the machine is quiet and answers about the RuvNet stack come from the model\'s own memory instead of from source. The switch is a file, so it survives updates and both states are readable by every part of the product at once.',
104
- downside: 'Off, answers about rUv\'s ecosystem are no longer grounded in his source and nothing warns you when they drift, the write-path grounding gate stops enforcing, and nothing is learned from this or any later session until you switch it back on. Updates and health alarms keep running while it is off, which the console states plainly rather than hiding.',
105
- }),
106
-
107
- Object.freeze({
108
- key: 'brainProfile',
109
- label: 'How much of the brain is installed',
110
- type: 'enum',
111
- options: Object.freeze(['complete', 'ruvector']),
112
- default: 'complete',
113
- escalates: Object.freeze([]),
114
- help: 'Complete Brain searches every installed public rUv repository. RuVector Only keeps and searches only RuVector.',
115
- whyItMatters: 'RuVector Only uses substantially less disk and searches a much smaller corpus. Complete Brain can answer cross-repository questions and find supporting evidence outside RuVector.',
116
- downside: 'RuVector Only deliberately removes the other repository stores, so answers cannot cite supporting evidence that lives in Ruflo, AgentDB, RuView, meeting notes, or another rUv repository. Switching back restores them from the complete bundle.',
117
- }),
118
-
119
- Object.freeze({
120
- key: 'learningScope',
121
- label: 'What it learns from',
122
- type: 'enum',
123
- options: Object.freeze(['off', 'project', 'user']),
124
- default: 'project',
125
- escalates: Object.freeze(['user']),
126
- help: 'Whether what it learns stays in this project, compounds across every project you own, or is not kept at all.',
127
- // DEFAULT = 'project', and this is the one default worth defending at length, because 'off' is
128
- // technically more conservative and I did not choose it.
129
- //
130
- // The rule is "nothing that changes the MACHINE defaults to on". 'project' writes only into the
131
- // directory you deliberately invoked us in — the same place your source, your .git and your
132
- // node_modules already live. It changes nothing outside your own repo, so it does not engage the
133
- // rule. 'user' does: a lesson learned in one client's repo would surface while you work in
134
- // another's, which is a data-flow decision only the user can make, so it is opt-in.
135
- //
136
- // Defaulting the whole thing to 'off' would be conservatism theatre: memory would be silently
137
- // absent, the console would truthfully report an empty store, and the user would conclude the
138
- // product does not work rather than that they never switched it on.
139
- whyItMatters: 'Project scope keeps everything it learns inside this repo, which is the narrowest setting where the thing still works at all. User scope is where it gets genuinely useful — a mistake corrected once is never repeated anywhere — but it means notes taken while working on one codebase can surface while you are working on a different one.',
140
- downside: 'On "user", context crosses between unrelated projects: a client repo can teach the model something that shows up while you work for a different client. On "off", nothing is remembered between sessions and you will re-explain the same preferences indefinitely.',
141
- }),
142
-
143
- Object.freeze({
144
- key: 'advocacy',
145
- label: 'How much it jumps in',
146
- type: 'enum',
147
- options: Object.freeze([1, 2, 3, 4, 5]),
148
- default: 3,
149
- escalates: Object.freeze([]), // speech only — no value of this setting writes anything anywhere
150
- // 1–5 DIAL (ADR-052, owner 2026-07-25: "a setting from 1 to 5 on how aggressive you want it to be
151
- // to support you"). The runtime — plugin/scripts/unprompted-runtime.mjs, the SINGLE enforcement
152
- // chokepoint (DDD-0004) — maps a level to {advocacy channel on/off, promotion channel on/off,
153
- // severity floor}. No producer decides. DEFAULT = 3 (Balanced): advocacy on for relevant findings,
154
- // promotion off — byte-identical to the old 'important-only' default, so nobody's behaviour changes
155
- // on upgrade.
156
- //
157
- // LEGACY MIGRATION: pre-dial string values map to their nearest level rather than being rejected
158
- // and silently reset to default (which would lose a real choice). off→1, important-only→3, all→4.
159
- legacy: Object.freeze({ 'off': 1, 'important-only': 3, 'all': 4 }),
160
- // Each level, named + what it ACTUALLY changes (kept honest against the runtime's LEVEL_POLICY; if
161
- // that map changes, this copy changes with it). Levels 4–5 additionally enable the promotion
162
- // channel — offers to promote lessons learned across your projects — which is also what lets the
163
- // outcome ledger fill from users who opted into more help (ADR-052).
164
- levels: Object.freeze({
165
- 1: { name: 'Only when I ask', what: 'Nothing unprompted — the brain speaks only when you open the console or run a command. Genuine failure alarms still reach you.' },
166
- 2: { name: 'Critical only', what: 'Unprompted only for high-severity findings (a corrupt store, a failing job). No routine suggestions, no lesson-promotion nudges.' },
167
- 3: { name: 'Balanced', what: 'High-confidence suggestions relevant to what you are doing, plus anything critical. No lesson-promotion nudges. The recommended default.' },
168
- 4: { name: 'Proactive', what: 'Everything in Balanced, plus offers to promote lessons it has learned across your projects to your global brain.' },
169
- 5: { name: 'Maximum help', what: 'The most forward: it also surfaces more loosely-relevant capabilities and optimizations. Best when you want it to teach you the stack.' },
170
- }),
171
- help: 'How much the brain jumps in unprompted, on a 1–5 dial (3 = Balanced, the default). It governs unsolicited capability advocacy and lesson-promotion nudges; genuine failure alarms are never gated by it, and named lessons carry their own controls.',
172
- // This dial governs the advocacy + promotion channels, enforced centrally in the runtime. It is NOT
173
- // a master mute: named lessons (ratification + blocking-optin.json + RUVNET_LESSON_MAX_SHOWS) and
174
- // the Markdown grounding stamp (RUVNET_MD_STAMP) are separate channels with their own controls, and
175
- // a genuine failure alarm bypasses the dial at every level by design.
176
- whyItMatters: '1–5, from "only when I ask" to "maximum help". You set how forward the brain is; it records whether you act on what it offers, so it can prove it is helping rather than nagging. Higher levels offer more (and generate more of that feedback); lower levels stay quiet. Failure alarms reach you at every level, and named lessons are a separate channel with its own controls.',
177
- downside: 'At 1 the brain never volunteers anything (alarms aside) — help only when you ask. At 2 it offers only high-severity findings; at 3 (default) also the clearly-relevant ones; at 4–5 it additionally nudges you to promote learned lessons and surfaces more optional capabilities. If a level feels too forward, dial it down — the change takes effect immediately, machine-wide. There is no level at which it hides a genuine failure, and this dial never touches named lessons or the md-stamp, which have their own controls.',
178
- }),
179
-
180
- Object.freeze({
181
- key: 'autoApply',
182
- label: 'May it act on its own',
183
- type: 'bool',
184
- default: false,
185
- escalates: Object.freeze([true]),
186
- help: 'Whether it may apply a fix itself, or must always stop and ask you first.',
187
- // DEFAULT = false, and this is the least negotiable default in the file. The executors this would
188
- // unlock are real machine changes — reindexing stores, flushing and training on learning data,
189
- // rewriting settings files. Every one of them is reversible today, which is exactly why it is
190
- // tempting to default it on, and exactly the wrong reason: "we can undo it" is not consent.
191
- //
192
- // The user must be able to describe their machine without reading a changelog. Anything that
193
- // edits it while they are not looking breaks that, no matter how good the edit was.
194
- whyItMatters: 'Off, it can only ever propose — you read the change and click it yourself, so the machine is never modified without you present. On, routine fixes stop needing your attention, which matters if you are running long sessions unattended.',
195
- downside: 'On, your machine changes while you are not watching. Each change is backed up and reversible, but you will find your setup different from how you left it and have to read a log to learn why.',
196
- }),
197
-
198
- Object.freeze({
199
- key: 'newProjectDefaults',
200
- label: 'Apply these choices to new projects',
201
- type: 'bool',
202
- default: false,
203
- escalates: Object.freeze([true]),
204
- help: 'Whether these same answers are reused automatically the next time you open a project that has never been set up.',
205
- // DEFAULT = false. Turning this on means writing configuration into directories the user has not
206
- // opened yet and never opted in for — a mutation whose blast radius is "every repo I touch from
207
- // now on". That is precisely the shape of change that must be chosen out loud.
208
- //
209
- // Note the honest asymmetry: enabling this is a convenience for someone who has already decided
210
- // how they like to work. It is a trap for someone still deciding, because a preference set once,
211
- // early, silently becomes the policy for everything they do afterwards.
212
- whyItMatters: 'Off, every new project starts neutral and asks you once. On, you answer these questions a single time and every future project inherits them, which is the difference between setting this up once and setting it up forty times.',
213
- downside: 'On, a choice you made early — possibly before you understood it — is silently applied to projects you have not created yet, including ones where it is the wrong choice. You will not be asked again, so a bad default propagates quietly.',
214
- }),
215
- ]);
216
-
217
- const BY_KEY = new Map(SETTINGS_SCHEMA.map((s) => [s.key, s]));
218
-
219
- /** The shipped answer for every key. Callers get a fresh object — the schema itself stays frozen. */
220
- export function defaults() {
221
- return Object.fromEntries(SETTINGS_SCHEMA.map((s) => [s.key, s.default]));
222
- }
223
-
224
- /** Does this value reach outside the project you are standing in? The question a console must render. */
225
- export function escalatesBeyondProject(key, value) {
226
- const entry = BY_KEY.get(key);
227
- return entry ? entry.escalates.includes(value) : false;
228
- }
229
-
230
- /**
231
- * VALIDATE — total, never throws, and always returns a COMPLETE settings object.
232
- *
233
- * Total on purpose. This reads a file the user is explicitly invited to hand-edit (that is the point
234
- * of storing it as readable JSON rather than a database), so malformed input is an expected state,
235
- * not an exceptional one. The lesson-store precedent applies: re-validate on read, drop what cannot
236
- * be honoured, keep the rest usable — a single bad key must not cost the user their other answers.
237
- *
238
- * What it will NOT do is guess. An unrecognised value falls back to that key's default and says so in
239
- * `errors`, rather than being coerced into whatever is nearest. Silently reinterpreting a user's
240
- * stated preference is worse than ignoring it, because they have no way to notice.
241
- */
242
- export function validate(input) {
243
- const values = defaults();
244
- const errors = [];
245
- const warnings = [];
246
-
247
- if (input === null || typeof input !== 'object' || Array.isArray(input)) {
248
- if (input !== undefined) errors.push({ key: null, reason: 'settings must be a JSON object; using defaults for everything' });
249
- return { ok: false, values, errors, warnings };
250
- }
251
-
252
- for (const [key, raw] of Object.entries(input)) {
253
- const entry = BY_KEY.get(key);
254
- if (!entry) {
255
- // Dropped, not preserved. Carrying unknown keys forward would let a typo'd setting live in the
256
- // file forever looking like it does something.
257
- warnings.push({ key, reason: 'not a known setting — ignored' });
258
- continue;
259
- }
260
- if (entry.type === 'bool') {
261
- if (typeof raw !== 'boolean') { errors.push({ key, reason: `expected true or false, got ${JSON.stringify(raw)} — using the default (${entry.default})` }); continue; }
262
- values[key] = raw;
263
- } else if (entry.type === 'enum') {
264
- // Legacy-value migration: a pre-rename stored value maps to its current equivalent (entry.legacy)
265
- // rather than being rejected and silently reset to the default — which would lose the user's real
266
- // choice. Only defined for settings that were renamed (advocacy: off→1, important-only→3, all→4).
267
- let v = raw;
268
- if (entry.legacy && Object.prototype.hasOwnProperty.call(entry.legacy, raw)) v = entry.legacy[raw];
269
- if (!entry.options.includes(v)) { errors.push({ key, reason: `expected one of ${entry.options.join(' | ')}, got ${JSON.stringify(raw)} — using the default (${entry.default})` }); continue; }
270
- values[key] = v;
271
- }
272
- }
273
- return { ok: errors.length === 0, values, errors, warnings };
274
- }
275
-
276
- /**
277
- * LOAD — returns an envelope, not a bare object, because the caller has to be able to tell the
278
- * difference between "the user chose the defaults" and "we could not read their choices".
279
- *
280
- * House rule 1 in a function signature: a console that renders these must be able to say which of
281
- * those two it is looking at. Collapsing them into one plain object would force it to present a
282
- * corrupt file as a deliberate configuration, which is exactly the class of fabricated status this
283
- * repo has a standing order against.
284
- */
285
- export function loadSettings(file = STORE_PATH) {
286
- // `fromFuture` is a NARROW flag and its narrowness is the entire design. It marks the one state in
287
- // which saving would DESTROY RECOVERABLE DATA, and nothing else:
288
- //
289
- // fromFuture = the file is perfectly valid and intact; we simply do not understand its schema
290
- // because a NEWER build wrote it. Their real choices are sitting there, readable by
291
- // the version that made them. Overwriting = deleting live data. → REFUSE.
292
- // corrupt = the bytes are not JSON. Their choices are already GONE; there is nothing left to
293
- // protect. Refusing here would strand the user forever with a broken file and no way
294
- // to fix it from the console. → RECOVER: back the wreckage up and write a good file.
295
- // !healthy = we read their choices fine and one value was invalid. Entirely normal. → PROCEED.
296
- //
297
- // The tempting version of this fix was one flag for "could not read it" covering both corrupt and
298
- // fromFuture. That is wrong, and its own test caught it: recovery from a truncated write is a
299
- // CONTRACT here, and a blanket refusal breaks the only path out of it. "Can we still recover their
300
- // intent from this file?" is the question — not "can we parse it?"
301
- const envelope = { path: file, exists: false, healthy: true, fromFuture: false, values: defaults(), errors: [], warnings: [] };
302
- let raw;
303
- try { raw = fs.readFileSync(file, 'utf8'); }
304
- catch { return envelope; } // no file yet is the NORMAL state, not a fault — empty-first, house rule 3
305
-
306
- envelope.exists = true;
307
- let parsed;
308
- try { parsed = JSON.parse(raw); }
309
- catch (e) {
310
- // Degrade, do not throw. A truncated write (full disk, killed process) must not make every
311
- // surface that reads settings explode; it must make them fall back and say why.
312
- // NOT fromFuture: these bytes are unrecoverable, so there is nothing here for a refusal to
313
- // protect. saveSettings backs the wreckage up and writes a clean file — recovery, not overwrite.
314
- envelope.healthy = false;
315
- envelope.errors.push({ key: null, reason: `settings file is not valid JSON (${e.message}) — using defaults` });
316
- return envelope;
317
- }
318
-
319
- if (parsed && typeof parsed === 'object' && typeof parsed.version === 'number' && parsed.version > SETTINGS_VERSION) {
320
- // Written by a newer version than this code understands. Refuse to reinterpret it — an older
321
- // reader guessing at a newer schema is how settings get silently downgraded on the next save.
322
- envelope.healthy = false;
323
- envelope.fromFuture = true;
324
- envelope.errors.push({ key: null, reason: `settings were written by a newer version (v${parsed.version} > v${SETTINGS_VERSION}) — using defaults rather than misreading them` });
325
- return envelope;
326
- }
327
-
328
- const result = validate(parsed && typeof parsed === 'object' ? parsed.settings : undefined);
329
- envelope.values = result.values;
330
- envelope.errors = result.errors;
331
- envelope.warnings = result.warnings;
332
- envelope.healthy = result.ok;
333
- return envelope;
334
- }
335
-
336
- /**
337
- * MUTUAL EXCLUSION around read-modify-write, because the comment that used to sit on saveSettings
338
- * claimed read-modify-write had solved the 2026-07-12 concurrent-clobber and it had not — it only
339
- * narrowed the window. MEASURED: four writers each setting a different key, released simultaneously,
340
- * lost at least one setting in 19 of 20 trials. Every writer returned `ok: true`. No error, no
341
- * warning, no evidence — the user clicks four toggles and two of them quietly do not stick, which is
342
- * the same silent-loss shape as the checkpoint clobber, on the surface whose entire job is to record
343
- * what the user wants.
344
- *
345
- * `open(…, 'wx')` is the primitive: exclusive creation is atomic on POSIX and on Windows, and it
346
- * needs no dependency. The alternative — a lockfile package — is not available to a script that must
347
- * run from a bare install.
348
- *
349
- * STALE LOCKS ARE BROKEN, DELIBERATELY. A process killed mid-save leaves the lock file behind, and a
350
- * guard that then wedges every future save forever is worse than the race it prevents. This repo has
351
- * already retired two defensive wrappers (`-readonly`, `timeout`) for causing exactly the failures
352
- * they were meant to guard against; a lock with no stale path would be the third. So the lock records
353
- * its pid and mtime, and any lock older than STALE_LOCK_MS is taken over.
354
- */
355
- export const LOCK_WAIT_MS = 5000; // total time a writer will queue before giving up and saying so
356
- const STALE_LOCK_MS = 30_000; // older than this and the holder is presumed dead, not slow
357
-
358
- /** Sleep without async. Atomics.wait on a throwaway buffer is the only dependency-free sync sleep. */
359
- function sleepSync(ms) {
360
- try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
361
- catch { /* SharedArrayBuffer unavailable — spin the remaining wait out rather than fail the save */ }
362
- }
363
-
364
- // EXPORTED because the console's own writer needs them. These were written, tested and hardened here
365
- // while `/api/save-config` — the only writer any user can actually reach — kept using a truncating
366
- // writeFileSync with no lock and no validation. Write-safety that lives in a module nothing calls is
367
- // not write-safety; it is a test suite. See saveConfig in onboarding-console.mjs.
368
- export function withLock(file, fn) {
369
- const lock = `${file}.lock`;
370
- const deadline = Date.now() + LOCK_WAIT_MS;
371
- let fd = null;
372
-
373
- for (;;) {
374
- try { fd = fs.openSync(lock, 'wx'); break; }
375
- catch (e) {
376
- // A filesystem error (EACCES on a read-only directory, EROFS, ENOSPC) is NOT contention, and
377
- // must not be thrown out of a function whose callers are all documented to return a receipt
378
- // rather than raise. It broke the read-only-directory test by turning a clean "refusing to
379
- // write — backup failed" into an EACCES stack trace out of saveSettings.
380
- //
381
- // Proceeding unlocked is safe here, and provably so rather than hopefully: the lock lives in
382
- // the SAME directory as the settings file, so a directory that refuses a new lock entry will
383
- // equally refuse the backup and the temp file. The real operation fails a moment later with the
384
- // accurate message about what actually could not be done. Better a truthful error from the step
385
- // that matters than an accurate-but-obscure one about a lock the user never asked for.
386
- if (e.code !== 'EEXIST') return { ok: true, unlocked: true, value: fn() };
387
- let age = 0;
388
- try { age = Date.now() - fs.statSync(lock).mtimeMs; }
389
- catch { continue; } // vanished between open and stat — the holder just released it; retry
390
- if (age > STALE_LOCK_MS) {
391
- // Presumed-dead holder. Unlink and retry rather than write through the lock, so two
392
- // simultaneous stale-breakers still serialise on the next exclusive create.
393
- try { fs.unlinkSync(lock); } catch { /* someone else won the race to clear it — fine */ }
394
- continue;
395
- }
396
- if (Date.now() > deadline) {
397
- return { ok: false, timedOut: true, heldMs: age };
398
- }
399
- sleepSync(25);
400
- }
401
- }
402
-
403
- try {
404
- try { fs.writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`); } catch { /* advisory only */ }
405
- return { ok: true, value: fn() };
406
- } finally {
407
- try { fs.closeSync(fd); } catch { /* already closed */ }
408
- try { fs.unlinkSync(lock); } catch { /* already cleared by a stale-breaker */ }
409
- }
410
- }
411
-
412
- /**
413
- * Write a file so that no reader — and no crash — can ever observe a half-written one.
414
- *
415
- * temp-in-the-same-directory + rename() is atomic on POSIX, so the settings file is either entirely
416
- * the old content or entirely the new one. The previous code wrote in place with writeFileSync,
417
- * which truncates first: a process killed between truncate and write leaves an empty settings file
418
- * and the user's answers are gone with no backup step having failed.
419
- *
420
- * The temp name carries the pid so two concurrent writers cannot collide on it even in the moment
421
- * before one of them takes the lock.
422
- */
423
- export function writeAtomic(file, body) {
424
- const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
425
- let fd;
426
- try {
427
- fd = fs.openSync(tmp, 'wx');
428
- fs.writeSync(fd, body);
429
- // fsync before rename: rename is atomic with respect to readers, but without the flush a power
430
- // loss can land the rename while the content is still in the page cache — an atomically-renamed
431
- // empty file, which is precisely the outcome the rename was chosen to prevent.
432
- try { fs.fsyncSync(fd); } catch { /* filesystem without fsync (some network mounts) — proceed */ }
433
- fs.closeSync(fd);
434
- fd = null;
435
- fs.renameSync(tmp, file);
436
- } finally {
437
- if (fd !== null) { try { fs.closeSync(fd); } catch { /* nothing to close */ } }
438
- if (fs.existsSync(tmp)) { try { fs.unlinkSync(tmp); } catch { /* leave the temp rather than throw over it */ } }
439
- }
440
- }
441
-
442
- /** Backups this module has taken, newest last. The undo history, derived from disk rather than claimed. */
443
- export function listBackups(file = STORE_PATH) {
444
- const dir = path.dirname(file);
445
- const prefix = `${path.basename(file)}.bak-`;
446
- try {
447
- return fs.readdirSync(dir).filter((n) => n.startsWith(prefix)).sort().map((n) => path.join(dir, n));
448
- } catch { return []; }
449
- }
450
-
451
- /**
452
- * SAVE — backup first, merge, then write. Every clause here is a real failure, not defensiveness.
453
- *
454
- * BACKUP BEFORE WRITE, and refuse the write outright if the backup fails. A save that cannot be
455
- * undone is not a save, it is an overwrite, and the user has no way to know which one they got.
456
- *
457
- * READ-MODIFY-WRITE rather than replace-wholesale, because two Claude Code sessions genuinely do run
458
- * against one machine at once. That exact scenario destroyed a checkpoint on 2026-07-12: a second
459
- * session's plain overwrite wiped the first session's state with no error and no evidence beyond a
460
- * changed row id. A partial patch here therefore updates the keys it names and leaves every other
461
- * answer standing.
462
- *
463
- * The `patch` is validated as a WHOLE-object merge against what is already on disk, so an invalid
464
- * incoming value falls back to the stored answer's replacement rather than to the shipped default —
465
- * a bad click must not quietly reset the three settings the user got right.
466
- */
467
- export function saveSettings(patch, { file = STORE_PATH } = {}) {
468
- // The directory must exist before the lock can be created in it — the lock lives beside the file.
469
- try { fs.mkdirSync(path.dirname(file), { recursive: true }); }
470
- catch (e) { return { ok: false, backup: null, values: defaults(), log: `refusing to write — could not create ${path.dirname(file)}: ${e.message}` }; }
471
-
472
- // EVERYTHING from here is under the lock, and `before` is re-read INSIDE it. Reading before
473
- // acquiring would reintroduce the exact race the lock exists to close: two writers could both read
474
- // the same pre-state, both merge onto it, and the second rename would erase the first's key.
475
- const held = withLock(file, () => saveLocked(patch, file));
476
- if (held.timedOut) {
477
- return {
478
- ok: false,
479
- backup: null,
480
- values: loadSettings(file).values,
481
- log: `another process is saving these settings and did not finish within ${LOCK_WAIT_MS}ms (lock held ${Math.round(held.heldMs)}ms) — nothing was written; try again`,
482
- };
483
- }
484
- return held.value;
485
- }
486
-
487
- function saveLocked(patch, file) {
488
- const before = loadSettings(file);
489
-
490
- // REFUSE rather than silently downgrade. loadSettings already decided it could not interpret these
491
- // choices and said so in its own comment — "an older reader guessing at a newer schema is how
492
- // settings get silently downgraded on the next save" — and then the next save did precisely that.
493
- // MEASURED: a v2 file holding six deliberate choices, saved once by this v1 code after toggling one
494
- // unrelated key, came back as v1 with four keys reset to defaults and two deleted outright, and the
495
- // receipt said "saved; previous settings kept at …bak-…". A clean-looking success that destroyed
496
- // three explicit decisions is worse than any error message.
497
- //
498
- // ONLY the from-the-future case. A corrupt file deliberately falls through to the recovery path
499
- // below: there is no live data left in it to protect, and a refusal there would leave the user with
500
- // a broken settings file and no way to repair it from the console — a guard that traps the person
501
- // it was written for. See the flag's definition in loadSettings.
502
- //
503
- // Note what is NOT done here: unknown keys are still dropped by validate(), by the deliberate
504
- // decision documented there. Preserving them would be a second, weaker answer to this problem —
505
- // refusing the write protects a newer file completely, whereas preserving keys would still rewrite
506
- // its version stamp and re-interpret the keys we think we recognise.
507
- if (before.fromFuture) {
508
- return {
509
- ok: false,
510
- backup: null,
511
- values: before.values,
512
- log: `refusing to write over settings this version cannot read (${before.errors[0]?.reason ?? 'unreadable file'}) — your file is untouched; nothing was saved`,
513
- };
514
- }
515
-
516
- const merged = { ...before.values, ...(patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}) };
517
- const result = validate(merged);
518
-
519
- // validate() falls back to the SHIPPED default, which is right for a cold read and wrong here: on a
520
- // save, the honest fallback is what the user already had. Caught by its own test — a bad click on
521
- // one control was silently resetting that control to factory rather than leaving it alone. Every
522
- // value in `before.values` is already validated, so this cannot reintroduce a bad value.
523
- for (const e of result.errors) {
524
- if (e.key && Object.hasOwn(before.values, e.key)) result.values[e.key] = before.values[e.key];
525
- }
526
-
527
- let backup = null;
528
- if (fs.existsSync(file)) {
529
- // The timestamp is only millisecond-resolution, and two saves DO land in the same millisecond —
530
- // measured, not theorised: six rapid saves produced five backups, because one copyFileSync
531
- // overwrote another at an identical path. Silently, with no error. An undo history that drops a
532
- // step without saying so is worse than none, since the user is relying on it. So the name is made
533
- // unique before writing: one save, one backup, always.
534
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
535
- backup = `${file}.bak-${stamp}`;
536
- // Zero-padded so listBackups()'s lexicographic sort still means "newest last" — unpadded, "-10"
537
- // would sort BEFORE "-2" and revert-without-a-named-backup would restore the wrong one.
538
- for (let n = 2; fs.existsSync(backup); n++) backup = `${file}.bak-${stamp}-${String(n).padStart(2, '0')}`;
539
- // READ-THEN-WRITE, NOT copyFileSync — and this is not stylistic. Under sustained concurrent
540
- // saving, copyFileSync WEDGED the process permanently: reproduced in 3 of 6 trials at 40 saves
541
- // per writer with two writers, observed at 100% CPU for 4m38s with zero file progress, never
542
- // returning and never throwing. macOS `sample` put 1473 of 1476 stack samples inside
543
- // node::fs::CopyFile. A hung request handler that never recovers is not survivable on a surface
544
- // people are told to click. `wx` additionally refuses to overwrite an existing backup, so the
545
- // uniqueness loop above cannot be defeated by a racing writer between existsSync and the write.
546
- try { fs.writeFileSync(backup, fs.readFileSync(file), { flag: 'wx' }); }
547
- catch (e) { return { ok: false, backup: null, values: before.values, log: `refusing to write — backup failed: ${e.message}` }; }
548
- }
549
-
550
- const body = { version: SETTINGS_VERSION, updated: new Date().toISOString(), settings: result.values };
551
- try {
552
- writeAtomic(file, JSON.stringify(body, null, 2) + '\n');
553
- } catch (e) {
554
- return { ok: false, backup, values: before.values, log: `write failed: ${e.message}${backup ? `; your previous settings are at ${backup}` : ''}` };
555
- }
556
-
557
- // `existedBefore: false` is the undo instruction for the first-ever save: there is no backup to
558
- // restore because there was no file, so reverting means REMOVING it. Without this flag, revert
559
- // would have to guess, and a revert that guesses is not an undo.
560
- return {
561
- ok: true,
562
- file,
563
- backup,
564
- existedBefore: before.exists,
565
- values: result.values,
566
- errors: result.errors,
567
- warnings: result.warnings,
568
- log: backup ? `saved; previous settings kept at ${backup.replace(HOME, '~')}` : 'saved (first time — reverting will remove the file)',
569
- };
570
- }
571
-
572
- /**
573
- * REVERT — the other half of the promise made by saveSettings.
574
- *
575
- * Restores a specific backup, or the most recent one when none is named. Passing the save receipt's
576
- * `{ backup: null, existedBefore: false }` removes the file, returning the machine to genuinely
577
- * having no settings rather than to a synthesised "defaults" file — those are different states and
578
- * loadSettings() reports them differently, so revert must not blur them.
579
- */
580
- export function revertSettings({ file = STORE_PATH, backup, existedBefore = true } = {}) {
581
- const target = backup ?? listBackups(file).slice(-1)[0] ?? null;
582
-
583
- if (!target) {
584
- if (existedBefore === false || !fs.existsSync(file)) {
585
- if (!fs.existsSync(file)) return { ok: true, log: 'nothing to revert — there are no settings on disk' };
586
- try { fs.rmSync(file); return { ok: true, log: 'removed the settings file (there was none before this save)' }; }
587
- catch (e) { return { ok: false, log: `could not remove ${file}: ${e.message}` }; }
588
- }
589
- return { ok: false, log: 'no backup available to restore' };
590
- }
591
- if (!fs.existsSync(target)) return { ok: false, log: `that backup is gone (${target})` };
592
-
593
- // Same read-then-atomic-write as the save path, for the same two reasons: copyFileSync can wedge
594
- // under contention (see saveSettings), and an in-place copy that is interrupted leaves the user
595
- // with neither their old settings nor their new ones — during an UNDO, which is the one operation
596
- // that must never be able to lose data.
597
- //
598
- // AND THE RECEIPT IS CHECKED. withLock returns {ok:false, timedOut:true} WITHOUT EVER CALLING fn
599
- // when another process holds a fresh lock — so discarding its result meant a revert that wrote
600
- // nothing still reported "restored your previous settings from …bak-…". MEASURED against a held
601
- // lock: 5017ms elapsed, receipt said ok:true, file byte-for-byte unchanged. saveSettings has
602
- // checked `held.timedOut` since the day it was written; revert was the copy that forgot, which put
603
- // the fabricated-success bug inside the one operation whose entire promise is that it really
604
- // happened. A failed undo the user is told succeeded is worse than an undo button that isn't there.
605
- let held;
606
- try {
607
- const bytes = fs.readFileSync(target);
608
- held = withLock(file, () => writeAtomic(file, bytes));
609
- } catch (e) { return { ok: false, log: `restore failed: ${e.message}` }; }
610
- if (held.timedOut) {
611
- return {
612
- ok: false,
613
- log: `another process is writing these settings and did not finish within ${LOCK_WAIT_MS}ms (lock held ${Math.round(held.heldMs)}ms) — NOTHING was restored and your backup at ${path.basename(target)} is intact; try again`,
614
- };
615
- }
616
- return { ok: true, restored: target, log: `restored your previous settings from ${path.basename(target)}` };
617
- }
618
-
619
- // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
620
- // Read-only by default. Printing what is actually stored, plus the downside of every choice, is the
621
- // whole point — a settings model you cannot inspect from a terminal is one you have to trust.
622
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('user-settings.mjs');
623
- if (invokedDirectly) {
624
- const state = loadSettings();
625
- if (process.argv.includes('--json')) {
626
- console.log(JSON.stringify(state, null, 2));
627
- } else {
628
- console.log(`\n Settings file: ${state.path.replace(HOME, '~')}${state.exists ? '' : ' (not created yet — showing defaults)'}\n`);
629
- for (const s of SETTINGS_SCHEMA) {
630
- const v = state.values[s.key];
631
- const reach = escalatesBeyondProject(s.key, v) ? ' ← reaches outside this project' : '';
632
- console.log(` ${s.label}`);
633
- console.log(` now: ${JSON.stringify(v)}${v === s.default ? ' (default)' : ''}${reach}`);
634
- console.log(` ${s.help}`);
635
- console.log(` downside: ${s.downside}\n`);
636
- }
637
- for (const e of state.errors) console.log(` ! ${e.key ?? 'file'}: ${e.reason}`);
638
- for (const w of state.warnings) console.log(` · ${w.key}: ${w.reason}`);
639
- }
640
- }
1
+ // Compatibility export for repository tools. The executable implementation belongs inside the
2
+ // self-contained plugin payload so Stable Spine and Codex-only installs never depend on a separate
3
+ // Claude marketplace checkout.
4
+ export * from '../plugin/scripts/user-settings.mjs';