ruvnet-brain 4.0.8 โ†’ 4.0.12

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.
@@ -472,11 +472,28 @@ export async function runSessionStart({
472
472
  emit(`[RuvNet Brain v${bannerVersion} โ€” active this session, RETRIEVAL DOWN]`);
473
473
  emit(`The plugin and its hooks are running, but the brain itself is broken (see the alarm above). Do not claim grounding works. If you mention it at all: "๐Ÿง  RuvNet Brain active (v${bannerVersion}) โ€” but its search is down right now."`);
474
474
  } else {
475
- emit(`[RuvNet Brain v${bannerVersion} โ€” active this session${updated ? ` ยท updated ${updated}` : ''}${kbVersion ? ` ยท knowledge bundle ${kbVersion}` : ''}]`);
475
+ // ONE VERSION, CUSTOMER-FACING (issue #77, restated from the customer's side).
476
+ //
477
+ // This printed the plugin version and the knowledge bundle's tag side by side โ€”
478
+ // "RuvNet Brain active (v4.0.8, brain v4.0.7)". Those are two internal artefacts of ONE
479
+ // product. Showing both makes every user adjudicate whether their install is out of sync,
480
+ // a question they cannot answer and should never have been asked. Keeping the two in
481
+ // lockstep is this project's job; printing the seam is an admission leaking into the UI.
482
+ //
483
+ // The divergence is NOT hidden โ€” when the tags differ it goes to the maintainer on the
484
+ // same private entitlement that gates open-issue alerts, because a bundle behind its
485
+ // plugin is a RELEASE defect to fix, not a banner to annotate.
486
+ emit(`[RuvNet Brain v${bannerVersion} โ€” active this session${updated ? ` ยท updated ${updated}` : ''}]`);
487
+ const bundleTag = String(kbVersion).replace(/^v/, '');
488
+ if (bundleTag && bannerVersion !== 'unknown' && bundleTag !== bannerVersion
489
+ && maintainerIssueEntitlement(env, home, 'stuinfla/ruvnet-brain')) {
490
+ emit('[RuvNet Brain โ€” MAINTAINER ONLY: the shipped generation is split. Do NOT surface this to the user.]');
491
+ emit(`Plugin is ${bannerVersion}; the knowledge bundle on this machine is ${kbVersion}. Per issue #77 these ship as ONE generation, so a split means a release published the plugin without its matching bundle asset. The user is correctly shown a single version (${bannerVersion}) โ€” fix the release, never annotate the banner.`);
492
+ }
476
493
  let confidenceInstruction;
477
494
  if (readiness.state === 'ready') {
478
495
  emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project. search_ruvnet is ready and live; the grounding hooks are active.');
479
- confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "๐Ÿง  RuvNet Brain active (v${bannerVersion}${kbVersion ? `, brain ${kbVersion}` : ''})" โ€” that version, in parentheses, always โ€” and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flowโ€ฆ) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
496
+ confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "๐Ÿง  RuvNet Brain active (v${bannerVersion})" โ€” ONE version, in parentheses, always; never a second number, never a bundle tag beside it โ€” and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flowโ€ฆ) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
480
497
  } else if (readiness.state === 'degraded') {
481
498
  const receipt = readiness.receipt || {};
482
499
  emit(`USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project. search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
@@ -0,0 +1,74 @@
1
+ // ONE definition of "what the persistent Console runtime is" and "which generation is running".
2
+ //
3
+ // #79 reported a Console that survived an update: the browser was served the new frontend from disk
4
+ // while the Node process kept its pre-update router in memory, and every relaunch said "already
5
+ // running". The reuse check compared a `sourceSha256` computed from a SINGLE file โ€”
6
+ // scripts/onboarding-console.mjs โ€” while the runtime the server actually executes is this whole
7
+ // surface: ~230 modules it imports plus the frontend it serves. Two genuinely different candidates
8
+ // whose entrypoint bytes happened to match produced byte-identical identities, so an update that
9
+ // changed console-engine.mjs, capability-registry.mjs or console/app.js was invisible to the
10
+ // launcher. The identity has to derive from something an update cannot leave behind, which is the
11
+ // installed bytes themselves โ€” all of them.
12
+ //
13
+ // #76 reported the same disease at the asset boundary: the runtime copied plugin/scripts without
14
+ // the sibling docs/ and manifest that plugin/scripts/whats-new.mjs reads, so the executable
15
+ // travelled and its assets did not. The copy list and the identity list were two separate
16
+ // enumerations of one payload; they are one list now, so an asset added to the runtime is part of
17
+ // its generation by construction.
18
+ //
19
+ // The installer hashes the staged tree with this function and the running server hashes its own
20
+ // root with this function, over this list. Neither side can define the fact differently.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import crypto from 'node:crypto';
25
+
26
+ /**
27
+ * Every path the persistent Console runtime carries, relative to both the candidate source root and
28
+ * the installed runtime root (they are laid out identically). This is simultaneously the installer's
29
+ * copy list and the generation's digest input โ€” one enumeration, so they cannot drift.
30
+ */
31
+ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
32
+ 'console',
33
+ 'scripts',
34
+ 'plugin/scripts',
35
+ // whats-new.mjs resolves its version manifest and curated notes relative to its own plugin root
36
+ // (#76). Shipping the executable without them is how the installed skill lost its release notes.
37
+ 'plugin/docs',
38
+ 'plugin/.claude-plugin',
39
+ 'data/model-catalog.json',
40
+ 'kb/brain-profile.mjs',
41
+ 'bin/install.mjs',
42
+ 'package.json',
43
+ ]);
44
+
45
+ /** The runtime's own identity file, written by the installer INTO the tree โ€” never part of its own digest. */
46
+ export const CONSOLE_RUNTIME_IDENTITY_FILE = 'runtime-identity.json';
47
+
48
+ /**
49
+ * Deterministic content digest of the runtime surface under `root`.
50
+ *
51
+ * A path that is absent is hashed as absent rather than skipped: a runtime that lost a file is a
52
+ * different generation, not the same one, and a launcher must be able to see that.
53
+ *
54
+ * @param {string} root runtime root (an installed `.console-runtime`, a marketplace clone, or a checkout)
55
+ * @returns {string} hex sha256
56
+ */
57
+ export function consoleRuntimeDigest(root) {
58
+ const hash = crypto.createHash('sha256');
59
+ const visit = (absolute, relative) => {
60
+ let stat;
61
+ try { stat = fs.lstatSync(absolute); } catch { hash.update(`absent\0${relative}\0`); return; }
62
+ if (stat.isDirectory()) {
63
+ hash.update(`d\0${relative}\0`);
64
+ let entries = [];
65
+ try { entries = fs.readdirSync(absolute).sort(); } catch { hash.update(`unreadable\0${relative}\0`); return; }
66
+ for (const entry of entries) visit(path.join(absolute, entry), `${relative}/${entry}`);
67
+ return;
68
+ }
69
+ hash.update(`f\0${relative}\0`);
70
+ try { hash.update(fs.readFileSync(absolute)); } catch { hash.update(`unreadable\0${relative}\0`); }
71
+ };
72
+ for (const relative of CONSOLE_RUNTIME_SURFACE) visit(path.join(root, relative), relative);
73
+ return hash.digest('hex');
74
+ }
@@ -37,9 +37,13 @@ import fs from 'node:fs';
37
37
  import os from 'node:os';
38
38
  import path from 'node:path';
39
39
  import { execFileSync, spawnSync } from 'node:child_process';
40
+ import { resolveRuflo, RUFLO_MISSING } from '../plugin/scripts/ruflo-bin.mjs';
40
41
 
41
42
  const HOME = os.homedir();
42
- const RUFLO = process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo');
43
+ // Issue #99: this was `process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo')`, so every
44
+ // install whose npm prefix is not the owner's (Homebrew, nvm, Volta, plain `npm -g`) was told its
45
+ // tool was missing while ruflo sat on their PATH. One resolver, shared โ€” see ruflo-bin.mjs.
46
+ const RUFLO = resolveRuflo();
43
47
  const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
44
48
 
45
49
  const argv = process.argv.slice(2);
@@ -126,6 +130,10 @@ if (has('--restore')) {
126
130
  }
127
131
 
128
132
  // โ”€โ”€ PRE-FLIGHT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
133
+ // Two different facts, so two different sentences. `null` means we looked everywhere and found
134
+ // nothing; a path that does not exist means the user pointed RUFLO_BIN somewhere empty, and naming
135
+ // that exact path back to them is the whole value of the message.
136
+ if (!RUFLO) die(RUFLO_MISSING);
129
137
  if (!fs.existsSync(RUFLO)) die(`ruflo is not at ${RUFLO.replace(HOME, '~')} โ€” install it with \`npm i -g ruflo@latest\``);
130
138
  if (!fs.existsSync(DB)) die(`no memory store at ${DB.replace(HOME, '~')} โ€” nothing to distill for this project`);
131
139
 
@@ -408,6 +408,7 @@ export function evaluateDoc(root, rel, opts = {}) {
408
408
  status: k.status ?? null,
409
409
  date: k.date ?? null,
410
410
  updated: k.updated ?? null,
411
+ updatedPinned: k.updated_pinned === true || k.updated_pinned === 'true',
411
412
  implStored: k.impl ?? null,
412
413
  verifiedStamp: k.verified ?? null,
413
414
  verifiedDigestStored: k.verified_digest ?? null,
@@ -455,7 +456,19 @@ export function evaluateDoc(root, rel, opts = {}) {
455
456
  if (Number.isFinite(d) && Number.isFinite(g)) {
456
457
  const deltaDays = Math.round((g - d) / 86400000);
457
458
  if (deltaDays >= 1) {
458
- add(BLOCK, 'stamp-lags-doc',
459
+ // `updated_pinned: true` โ€” the date is a HISTORICAL RECORD, not a currency stamp.
460
+ //
461
+ // Some documents record WHEN SOMETHING HAPPENED, not when the file was last edited.
462
+ // ADR-050's `updated: 2026-08-02` is an incident cutoff and is asserted by
463
+ // tests/unit/fix-workstream-guidance. A blanket "make the stamp match the last commit"
464
+ // rule cannot tell those apart, so it silently rewrote the pinned date โ€” putting two gates
465
+ // into direct contradiction, one demanding the pinned value and one demanding the commit
466
+ // date, with no way to satisfy both. The document itself is the only thing that knows
467
+ // which kind of date it carries, so it now says so, and --fix must never overwrite it.
468
+ if (doc.updatedPinned) {
469
+ add(WARN, 'stamp-pinned',
470
+ `updated: ${doc.updated} is PINNED (updated_pinned: true) and deliberately does not track the last commit ${docCommit.date} โ€” it records an event, not the edit`);
471
+ } else add(BLOCK, 'stamp-lags-doc',
459
472
  `updated: ${doc.updated} but the document's own last commit is ${docCommit.date} (${docCommit.sha.slice(0, 8)}) โ€” edited without touching its own stamp`);
460
473
  } else if (deltaDays <= -1) {
461
474
  // Typed ahead. WARN only: a stamp dated today on a change not yet committed is CORRECT, and
@@ -610,7 +623,7 @@ export function planFix(root, doc) {
610
623
  if (doc.dirty) blocked.push({ code: 'missing-updated', why: 'working tree is modified โ€” the last commit date is not the date of the current contents' });
611
624
  else if (doc.docCommit) changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', evidence: `git log -1 ${doc.file} โ†’ ${doc.docCommit.sha.slice(0, 8)}` });
612
625
  else blocked.push({ code: 'missing-updated', why: 'git reports no commit touching this path' });
613
- } else if (doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
626
+ } else if (!doc.updatedPinned && doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
614
627
  changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', replaces: doc.updated, evidence: `git log -1 ${doc.file} โ†’ ${doc.docCommit.sha.slice(0, 8)}` });
615
628
  }
616
629
  return { changes, blocked };
@@ -24,8 +24,12 @@ import path from 'node:path';
24
24
  import os from 'node:os';
25
25
  import { execFileSync, spawnSync } from 'node:child_process';
26
26
  import { findStores, diagnose } from './memory-doctor.mjs';
27
+ import { loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
27
28
 
28
29
  const HOME = os.homedir();
30
+ // The SAME project root learn-flush.mjs computes, by the same rule โ€” the two halves of the flush
31
+ // have to agree about which project they mean or they address different queues (issue #104).
32
+ const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
29
33
  const argv = process.argv.slice(2);
30
34
  const has = (f) => argv.includes(f);
31
35
 
@@ -93,25 +97,98 @@ function repairMemory() {
93
97
  return { ok: true, log: `repaired โ€” integrity ok, ${rowsAfter} entries intact (was ${rowsBefore}). Backup: ${backup.replace(HOME, '~')}`, backup };
94
98
  }
95
99
 
96
- /** Drain the capture queue into rUv's learner โ€” his tool, not ours. */
100
+ /**
101
+ * Drain the capture queue into rUv's learner โ€” his tool, not ours.
102
+ *
103
+ * ISSUE #104: this measured one queue and drained another, so it could only ever report "fed 0".
104
+ * The flusher was spawned with NO environment, so inside learn-flush.mjs RUVNET_LEARNING_SCOPE was
105
+ * unset and defaulted to 'project' โ€” it drained `<project>/.swarm/ruvnet-brain-learn/` while THIS
106
+ * function counted `<user>/.cache/ruvnet-brain/learn/`. `before - after` was structurally 0: it
107
+ * could not report truthfully in either direction even on a flush that worked, and the real queue
108
+ * grew forever. Two independent defaults are not an agreement.
109
+ *
110
+ * So: resolve the scope ONCE, from the same policy module learn-flush reads, derive the queue root
111
+ * FROM that scope, pass the scope (and the exact queue file) to the child explicitly, and measure
112
+ * the root that was actually drained.
113
+ *
114
+ * And LOOP. learn-flush feeds at most MAX_ACTIONS distinct actions per invocation and writes the
115
+ * remainder back on purpose (a SessionEnd hook must stay fast), so one call cannot drain a deep
116
+ * queue โ€” the reporter needed 15 rounds for 293 entries. A single call would leave a queue that
117
+ * "flushes" every time and never empties, which is the same lie in slow motion.
118
+ */
97
119
  function flushLearning() {
98
120
  const flusher = path.join(HOME, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin', 'scripts', 'learn-flush.mjs');
99
- const local = path.join(process.cwd(), 'plugin', 'scripts', 'learn-flush.mjs');
121
+ const local = path.join(PROJECT, 'plugin', 'scripts', 'learn-flush.mjs');
100
122
  const script = fs.existsSync(flusher) ? flusher : (fs.existsSync(local) ? local : null);
101
123
  if (!script) return { ok: false, log: 'learn-flush.mjs not found โ€” cannot drain the queue' };
102
124
 
103
- const queueDir = path.join(HOME, '.cache', 'ruvnet-brain', 'learn');
104
- const depth = () => {
105
- try {
106
- return fs.readdirSync(queueDir).filter((f) => f.endsWith('.jsonl'))
107
- .reduce((n, f) => n + fs.readFileSync(path.join(queueDir, f), 'utf8').split('\n').filter(Boolean).length, 0);
108
- } catch { return 0; }
125
+ const configured = process.env.RUVNET_LEARNING_SCOPE
126
+ || loadRuntimePreferences({ cwd: PROJECT }).values.learningScope;
127
+ const scope = ['off', 'project', 'user'].includes(configured) ? configured : 'project';
128
+ if (scope === 'off') {
129
+ return { ok: true, noop: true, log: 'learning is switched off for this project โ€” nothing is being captured, so there is nothing to feed' };
130
+ }
131
+
132
+ // Derived from the scope, never assumed: this is the directory the child will actually read.
133
+ const queueDir = scope === 'user'
134
+ ? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
135
+ : path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
136
+ // Displayed to a human and matched by tests, so it is normalised to forward slashes on every
137
+ // platform. Without this, Windows reports `~\.cache\ruvnet-brain\learn` while macOS and Linux
138
+ // report `~/.cache/ruvnet-brain/learn` โ€” the same location under two spellings, which is the
139
+ // exact defect class this branch has been closing (one fact, two representations).
140
+ const where = queueDir.replace(HOME, '~').split(path.sep).join('/');
141
+
142
+ const queueFiles = () => {
143
+ try { return fs.readdirSync(queueDir).filter((f) => f.endsWith('.jsonl')).map((f) => path.join(queueDir, f)); }
144
+ catch { return []; }
145
+ };
146
+ const depthOf = (f) => {
147
+ try { return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean).length; } catch { return 0; }
109
148
  };
149
+ const depth = () => queueFiles().reduce((n, f) => n + depthOf(f), 0);
150
+
110
151
  const before = depth();
111
- try { execFileSync(process.execPath, [script], { stdio: 'ignore', timeout: 600_000 }); }
112
- catch (e) { return { ok: false, log: `flush failed: ${e.message} โ€” the queue is preserved for retry` }; }
152
+ if (!before) return { ok: true, noop: true, log: `nothing queued in ${where} โ€” the learner is already caught up` };
153
+
154
+ const env = { ...process.env, RUVNET_LEARNING_SCOPE: scope, RUVNET_BRAIN_PROJECT_DIR: PROJECT };
155
+ const deadline = Date.now() + 540_000; // inside the 600s this action is allowed overall
156
+ const stalled = [];
157
+ for (const file of queueFiles()) {
158
+ let d = depthOf(file);
159
+ while (d > 0 && Date.now() < deadline) {
160
+ try {
161
+ execFileSync(process.execPath, [script], {
162
+ env: { ...env, LEARN_QUEUE: file },
163
+ stdio: 'ignore',
164
+ timeout: Math.min(120_000, Math.max(1_000, deadline - Date.now())),
165
+ });
166
+ } catch (e) { stalled.push(`${path.basename(file)}: ${String(e.message).split('\n')[0].slice(0, 80)}`); break; }
167
+ const next = depthOf(file);
168
+ // STRICT progress, or stop. learn-flush KEEPS a queue it could not feed (by design โ€” the queue
169
+ // is evidence), so a round that shrinks nothing means the learner is not accepting the work.
170
+ // Spinning on it would burn the whole budget and still report zero.
171
+ if (next >= d) { stalled.push(`${path.basename(file)}: ${d} entr${d === 1 ? 'y' : 'ies'} would not feed`); break; }
172
+ d = next;
173
+ }
174
+ }
175
+
113
176
  const after = depth();
114
- return { ok: true, log: `fed ${Math.max(0, before - after)} captured events into the learner (queue ${before} โ†’ ${after})` };
177
+ const fed = before - after;
178
+ if (fed <= 0) {
179
+ // Name the most likely cause instead of shrugging: learn-flush invokes ruflo at a FIXED path,
180
+ // so on a machine with a different npm prefix it feeds nothing and honestly keeps the queue.
181
+ const rufloAtFixedPath = fs.existsSync(path.join(HOME, '.npm-global/bin/ruflo'));
182
+ const why = stalled.length ? ` (${stalled.slice(0, 3).join('; ')})` : '';
183
+ const hint = rufloAtFixedPath ? '' : ' ruflo is not at ~/.npm-global/bin/ruflo, which is where the flusher looks for it โ€”'
184
+ + ' `npm i -g ruflo@latest` installs it there.';
185
+ return { ok: false, log: `fed 0 of ${before} queued events from ${where}${why} โ€” the queue is preserved for retry.${hint}` };
186
+ }
187
+ return {
188
+ ok: true,
189
+ log: `fed ${fed} captured events into the ${scope} learner (queue ${before} โ†’ ${after} in ${where})`
190
+ + (after ? ` โ€” ${after} remain and will drain on the next flush` : ''),
191
+ };
115
192
  }
116
193
 
117
194
  /** One training cycle, via rUv's own CLI, in the GLOBAL (cross-project) learner. */
@@ -0,0 +1,155 @@
1
+ // host-install-matrix.mjs โ€” ONE definition of "install this artifact into clean hosts and judge it".
2
+ //
3
+ // WHY THIS EXISTS. Two harnesses did this same job and disagreed about almost everything:
4
+ //
5
+ // scripts/staged-host-verifier.mjs scripts/publication-receipt.mjs (installHosts)
6
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
7
+ // modes: claude / codex / dual modes: claudeOnly / codexOnly / dual
8
+ // --local โ€ฆ --no-selfcheck --version v<X> (no --local, selfcheck ON)
9
+ // RUVNET_CLAUDE_MARKETPLACE_SOURCE RUVNET_STRICT_INSTALL=1
10
+ // RUVNET_CODEX_HOOK_TRUST_MODE=bypass RUVNET_BRAIN_PROFILE=complete
11
+ //
12
+ // They could not even be compared: one produced `fixtures.claude`, the other `hosts.claudeOnly`,
13
+ // for the identical fixture. So "the hosts passed" meant two different things depending on which
14
+ // half of the release you asked, and nothing in the system could notice they had drifted apart.
15
+ //
16
+ // Some of that difference is REAL and must survive: a STAGED check runs before publication against
17
+ // local bytes, so it points the marketplace at the unpacked package and skips selfcheck; a
18
+ // PUBLISHED check runs after, against what npm actually serves, so it resolves by version and
19
+ // installs strictly. That is one axis with two values โ€” a parameter. Everything else was accident.
20
+ //
21
+ // So: the modes are named once, the loop is written once, the verdict is classified once, and the
22
+ // only thing a caller chooses is which VARIANT it is running. Same shape as
23
+ // scripts/console-runtime-identity.mjs, where one enumeration serves both the copy list and the
24
+ // digest โ€” a fact stated once cannot drift from itself.
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { spawnSync } from 'node:child_process';
29
+
30
+ /** The three host shapes a release must survive. ONE name each, for every consumer. */
31
+ export const HOST_MODES = Object.freeze(['claude', 'codex', 'dual']);
32
+
33
+ /** Which CLIs each mode is allowed to see. A codex-only box genuinely has no `claude`. */
34
+ export const MODE_HOSTS = Object.freeze({
35
+ claude: ['claude'],
36
+ codex: ['codex'],
37
+ dual: ['claude', 'codex'],
38
+ });
39
+
40
+ /**
41
+ * The published-side receipt has always spelled these `claudeOnly` / `codexOnly` / `dual`, and that
42
+ * name is baked into current-release.json and every receipt already published โ€” renaming it would
43
+ * invalidate history for a cosmetic win. So the two spellings are reconciled HERE, once, instead of
44
+ * being silently different in two files. `publication.installed.claudeOnly` and `fixtures.claude`
45
+ * are now provably the same fixture, which is what made the two halves of a release incomparable.
46
+ */
47
+ export const RECEIPT_MODE_NAMES = Object.freeze({ claude: 'claudeOnly', codex: 'codexOnly', dual: 'dual' });
48
+ export const MODE_FROM_RECEIPT_NAME = Object.freeze({ claudeOnly: 'claude', codexOnly: 'codex', dual: 'dual' });
49
+
50
+ /**
51
+ * The two genuinely different questions, declared once instead of implied by two files.
52
+ *
53
+ * `staged` โ€” before publication, against local bytes. The marketplace is pointed at the unpacked
54
+ * package because nothing is on npm yet, and selfcheck is skipped for the same reason.
55
+ * `published` โ€” after publication, against what npm actually serves. Resolves by version and
56
+ * installs strictly, because this is the run that must match a stranger's machine.
57
+ */
58
+ export const VARIANTS = Object.freeze({
59
+ staged: Object.freeze({
60
+ installerArgs: (_v) => ['--local', '--yes', '--force', '--no-nightly-prompt',
61
+ '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline', '--no-selfcheck'],
62
+ env: ({ packageRoot }) => ({
63
+ RUVNET_CLAUDE_MARKETPLACE_SOURCE: packageRoot,
64
+ RUVNET_CODEX_HOOK_TRUST_MODE: 'bypass',
65
+ }),
66
+ }),
67
+ published: Object.freeze({
68
+ installerArgs: (version) => ['--yes', '--force', '--version', `v${version}`, '--no-nightly-prompt',
69
+ '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline'],
70
+ env: () => ({
71
+ RUVNET_STRICT_INSTALL: '1',
72
+ RUVNET_BRAIN_PROFILE: 'complete',
73
+ }),
74
+ }),
75
+ });
76
+
77
+ /** A doctor run is ACCEPTED only on a clean exit. One rule, not one per harness. */
78
+ export function classifyDoctor(result) {
79
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
80
+ if (!result.error && result.status === 0) return { accepted: true, status: 'PASS', output };
81
+ return { accepted: false, status: 'FAIL', output };
82
+ }
83
+
84
+ /** Build a PATH exposing only the CLIs this mode is entitled to see. */
85
+ export function fixturePath(mode, temp, locate) {
86
+ const bin = path.join(temp, `bin-${mode}`);
87
+ fs.mkdirSync(bin, { recursive: true });
88
+ for (const name of ['node', 'npm', ...MODE_HOSTS[mode]]) {
89
+ const target = locate(name);
90
+ if (!target) throw new Error(`${name} CLI unavailable for the ${mode} host fixture`);
91
+ fs.symlinkSync(target, path.join(bin, name));
92
+ }
93
+ return `${bin}:/usr/bin:/bin`;
94
+ }
95
+
96
+ /**
97
+ * Install `packageRoot` into a clean HOME per mode and run its doctor. Returns a result for EVERY
98
+ * mode โ€” including the ones that failed โ€” because "which host broke" is the whole diagnostic value,
99
+ * and the previous harnesses threw on the first failure and lost the rest.
100
+ *
101
+ * @returns {{verdict:'PASS'|'FAIL', fixtures:Record<string,object>, error?:string}}
102
+ */
103
+ export function runHostMatrix({ packageRoot, version, variant = 'staged', locate, temp, run = spawnSync }) {
104
+ const spec = VARIANTS[variant];
105
+ if (!spec) throw new Error(`unknown host-matrix variant: ${variant}`);
106
+ const workspace = temp || fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-host-matrix-'));
107
+ const installer = path.join(packageRoot, 'bin', 'install.mjs');
108
+ const fixtures = {};
109
+ let verdict = 'PASS';
110
+ let error;
111
+
112
+ for (const mode of HOST_MODES) {
113
+ try {
114
+ const home = path.join(workspace, `home-${mode}`);
115
+ const codexHome = path.join(home, '.codex');
116
+ const brainHome = path.join(home, '.cache', 'ruvnet-brain');
117
+ fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
118
+ if (mode !== 'claude') fs.mkdirSync(codexHome, { recursive: true });
119
+ const env = {
120
+ ...process.env,
121
+ HOME: home,
122
+ CODEX_HOME: codexHome,
123
+ RUVNET_BRAIN_HOME: brainHome,
124
+ RUVNET_BRAIN_KB: path.join(brainHome, 'kb'),
125
+ CI: 'true',
126
+ PATH: fixturePath(mode, workspace, locate),
127
+ ...spec.env({ packageRoot }),
128
+ };
129
+ const install = run(process.execPath, [installer, ...spec.installerArgs(version)], {
130
+ cwd: packageRoot, env, encoding: 'utf8', timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024,
131
+ });
132
+ if (install.error || install.status !== 0) {
133
+ throw new Error(`install failed for ${mode}: ${(install.stderr || install.error?.message || '').slice(-4000)}`);
134
+ }
135
+ const doctor = run(process.execPath, [installer, '--doctor', '--hooks'], {
136
+ cwd: packageRoot, env, encoding: 'utf8', timeout: 300_000, maxBuffer: 32 * 1024 * 1024,
137
+ });
138
+ const classified = classifyDoctor(doctor);
139
+ fixtures[mode] = { status: classified.status, doctorExit: doctor.status, version };
140
+ if (!classified.accepted) {
141
+ verdict = 'FAIL';
142
+ fixtures[mode].output = classified.output.slice(-5000);
143
+ // The output belongs IN the error. The previous harness put it there and I dropped it in
144
+ // the consolidation, so CI reported a bare "doctor failed for claude" and the one thing
145
+ // needed to act on it โ€” what the doctor actually said โ€” was thrown away.
146
+ error = error || `doctor failed for ${mode} (exit ${doctor.status}): ${classified.output.slice(-4000)}`;
147
+ }
148
+ } catch (e) {
149
+ verdict = 'FAIL';
150
+ fixtures[mode] = { status: 'FAIL', error: e.message };
151
+ error = error || e.message;
152
+ }
153
+ }
154
+ return error ? { verdict, fixtures, error } : { verdict, fixtures };
155
+ }
@@ -41,6 +41,7 @@ import { execFileSync } from 'node:child_process';
41
41
  import fs from 'node:fs';
42
42
  import os from 'node:os';
43
43
  import path from 'node:path';
44
+ import { canonicalPath, pathIdentity } from '../plugin/scripts/project-identity.mjs';
44
45
 
45
46
  const HOME = os.homedir();
46
47
  const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
@@ -132,10 +133,6 @@ const q = (db, sql) => {
132
133
  // n() returns null for "unknown" and a number only when we genuinely counted. Callers must handle null.
133
134
  const n = (r) => (r.ok ? (r.value === null || r.value === '' ? null : parseInt(r.value, 10)) : null);
134
135
 
135
- function realExisting(value) {
136
- try { return fs.realpathSync(value); } catch { return null; }
137
- }
138
-
139
136
  export function candidateRoots({
140
137
  home = HOME,
141
138
  configPath = path.join(home, '.claude', 'ruvnet-brain', 'config.json'),
@@ -150,13 +147,18 @@ export function candidateRoots({
150
147
  }
151
148
  } catch { /* absent or malformed config does not erase the common roots */ }
152
149
 
153
- const roots = new Set();
150
+ // Keyed by pathIdentity, not by the spelling. DEFAULT_SCAN_ROOTS deliberately lists both `Code`
151
+ // and `code` so neither convention is missed, and on a case-insensitive volume those are ONE
152
+ // directory โ€” which the old Set-of-strings admitted twice and then scanned, counted and summed
153
+ // twice (#107). On a case-sensitive volume they are two inodes and both are still kept.
154
+ const roots = new Map();
154
155
  const addRoot = (value) => {
155
156
  const absolute = path.isAbsolute(value) ? value : path.join(home, value);
156
- const canonical = realExisting(absolute);
157
+ const canonical = canonicalPath(absolute);
157
158
  try {
158
159
  if (canonical && fs.statSync(canonical).isDirectory()) {
159
- roots.add(canonical);
160
+ const identity = pathIdentity(canonical) ?? canonical;
161
+ if (!roots.has(identity)) roots.set(identity, canonical);
160
162
  return true;
161
163
  }
162
164
  } catch { /* missing/non-directory roots are not candidates on this machine */ }
@@ -170,12 +172,14 @@ export function candidateRoots({
170
172
  if (configuredCount > 0 && validConfigured === 0) {
171
173
  throw new Error('configured scanRoots contain no existing directories');
172
174
  }
173
- return [...roots].sort();
175
+ return [...roots.values()].sort();
174
176
  }
175
177
 
178
+ // Keyed by pathIdentity for the same reason candidateRoots is: one store reached by two names is
179
+ // one store. Values are the canonical spelling, which is what every caller reads back.
176
180
  function storesBelow(root) {
177
- const out = new Set();
178
- const canonicalRoot = realExisting(root);
181
+ const out = new Map();
182
+ const canonicalRoot = canonicalPath(root);
179
183
  if (!canonicalRoot) return out;
180
184
  const walk = (dir, depth) => {
181
185
  if (depth > 4) return;
@@ -186,8 +190,8 @@ function storesBelow(root) {
186
190
  if (e.name === 'node_modules' || e.name === '.git') continue;
187
191
  if (e.name === '.swarm') {
188
192
  const db = path.join(dir, '.swarm/memory.db');
189
- const canonical = realExisting(db);
190
- if (canonical) out.add(canonical);
193
+ const canonical = canonicalPath(db);
194
+ if (canonical) out.set(pathIdentity(canonical) ?? canonical, canonical);
191
195
  continue;
192
196
  }
193
197
  if (e.name.startsWith('.') && e.name !== '.swarm') continue;
@@ -199,28 +203,27 @@ function storesBelow(root) {
199
203
  }
200
204
 
201
205
  export function findStores(root) {
202
- const out = new Set();
206
+ const out = new Map();
207
+ const collect = (found) => { for (const [identity, db] of found) if (!out.has(identity)) out.set(identity, db); };
203
208
  if (root !== undefined) {
204
- for (const db of storesBelow(root)) out.add(db);
205
- return [...out].sort();
209
+ collect(storesBelow(root));
210
+ return [...out.values()].sort();
206
211
  }
207
212
 
208
- for (const candidate of candidateRoots()) {
209
- for (const db of storesBelow(candidate)) out.add(db);
210
- }
213
+ for (const candidate of candidateRoots()) collect(storesBelow(candidate));
211
214
  // These two stores intentionally sit outside the project-root convention. They belong only to
212
215
  // the fleet-wide no-argument scan; an explicit root must remain genuinely scoped.
213
216
  for (const extra of [path.join(HOME, '.claude/.swarm/memory.db'), path.join(HOME, 'cognitum-trader/.swarm/memory.db')]) {
214
- const canonical = realExisting(extra);
215
- if (canonical) out.add(canonical);
217
+ const canonical = canonicalPath(extra);
218
+ if (canonical) collect([[pathIdentity(canonical) ?? canonical, canonical]]);
216
219
  }
217
- return [...out].sort();
220
+ return [...out.values()].sort();
218
221
  }
219
222
 
220
223
  export function displayStoreName(db, home = HOME) {
221
- const canonicalDb = realExisting(db) || path.resolve(db);
224
+ const canonicalDb = canonicalPath(db) || path.resolve(db);
222
225
  const project = path.dirname(path.dirname(canonicalDb));
223
- const canonicalHome = realExisting(home) || path.resolve(home);
226
+ const canonicalHome = canonicalPath(home) || path.resolve(home);
224
227
  const relative = path.relative(canonicalHome, project);
225
228
  if (relative === '') return '~';
226
229
  if (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
@@ -1,3 +1,6 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
1
4
  // Managed facts are additive for existing users. Their existing row is the user overlay: it wins
2
5
  // byte-for-byte, including disablement, tier/priority changes, and local candidates. Newly shipped
3
6
  // metered rows are not auto-added; that would silently expand spend authority.
@@ -14,3 +17,34 @@ export function mergeManagedCatalog(existing, managed) {
14
17
  };
15
18
  return JSON.stringify(next) === JSON.stringify(existing) ? existing : next;
16
19
  }
20
+
21
+ // Issue #87: the merge above was correct but reachable only from offerRouterProfile(), which runs on
22
+ // the FRESH-INSTALL path alone. `--update` (and therefore Evergreen nightly) never called it, so the
23
+ // one population that needs managed additions โ€” users who already have ~/.claude/model-router/
24
+ // catalog.json โ€” could never acquire a newly shipped managed model. This is that same merge as a
25
+ // non-interactive, idempotent step the update path can call: no prompts, no TEST_MODE gate, backs the
26
+ // user's file up before it writes, and replaces it by atomic rename so a crash can never half-apply.
27
+ export function applyManagedCatalogUpdate({ routerDir, packageRoot }) {
28
+ const template = path.join(packageRoot, 'config', 'model-router', 'catalog.template.json');
29
+ const target = path.join(routerDir, 'catalog.json');
30
+ if (!fs.existsSync(template)) return { action: 'skipped', added: [], detail: 'no managed template in this package' };
31
+ const managed = JSON.parse(fs.readFileSync(template, 'utf8'));
32
+ fs.mkdirSync(routerDir, { recursive: true });
33
+ if (!fs.existsSync(target)) {
34
+ fs.copyFileSync(template, target);
35
+ return { action: 'created', added: (managed.candidates || []).map((candidate) => candidate.id), detail: target };
36
+ }
37
+ const existing = JSON.parse(fs.readFileSync(target, 'utf8'));
38
+ const merged = mergeManagedCatalog(existing, managed);
39
+ if (merged === existing) return { action: 'unchanged', added: [], detail: target };
40
+ const before = new Set((existing.candidates || []).map((candidate) => candidate.id));
41
+ fs.copyFileSync(target, `${target}.pre-managed-merge`);
42
+ const staged = `${target}.tmp-${process.pid}`;
43
+ fs.writeFileSync(staged, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
44
+ fs.renameSync(staged, target);
45
+ return {
46
+ action: 'merged',
47
+ added: merged.candidates.filter((candidate) => !before.has(candidate.id)).map((candidate) => candidate.id),
48
+ detail: target,
49
+ };
50
+ }