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.
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ export const API_BILLING_ENV = Object.freeze([
6
+ 'ANTHROPIC_API_KEY',
7
+ 'CLAUDE_API_KEY',
8
+ 'OPENAI_API_KEY',
9
+ 'CODEX_API_KEY',
10
+ 'OPENROUTER_API_KEY',
11
+ 'AZURE_OPENAI_API_KEY',
12
+ 'AZURE_OPENAI_ENDPOINT',
13
+ 'GOOGLE_API_KEY',
14
+ 'GEMINI_API_KEY',
15
+ 'XAI_API_KEY',
16
+ ]);
17
+
18
+ export function subscriptionOnlyEnv(parent = process.env) {
19
+ const child = { ...parent, RUVNET_SUBSCRIPTION_ONLY: '1' };
20
+ for (const name of API_BILLING_ENV) delete child[name];
21
+ return child;
22
+ }
23
+
24
+ function execute(run, binary, args) {
25
+ return run(binary, args, {
26
+ encoding: 'utf8',
27
+ env: subscriptionOnlyEnv(),
28
+ timeout: 15_000,
29
+ });
30
+ }
31
+
32
+ function unavailable(host, auth = 'unknown', reason = 'subscription login not verified') {
33
+ return { host, eligible: false, auth, reason };
34
+ }
35
+
36
+ export function probeClaudeSubscription({ run = spawnSync } = {}) {
37
+ let result;
38
+ try {
39
+ result = execute(run, 'claude', ['auth', 'status', '--json']);
40
+ } catch {
41
+ return unavailable('claude-code');
42
+ }
43
+ if (result?.status !== 0) return unavailable('claude-code');
44
+
45
+ let status;
46
+ try {
47
+ status = JSON.parse(result.stdout);
48
+ } catch {
49
+ return unavailable('claude-code');
50
+ }
51
+
52
+ if (
53
+ status.loggedIn === true
54
+ && status.authMethod === 'claude.ai'
55
+ && status.apiProvider === 'firstParty'
56
+ && typeof status.subscriptionType === 'string'
57
+ && status.subscriptionType.length > 0
58
+ ) {
59
+ return {
60
+ host: 'claude-code',
61
+ eligible: true,
62
+ auth: 'claude.ai-subscription',
63
+ plan: status.subscriptionType,
64
+ reason: 'verified by claude auth status',
65
+ };
66
+ }
67
+
68
+ return unavailable(
69
+ 'claude-code',
70
+ status.loggedIn ? 'metered-or-unknown' : 'unknown',
71
+ 'Claude is not using a verified claude.ai subscription',
72
+ );
73
+ }
74
+
75
+ export function probeCodexSubscription({ run = spawnSync } = {}) {
76
+ let result;
77
+ try {
78
+ result = execute(run, 'codex', ['login', 'status']);
79
+ } catch {
80
+ return unavailable('codex');
81
+ }
82
+ if (result?.status !== 0) return unavailable('codex');
83
+
84
+ const status = `${String(result.stdout ?? '')}\n${String(result.stderr ?? '')}`.trim();
85
+ if (/(?:^|\n)Logged in using ChatGPT(?:\n|$)/i.test(status)) {
86
+ return {
87
+ host: 'codex',
88
+ eligible: true,
89
+ auth: 'chatgpt-subscription',
90
+ reason: 'verified by codex login status',
91
+ };
92
+ }
93
+ return unavailable(
94
+ 'codex',
95
+ /api key/i.test(status) ? 'metered-or-unknown' : 'unknown',
96
+ 'Codex is not using a verified ChatGPT login',
97
+ );
98
+ }
99
+
100
+ export function probeSubscriptionHosts(options = {}) {
101
+ return {
102
+ claude: probeClaudeSubscription(options),
103
+ codex: probeCodexSubscription(options),
104
+ };
105
+ }
@@ -0,0 +1,465 @@
1
+ // upgrade-notice.mjs — telling EXISTING users what a new feature release changed, at most once,
2
+ // and shutting up the moment they say no.
3
+ //
4
+ // WHY THIS EXISTS AT ALL. The install conversation only ever reaches people who are installing.
5
+ // The owner's correction, 2026-07-22: *"That's only going to help people newly installing. It needs
6
+ // to be smart enough when it comes up to say: version 4 is here, here are some things about it, you
7
+ // have much more finely grained control, here's how you should install it, and here are your
8
+ // choices."* Everyone already running an older build is the majority of the userbase and, before
9
+ // this file, the one group the whole conversation could never reach.
10
+ //
11
+ // WHY IT IS SO CONSERVATIVE. The same session established what this project may not become. A power
12
+ // user's verdict, relayed by the owner: *"I can't use your stuff because it has hooks and this and
13
+ // that."* And the owner's rule on top of it: *"Nudging somebody is very fair. Forcing them through a
14
+ // gate is not."* A notice is a nudge; a notice that returns after being declined is a gate wearing a
15
+ // nudge's clothes, and it is worse than a gate because it pretends to be optional. So the anti-nag
16
+ // rules here are not politeness, they are the product:
17
+ //
18
+ // 1. AT MOST ONCE PER MINOR (ADR-027). Patch releases are silent. If the news is not worth a
19
+ // feature-release number, it is not worth interrupting someone's work for.
20
+ // 2. A DISMISSAL IS FINAL FOR THAT VERSION. Not snoozed. Not "we'll ask after a restart."
21
+ // 3. TWO DISMISSALS IN A ROW AND WE NEVER ASK AGAIN. Two declines is an answer. Continuing to ask
22
+ // past an answer is the coercion the owner rejected, just spread thinly enough to deny.
23
+ // 4. ANYTHING WE CANNOT READ MEANS SILENCE. Every unreadable, unparseable, ambiguous or
24
+ // from-the-future state resolves to "say nothing" — never to "ask again to be safe". A bug in
25
+ // this file must cost the user a notice they might have wanted, never a nag they refused.
26
+ // That asymmetry is deliberate and every branch below is written to preserve it.
27
+ //
28
+ // WHAT IT DELIBERATELY DOES NOT DO. It does not install, update, migrate or change one byte of the
29
+ // user's setup, and it never implies their current setup is deficient. Somebody deliberately running
30
+ // per-project is not misconfigured, and a notice that treats them as a repair job is exactly the
31
+ // disrespect the owner warned about: *"All of these additional steps are the difference between
32
+ // acting as a good partner and forcing something down somebody's throat, which, even if it's good
33
+ // medicine, doesn't feel good."*
34
+
35
+ import fs from 'node:fs';
36
+ import os from 'node:os';
37
+ import path from 'node:path';
38
+
39
+ const HOME = os.homedir();
40
+
41
+ /**
42
+ * WHERE THE STATE LIVES — the same directory as user-settings.mjs, for the same checked reason.
43
+ *
44
+ * `--update` overwrites the cache directory entry-by-entry (install.mjs:410) and `--uninstall`
45
+ * rmSync's the whole kb dir (install.mjs:1526), so anything under ~/.cache/ruvnet-brain is transient
46
+ * BY DESIGN. Grepping install.mjs for `.config/ruvnet-brain` returns zero hits — the only program
47
+ * that deletes things cannot see this path.
48
+ *
49
+ * That is load-bearing here in a way it is not for ordinary settings: this state is the record of a
50
+ * user saying "no". If an update wiped it, every upgrade would re-ask everybody who had already
51
+ * declined — the exact nag this file exists to prevent, reintroduced by the update mechanism itself.
52
+ * A refusal that does not survive the next release is not a refusal.
53
+ *
54
+ * Env override matches the RUVNET_LESSON_STORE / RUVNET_SETTINGS_FILE idiom so tests never touch the
55
+ * real user's file.
56
+ */
57
+ export const STATE_PATH = process.env.RUVNET_UPGRADE_NOTICE_FILE
58
+ || path.join(HOME, '.config', 'ruvnet-brain', 'upgrade-notice.json');
59
+
60
+ /** Bumped only when the on-disk shape changes incompatibly. Newer-than-known states go silent, never loud. */
61
+ export const STATE_VERSION = 1;
62
+
63
+ /** Two declines is an answer. Past this many consecutive dismissals we never ask again, about anything. */
64
+ export const DISMISSALS_UNTIL_PERMANENT_SILENCE = 2;
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
67
+ // Version reading. Never a hardcoded literal anywhere in this file — scripts/sync-version.mjs
68
+ // --check fails the build on a stray quoted version, and more importantly a notice that names a
69
+ // version in its own source rots the day after it ships. Everything user-visible is derived from
70
+ // the string the caller passes in at runtime.
71
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Parse a semver string — bare, `v`-prefixed, or carrying a prerelease suffix — into
75
+ * {major,minor,patch}. Anything else → null, and null means silence everywhere downstream.
76
+ *
77
+ * Note the absence of worked examples in this comment: sync-version.mjs --check greps the whole tree
78
+ * for quoted version literals, and a doc-comment example is indistinguishable from a real hardcoded
79
+ * version to a scanner. It failed this file on exactly that, which is the gate working — an example
80
+ * written today reads as the product's version the day the product reaches it.
81
+ */
82
+ export function parseVersion(v) {
83
+ if (typeof v !== 'string') return null;
84
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
85
+ if (!m) return null;
86
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
87
+ }
88
+
89
+ /**
90
+ * The identity of a FEATURE release: "major.minor". Patch differences are invisible to every
91
+ * comparison in this file, which is rule 1 expressed as a data type rather than as an `if` somebody
92
+ * can forget. A hotfix stream can ship all day without generating a single interruption.
93
+ */
94
+ export function minorKey(v) {
95
+ const p = parseVersion(v);
96
+ return p ? `${p.major}.${p.minor}` : null;
97
+ }
98
+
99
+ /** Is `a` a LATER feature release than `b`? Patch is ignored on purpose (see minorKey). */
100
+ function isNewerMinor(a, b) {
101
+ const x = parseVersion(a), y = parseVersion(b);
102
+ if (!x || !y) return false;
103
+ return x.major !== y.major ? x.major > y.major : x.minor > y.minor;
104
+ }
105
+
106
+ /**
107
+ * Normalize the dismissal ledger, and REFUSE anything we do not recognise.
108
+ *
109
+ * Accepts the three shapes a caller can plausibly hold: absent (the normal state — nobody has ever
110
+ * dismissed anything), a plain count, or a list of records/version strings. Anything else — a string,
111
+ * a bare object, a negative or fractional count, a list containing an entry we cannot read — comes
112
+ * back `ok:false`, and every caller turns that into silence.
113
+ *
114
+ * The refusal is the point. The tempting version coerces junk to zero and carries on, which converts
115
+ * "we lost the record of you declining" into "ask them again". Corrupt state must degrade toward the
116
+ * quiet failure, never the loud one.
117
+ */
118
+ function readDismissals(d) {
119
+ if (d === undefined || d === null) return { ok: true, count: 0, versions: [] };
120
+
121
+ if (typeof d === 'number') {
122
+ if (!Number.isInteger(d) || d < 0) return { ok: false, count: 0, versions: [] };
123
+ return { ok: true, count: d, versions: [] };
124
+ }
125
+
126
+ if (Array.isArray(d)) {
127
+ const versions = [];
128
+ for (const entry of d) {
129
+ if (typeof entry === 'string') versions.push(entry);
130
+ else if (entry && typeof entry === 'object' && typeof entry.version === 'string') versions.push(entry.version);
131
+ // One unreadable entry condemns the whole ledger. It could have been the second dismissal —
132
+ // the one that means "never again" — and guessing wrong in that direction is the failure we
133
+ // are here to prevent.
134
+ else return { ok: false, count: 0, versions: [] };
135
+ }
136
+ return { ok: true, count: versions.length, versions };
137
+ }
138
+
139
+ return { ok: false, count: 0, versions: [] };
140
+ }
141
+
142
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
143
+ // The decision.
144
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * WHY THIS RETURNS A REASON. Every silence in this file is a deliberate choice, and a surface that
148
+ * cannot say WHICH choice it made cannot be audited — it looks identical to a surface that is simply
149
+ * broken. Same argument as loadSettings() returning an envelope: "the user declined" and "we could
150
+ * not read the file" are different facts and must never collapse into one.
151
+ *
152
+ * `dismissals` is the CONSECUTIVE-dismissal ledger, not a lifetime total. recordActed() clears it,
153
+ * so "twice in a row" is enforced by what the writer stores rather than by date arithmetic the
154
+ * reader would have to guess at. Someone who declines one release, acts on the next, then declines
155
+ * again has not refused twice in a row and is not silenced.
156
+ */
157
+ export function explainDecision(installedVersion, lastNotifiedVersion, dismissals) {
158
+ const installed = parseVersion(installedVersion);
159
+ if (!installed) {
160
+ // We cannot name the release, so we cannot honestly describe it. Saying nothing beats saying
161
+ // something vague — house rule: the product can never lie, and "a new version is here" without
162
+ // being able to say which is a claim we cannot support.
163
+ return { notify: false, reason: 'unreadable-installed-version' };
164
+ }
165
+
166
+ const led = readDismissals(dismissals);
167
+ if (!led.ok) return { notify: false, reason: 'unreadable-dismissal-history' };
168
+
169
+ if (led.count >= DISMISSALS_UNTIL_PERMANENT_SILENCE) {
170
+ return { notify: false, reason: 'declined-twice-permanent-silence' };
171
+ }
172
+
173
+ const key = minorKey(installedVersion);
174
+
175
+ // Belt AND braces with the lastNotified check below, and not redundantly so: these are two
176
+ // separate writes. A process killed between "append the dismissal" and "stamp lastNotified"
177
+ // leaves a ledger that remembers the refusal and a stamp that does not. The refusal wins.
178
+ if (led.versions.some((v) => minorKey(v) === key)) {
179
+ return { notify: false, reason: 'already-declined-this-release' };
180
+ }
181
+
182
+ if (lastNotifiedVersion === undefined || lastNotifiedVersion === null || lastNotifiedVersion === '') {
183
+ // Never told this user about anything. This is the state every EXISTING user is in the first
184
+ // time they run a build containing this file — precisely the audience the owner asked for.
185
+ // A brand-new install is NOT meant to land here: bin/install.mjs seeds the state with
186
+ // markAsAlreadyNotified() so nobody is handed "here's what changed" about software they just
187
+ // chose and configured thirty seconds ago.
188
+ return { notify: true, reason: 'never-notified' };
189
+ }
190
+
191
+ if (!parseVersion(lastNotifiedVersion)) {
192
+ // We hold something, but cannot tell what we already said. We cannot prove we have not already
193
+ // asked, so we do not ask. Silence on doubt, always.
194
+ return { notify: false, reason: 'unreadable-last-notified' };
195
+ }
196
+
197
+ if (!isNewerMinor(installedVersion, lastNotifiedVersion)) {
198
+ // Same feature release (patch bumps included), or older — a rollback is not news. Either way
199
+ // there is nothing here they have not already been offered once.
200
+ return { notify: false, reason: 'no-new-feature-release' };
201
+ }
202
+
203
+ return { notify: true, reason: 'new-feature-release' };
204
+ }
205
+
206
+ /** Should we say anything at all? The whole contract in one boolean. */
207
+ export function shouldNotify(installedVersion, lastNotifiedVersion, dismissals) {
208
+ return explainDecision(installedVersion, lastNotifiedVersion, dismissals).notify;
209
+ }
210
+
211
+ /** Same decision, driven off a loaded state envelope. An unhealthy or from-the-future state is silent. */
212
+ export function shouldNotifyFromState(installedVersion, state) {
213
+ if (!state || typeof state !== 'object') return false;
214
+ if (state.healthy === false || state.fromFuture === true) return false;
215
+ return shouldNotify(installedVersion, state.lastNotifiedVersion, state.dismissals);
216
+ }
217
+
218
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
219
+ // The words.
220
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * THE NOTICE. Plain text, no ANSI, no markdown — the identical bytes have to read correctly in a
224
+ * terminal, in the console page, and in a test assertion, and a string that renders three ways is a
225
+ * string that drifts three ways.
226
+ *
227
+ * FOUR THINGS IT HAS TO DO, and one it has to never do:
228
+ *
229
+ * · Name what changed in language that survives a non-expert reading it.
230
+ * · State the recommended scope AND, in the same breath, that the choice is the user's. The owner
231
+ * dictated this one close to verbatim and the phrasing below tracks it deliberately: *"Our
232
+ * strong recommendation is per-user — but we always want YOU to be the arbiter of how things run
233
+ * on your machine."*
234
+ * · Give the exact command. Both commands here were read out of this repo before being printed —
235
+ * `--update` at bin/install.mjs:70 (documented README:41) and `--what-changed` at
236
+ * bin/install.mjs:80. A notice that tells someone to run a flag that does not exist destroys
237
+ * more trust than the notice was ever going to earn.
238
+ * · Say how to make it stop, in the notice itself. An interruption that hides its own off switch
239
+ * is not offering a choice.
240
+ *
241
+ * · NEVER imply the current setup is broken. There is no "misconfigured", no "you should have",
242
+ * no "fix" anywhere in this copy, and a unit test enforces that against a blocklist. Someone
243
+ * running per-project on purpose made a decision; telling them it was a mistake is how you lose
244
+ * the exact power users this release is trying to win back.
245
+ *
246
+ * NO NUMBERS. Not one count, percentage or total appears here, because this string is composed
247
+ * without touching the machine and any figure in it would therefore be fabricated. The house rule is
248
+ * absolute: every number shown is derived at runtime, so a surface that cannot derive shows none.
249
+ * `--what-changed` is offered precisely so the real, measured picture comes from the tool that
250
+ * actually looks.
251
+ *
252
+ * @returns {string|null} the notice, or null for a version we cannot name (see explainDecision).
253
+ */
254
+ export function noticeFor(version) {
255
+ const v = parseVersion(version);
256
+ if (!v) return null;
257
+
258
+ const release = `${v.major}.${v.minor}`; // derived, never a literal — see the header note
259
+
260
+ return [
261
+ `RuvNet Brain ${release} is here, and the headline is that more of it is yours to decide.`,
262
+ ``,
263
+ `What changed, plainly:`,
264
+ ``,
265
+ ` · The pieces are separate, and each one has a name. The brain itself (the searchable`,
266
+ ` corpus of rUv's work), the search tool Claude calls, the hooks, the skills and the`,
267
+ ` console are distinct parts. Any one of them runs perfectly well without the others.`,
268
+ ` Take only the brain if that is all you want — that path is supported, not a fallback.`,
269
+ ``,
270
+ ` · Guidance leans, it does not shove. Where an earlier build would try to stop an action`,
271
+ ` outright, it now tells you what it found and leaves the call to you. The few places`,
272
+ ` that still stop anything are ones you switch on yourself, by name.`,
273
+ ``,
274
+ ` · Finer-grained control over which parts run, and where they run.`,
275
+ ``,
276
+ `Where it runs, and who decides:`,
277
+ ``,
278
+ ` Normally this happens on a per-user basis, which lets learning, intelligence, access and`,
279
+ ` software versions stay updated universally across all your projects. Only choose`,
280
+ ` per-project if this is something you absolutely only use on a per-project basis.`,
281
+ ``,
282
+ ` Our strong recommendation is per-user — but we always want YOU to be the arbiter of how`,
283
+ ` things run on your machine. Both are fully supported, and if you already run it`,
284
+ ` per-project on purpose, that keeps working exactly as it does today.`,
285
+ ``,
286
+ `The command:`,
287
+ ``,
288
+ ` npx ruvnet-brain@latest --update`,
289
+ ``,
290
+ ` Prefer to look first? npx ruvnet-brain --what-changed lists every piece currently on this`,
291
+ ` machine and where each one lives. It reads; it changes nothing.`,
292
+ ``,
293
+ `You will see this at most once per feature release. Dismiss it and this release goes quiet.`,
294
+ `Dismiss twice and it stops asking altogether — two declines is an answer, and we will take it.`,
295
+ ``,
296
+ `Tell us how this lands: https://github.com/stuinfla/ruvnet-brain/discussions`,
297
+ ].join('\n');
298
+ }
299
+
300
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
301
+ // The state on disk.
302
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
303
+
304
+ const emptyState = (file) => ({
305
+ path: file, exists: false, healthy: true, fromFuture: false,
306
+ lastNotifiedVersion: null, dismissals: [],
307
+ });
308
+
309
+ /**
310
+ * LOAD — an envelope, never a bare object, so callers can distinguish "nobody has been notified yet"
311
+ * from "we could not read whether they have". Those are opposite instructions and collapsing them
312
+ * would make a corrupt file indistinguishable from a fresh one, i.e. it would nag.
313
+ *
314
+ * On corrupt bytes this sets `healthy:false` AND replaces `dismissals` with a shape readDismissals()
315
+ * rejects. That belt-and-braces is intentional: a caller who forgets to check `healthy` and passes
316
+ * the fields straight into shouldNotify() still gets silence. There is no path through this module
317
+ * where unreadable state produces an interruption.
318
+ */
319
+ export function loadNoticeState(file = STATE_PATH) {
320
+ const state = emptyState(file);
321
+
322
+ let raw;
323
+ try { raw = fs.readFileSync(file, 'utf8'); }
324
+ catch { return state; } // no file yet is the NORMAL state, not a fault — empty-first
325
+
326
+ state.exists = true;
327
+
328
+ let parsed;
329
+ try { parsed = JSON.parse(raw); }
330
+ catch {
331
+ state.healthy = false;
332
+ state.dismissals = { unreadable: true }; // deliberately un-normalizable; see the note above
333
+ return state;
334
+ }
335
+
336
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
337
+ state.healthy = false;
338
+ state.dismissals = { unreadable: true };
339
+ return state;
340
+ }
341
+
342
+ if (typeof parsed.version === 'number' && parsed.version > STATE_VERSION) {
343
+ // Written by a build newer than this code. Their real answers are in there and readable by the
344
+ // version that wrote them; we neither reinterpret nor overwrite. Silent until that build runs
345
+ // again — the safe direction, as always.
346
+ state.fromFuture = true;
347
+ state.dismissals = { unreadable: true };
348
+ return state;
349
+ }
350
+
351
+ if (typeof parsed.lastNotifiedVersion === 'string') state.lastNotifiedVersion = parsed.lastNotifiedVersion;
352
+ else if (parsed.lastNotifiedVersion != null) state.healthy = false; // present but not a string
353
+
354
+ if (Array.isArray(parsed.dismissals)) state.dismissals = parsed.dismissals;
355
+ else if (parsed.dismissals != null) { state.healthy = false; state.dismissals = { unreadable: true }; }
356
+
357
+ return state;
358
+ }
359
+
360
+ /** Write the state atomically, backing up unreadable bytes rather than deleting them silently. */
361
+ function writeState(file, next) {
362
+ fs.mkdirSync(path.dirname(file), { recursive: true });
363
+ const tmp = `${file}.tmp-${process.pid}`;
364
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
365
+ fs.renameSync(tmp, file); // rename is atomic on the same filesystem: no half-written refusal
366
+ return next;
367
+ }
368
+
369
+ /**
370
+ * Every writer funnels through here so the refuse-vs-recover rule is stated once.
371
+ *
372
+ * fromFuture → REFUSE. The file is intact and full of a newer build's real answers. Writing would
373
+ * delete live data to record something we could just as well record later.
374
+ * corrupt → RECOVER. Those answers are already gone; refusing would strand the user with a
375
+ * broken file forever and no way back. Back up the wreckage, write a clean file.
376
+ *
377
+ * This is the same distinction user-settings.loadSettings() draws, and it is drawn here for the same
378
+ * reason: "can we still recover their intent?" is the question, not "can we parse it?"
379
+ */
380
+ function mutate(file, fn) {
381
+ const state = loadNoticeState(file);
382
+ if (state.fromFuture) return { ok: false, reason: 'state-written-by-newer-version', state };
383
+
384
+ if (state.exists && state.healthy === false) {
385
+ try { fs.copyFileSync(file, `${file}.corrupt-${Date.now()}`); } catch { /* best effort; never fatal */ }
386
+ }
387
+
388
+ const base = {
389
+ version: STATE_VERSION,
390
+ lastNotifiedVersion: state.healthy ? state.lastNotifiedVersion : null,
391
+ dismissals: state.healthy && Array.isArray(state.dismissals) ? state.dismissals : [],
392
+ };
393
+ return { ok: true, state: writeState(file, fn(base)) };
394
+ }
395
+
396
+ /** Record that the notice for `version` was actually SHOWN. Call it when it renders, not when it is composed. */
397
+ export function recordNotified(version, file = STATE_PATH) {
398
+ return mutate(file, (s) => ({ ...s, lastNotifiedVersion: version, lastNotifiedAt: new Date().toISOString() }));
399
+ }
400
+
401
+ /**
402
+ * Record a decline. Appends to the consecutive-dismissal ledger AND stamps lastNotified, so either
403
+ * write surviving on its own is still enough to keep us quiet about this release.
404
+ */
405
+ export function recordDismissal(version, file = STATE_PATH) {
406
+ return mutate(file, (s) => ({
407
+ ...s,
408
+ lastNotifiedVersion: version,
409
+ dismissals: [...s.dismissals, { version, at: new Date().toISOString() }],
410
+ }));
411
+ }
412
+
413
+ /**
414
+ * Record that the user ACTED on a notice — ran the update, opened the choices, engaged at all.
415
+ * Clearing the ledger here is what makes rule 3 mean "twice in a row" instead of "twice ever".
416
+ * Someone who declines one release, acts on the next, then declines again has not refused twice
417
+ * running, and treating them as though they had would silence a user who is plainly still listening.
418
+ */
419
+ export function recordActed(version, file = STATE_PATH) {
420
+ return mutate(file, (s) => ({ ...s, lastNotifiedVersion: version, dismissals: [], actedAt: new Date().toISOString() }));
421
+ }
422
+
423
+ /**
424
+ * Seed a FRESH INSTALL as already-notified: no dismissal, no interruption, just a stamp saying this
425
+ * release has had its conversation. Without this, a brand-new user finishes the install wizard and
426
+ * is immediately told "here's what changed and here's how to choose" about choices they made ninety
427
+ * seconds ago — which reads as software that was not paying attention.
428
+ */
429
+ export function markAsAlreadyNotified(version, file = STATE_PATH) {
430
+ return mutate(file, (s) => ({ ...s, lastNotifiedVersion: version }));
431
+ }
432
+
433
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
434
+ // CLI. `--dismiss` ships in the same commit as the notice on purpose: this repo's standing rule is
435
+ // that anything which can interrupt you must ship its own off switch (ADR-027 §2, generalised —
436
+ // detection without a remedy is prohibited). A notice you cannot turn off is not a nudge.
437
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
438
+ if (process.argv[1] && process.argv[1].endsWith('upgrade-notice.mjs')) {
439
+ const argv = process.argv.slice(2);
440
+ const flagValue = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
441
+ // The version is passed in, never read from a literal here — this file has no opinion about which
442
+ // release it is describing, which is why it does not rot.
443
+ const version = flagValue('--version') || process.env.RUVNET_BRAIN_VERSION;
444
+
445
+ if (!version) {
446
+ console.log('usage: node scripts/upgrade-notice.mjs --version <installed-version> [--dismiss|--acted|--seed]');
447
+ process.exit(2);
448
+ } else if (argv.includes('--dismiss')) {
449
+ recordDismissal(version);
450
+ console.log('Noted — you will not be asked about this release again.');
451
+ } else if (argv.includes('--acted')) {
452
+ recordActed(version);
453
+ } else if (argv.includes('--seed')) {
454
+ markAsAlreadyNotified(version);
455
+ } else {
456
+ const state = loadNoticeState();
457
+ if (shouldNotifyFromState(version, state)) {
458
+ console.log(noticeFor(version));
459
+ recordNotified(version);
460
+ } else if (argv.includes('--why')) {
461
+ // Only on request: explaining a silence unprompted would itself be an interruption.
462
+ console.log(`silent: ${explainDecision(version, state.lastNotifiedVersion, state.dismissals).reason}`);
463
+ }
464
+ }
465
+ }