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.
- package/README.md +1 -1
- package/bin/install.mjs +109 -42
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/continuation-gate.mjs +41 -1
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +15 -2
- package/scripts/health-repair.mjs +88 -11
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/memory-doctor.mjs +26 -23
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/publication-receipt.mjs +10 -1
- package/scripts/release-transaction-provider.mjs +30 -2
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/staged-host-verifier.mjs +27 -54
- package/scripts/sync-version.mjs +10 -10
|
@@ -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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
package/scripts/doc-currency.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
157
|
+
const canonical = canonicalPath(absolute);
|
|
157
158
|
try {
|
|
158
159
|
if (canonical && fs.statSync(canonical).isDirectory()) {
|
|
159
|
-
|
|
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
|
|
178
|
-
const canonicalRoot =
|
|
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 =
|
|
190
|
-
if (canonical) out.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
215
|
-
if (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 =
|
|
224
|
+
const canonicalDb = canonicalPath(db) || path.resolve(db);
|
|
222
225
|
const project = path.dirname(path.dirname(canonicalDb));
|
|
223
|
-
const canonicalHome =
|
|
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
|
+
}
|