ruvnet-brain 3.9.85-dev → 3.9.130-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.
- package/README.md +17 -15
- package/bin/install.mjs +698 -70
- package/kb/brain-profile.mjs +145 -0
- package/kb/model-requirements.mjs +72 -0
- package/kb/zip-extract.mjs +297 -0
- package/package.json +32 -4
- package/plugin/mcp/managed-cli-interface.mjs +236 -0
- package/plugin/mcp/server.mjs +133 -32
- package/plugin/scripts/codex-hook-wrapper.mjs +37 -0
- package/scripts/dual-host-deliberation.mjs +284 -0
- package/scripts/dual-host-suggest.mjs +58 -0
- package/scripts/hook-registry.mjs +567 -0
- package/scripts/install-scope.mjs +708 -0
- package/scripts/model-router-outcome.mjs +19 -7
- package/scripts/selfcheck.mjs +646 -0
- package/scripts/subscription-hosts.mjs +105 -0
- package/scripts/upgrade-notice.mjs +465 -0
- package/scripts/user-settings.mjs +640 -0
|
@@ -0,0 +1,640 @@
|
|
|
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
|
+
}
|