ruvnet-brain 4.5.17 → 4.6.0
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 +15 -19
- package/bin/developer-update.mjs +3 -0
- package/bin/install.mjs +14 -2
- package/bin/nightly-refresh.mjs +17 -1
- package/console/CONTRACT.md +47 -3
- package/console/app.js +112 -20
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +13 -1
- package/plugin/commands/configure.md +2 -2
- package/plugin/commands/rnb.md +13 -1
- package/plugin/commands/rnbc.md +13 -1
- package/plugin/commands/rvbc.md +13 -1
- package/plugin/commands/rvcb.md +13 -1
- package/plugin/scripts/automatic-update.mjs +11 -2
- package/plugin/scripts/continuation-gate.mjs +2 -1
- package/plugin/scripts/decision-gate.mjs +0 -16
- package/plugin/scripts/developer-update-cleanup.mjs +38 -0
- package/plugin/scripts/developer-update-lock.mjs +30 -0
- package/plugin/scripts/developer-update-maintenance.mjs +178 -0
- package/plugin/scripts/developer-update-owner.mjs +24 -0
- package/plugin/scripts/developer-update-policy.mjs +48 -0
- package/plugin/scripts/developer-update.mjs +435 -0
- package/plugin/scripts/host-update.mjs +6 -3
- package/plugin/scripts/nightly-scheduler.mjs +55 -16
- package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/codex-managed-terminal.mjs +1 -1
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/console-suite-update.mjs +117 -0
- package/scripts/hook-qualify-core.mjs +29 -0
- package/scripts/onboarding-console.mjs +41 -12
- package/scripts/release-qualification-contract.mjs +14 -0
- package/scripts/remedy-registry.mjs +1 -0
- package/scripts/stack-sync.mjs +19 -132
package/scripts/stack-sync.mjs
CHANGED
|
@@ -36,6 +36,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
36
36
|
import fs from 'node:fs';
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import path from 'node:path';
|
|
39
|
+
import { runDeveloperUpdate, readDeveloperUpdateConfig } from '../plugin/scripts/developer-update.mjs';
|
|
40
|
+
import { FAMILY, PLUGIN_MARKETPLACES, cmpVersion, pickTargetTag, normalizeNpmDistTags } from '../plugin/scripts/developer-update-policy.mjs';
|
|
41
|
+
export { FAMILY, PLUGIN_MARKETPLACES, cmpVersion, pickTargetTag, normalizeNpmDistTags } from '../plugin/scripts/developer-update-policy.mjs';
|
|
39
42
|
|
|
40
43
|
const HOME = os.homedir();
|
|
41
44
|
|
|
@@ -78,9 +81,9 @@ const RECEIPT = path.join(HOME, '.cache/ruvnet-brain/stack-sync-receipt.json');
|
|
|
78
81
|
const PLUGINS_DIR = path.join(HOME, '.claude', 'plugins');
|
|
79
82
|
|
|
80
83
|
// The tag policy. ONE table — the single source of truth for "what SHOULD be installed".
|
|
81
|
-
// The
|
|
82
|
-
//
|
|
83
|
-
export const TAG_POLICY = { ruflo: '
|
|
84
|
+
// The recommended default is latest. The canonical owner config can explicitly select alpha.
|
|
85
|
+
// Unlisted packages still get DEFAULT_TAG; installed Kit retains next/latest ordering.
|
|
86
|
+
export const TAG_POLICY = { ruflo: 'latest', '@claude-flow/cli': 'latest' };
|
|
84
87
|
export const DEFAULT_TAG = 'latest';
|
|
85
88
|
|
|
86
89
|
// Resolve a package's TARGET version to the NEWEST of its candidate tags — the policy tag AND latest —
|
|
@@ -91,19 +94,10 @@ export const DEFAULT_TAG = 'latest';
|
|
|
91
94
|
// 3.32.0 @alpha target, so it correctly refused to downgrade and never chased 3.32.7). Considering both
|
|
92
95
|
// tags and taking the higher version fixes that in EITHER direction: alpha-leads → track alpha;
|
|
93
96
|
// latest-leads → track latest. You are never left behind the newest thing rUv actually published.
|
|
94
|
-
export function pickTargetTag(tags, want, defaultTag = DEFAULT_TAG) {
|
|
95
|
-
if (!tags) return { tag: null, target: null };
|
|
96
|
-
const candidates = [...new Set([want, defaultTag])].filter((t) => tags[t]);
|
|
97
|
-
let tag = null, target = null;
|
|
98
|
-
for (const t of candidates) {
|
|
99
|
-
if (target === null || cmpVersion(tags[t], target) > 0) { tag = t; target = tags[t]; }
|
|
100
|
-
}
|
|
101
|
-
return { tag, target };
|
|
102
|
-
}
|
|
103
97
|
|
|
104
98
|
// What counts as "the stack": an explicit allow-list pattern, not a loose scope match, so a stray
|
|
105
99
|
// package can never be swept into a global install by accident.
|
|
106
|
-
|
|
100
|
+
|
|
107
101
|
|
|
108
102
|
// The plugin side of the same allow-list intent (ISSUE #22). Plugin identifiers are marketplace
|
|
109
103
|
// names (`ruflo-core`, `ruvnet-brain`, `cog-beehive-monitor`), NOT npm package names, so FAMILY
|
|
@@ -114,7 +108,7 @@ export const FAMILY = /^(ruflo|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|fl
|
|
|
114
108
|
// stray third-party plugin can never be swept into the RuvNet stack by accident. FAMILY is still
|
|
115
109
|
// applied as a secondary matcher, so a future rUv plugin shipped through a different marketplace is
|
|
116
110
|
// still counted.
|
|
117
|
-
|
|
111
|
+
|
|
118
112
|
|
|
119
113
|
const log = (m) => console.log(m);
|
|
120
114
|
const die = (m) => { console.error(`\n FAILED: ${m}`); process.exit(1); };
|
|
@@ -123,30 +117,7 @@ const die = (m) => { console.error(`\n FAILED: ${m}`); process.exit(1); };
|
|
|
123
117
|
// Prerelease-aware per semver: a prerelease sorts BEFORE its release (3.28.0-alpha.1 < 3.28.0),
|
|
124
118
|
// and numeric identifiers compare numerically — alpha.9 < alpha.10, which a string compare gets
|
|
125
119
|
// backwards, and a string compare is precisely the bug this whole file exists to kill.
|
|
126
|
-
|
|
127
|
-
const split = (v) => {
|
|
128
|
-
const [core, pre] = String(v).split('-');
|
|
129
|
-
return [core.split('.').map((n) => parseInt(n, 10) || 0), pre ? pre.split('.') : null];
|
|
130
|
-
};
|
|
131
|
-
const [ac, ap] = split(a);
|
|
132
|
-
const [bc, bp] = split(b);
|
|
133
|
-
for (let i = 0; i < 3; i++) {
|
|
134
|
-
const d = (ac[i] || 0) - (bc[i] || 0);
|
|
135
|
-
if (d !== 0) return d < 0 ? -1 : 1;
|
|
136
|
-
}
|
|
137
|
-
if (!ap && !bp) return 0;
|
|
138
|
-
if (ap && !bp) return -1;
|
|
139
|
-
if (!ap && bp) return 1;
|
|
140
|
-
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
141
|
-
const x = ap[i], y = bp[i];
|
|
142
|
-
if (x === undefined) return -1;
|
|
143
|
-
if (y === undefined) return 1;
|
|
144
|
-
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
|
145
|
-
if (nx && ny) { const d = parseInt(x, 10) - parseInt(y, 10); if (d) return d < 0 ? -1 : 1; }
|
|
146
|
-
else if (x !== y) return x < y ? -1 : 1;
|
|
147
|
-
}
|
|
148
|
-
return 0;
|
|
149
|
-
}
|
|
120
|
+
|
|
150
121
|
export const isBehind = (installed, target) => cmpVersion(installed, target) < 0;
|
|
151
122
|
|
|
152
123
|
export function installedVersion(pkg, lib = GLOBAL_LIB) {
|
|
@@ -219,7 +190,7 @@ function listInstalled({ lib = GLOBAL_LIB, pluginsDir = PLUGINS_DIR } = {}) {
|
|
|
219
190
|
function registryTags(pkg) {
|
|
220
191
|
const r = spawnSync('npm', ['view', pkg, 'dist-tags', '--json'], { encoding: 'utf8', timeout: 30000 });
|
|
221
192
|
if (r.status !== 0 || !r.stdout) return null;
|
|
222
|
-
try { return JSON.parse(r.stdout); } catch { return null; }
|
|
193
|
+
try { return normalizeNpmDistTags(JSON.parse(r.stdout)); } catch { return null; }
|
|
223
194
|
}
|
|
224
195
|
|
|
225
196
|
// A second copy of a stack package in the npx cache can only ever SHADOW the global one.
|
|
@@ -251,13 +222,13 @@ export function findShadows(npxCache = NPX_CACHE, lib = GLOBAL_LIB) {
|
|
|
251
222
|
// isBehind — this function assigns a state label, it does not compare versions itself except through
|
|
252
223
|
// the single comparator. Extracted so audit() (CLI, may exit) and auditModel() (embedders, never
|
|
253
224
|
// exits) share ONE classification, never two that can drift.
|
|
254
|
-
export function classify(pkgs) {
|
|
225
|
+
export function classify(pkgs, { channel = readDeveloperUpdateConfig().channel } = {}) {
|
|
255
226
|
return pkgs.map((p) => {
|
|
256
227
|
// Installed records prove presence, not marketplace currency.
|
|
257
228
|
if (p.source === 'plugin') {
|
|
258
229
|
return { ...p, tag: 'plugin', target: null, state: p.installed ? 'INSTALLED_UNVERIFIED' : 'BROKEN', evidence: 'local install record only; marketplace revision not checked' };
|
|
259
230
|
}
|
|
260
|
-
const want =
|
|
231
|
+
const want = p.name === '@pacphi/agentic-kit' ? 'next' : channel === 'alpha' && FAMILY.test(p.name) ? 'alpha' : DEFAULT_TAG;
|
|
261
232
|
const tags = registryTags(p.name);
|
|
262
233
|
// Newest of {policy tag, latest} — see pickTargetTag: "track @alpha" alone pinned the core behind
|
|
263
234
|
// @latest on 2026-07-18. A package with no alpha tag falls through to latest.
|
|
@@ -348,95 +319,11 @@ function report({ rows, shadows, stale }) {
|
|
|
348
319
|
return { behind: rows.filter((r) => r.state === 'BEHIND'), broken: rows.filter((r) => r.state === 'BROKEN'), stale };
|
|
349
320
|
}
|
|
350
321
|
|
|
351
|
-
function
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}, null, 2));
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// EXCLUSIVE LOCK. (Adversarial review, 2026-07-14.) Nothing stopped the nightly job and a manual
|
|
360
|
-
// run (or two Claude Code windows) from both running `npm install -g` on the same package at the
|
|
361
|
-
// same instant. Two concurrent installs interleaving in one node_modules dir is how you get a
|
|
362
|
-
// half-written package — which is almost certainly how @ruvector/edge-net ended up present-but-
|
|
363
|
-
// versionless with an orphaned `sharp` inside it. O_EXCL is atomic; a stale lock from a crashed
|
|
364
|
-
// run is reclaimed after 20 minutes (longer than the npm timeout).
|
|
365
|
-
const LOCK = path.join(HOME, '.cache/ruvnet-brain/stack-sync.lock');
|
|
366
|
-
function acquireLock() {
|
|
367
|
-
fs.mkdirSync(path.dirname(LOCK), { recursive: true });
|
|
368
|
-
try {
|
|
369
|
-
fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
|
|
370
|
-
} catch (e) {
|
|
371
|
-
if (e.code !== 'EEXIST') throw e;
|
|
372
|
-
let held = {};
|
|
373
|
-
try { held = JSON.parse(fs.readFileSync(LOCK, 'utf8')); } catch { /* unparseable = stale */ }
|
|
374
|
-
const ageMin = (Date.now() - (held.at ?? 0)) / 60000;
|
|
375
|
-
if (ageMin < 20) {
|
|
376
|
-
die(`another stack-sync is running (pid ${held.pid}, started ${ageMin.toFixed(1)}m ago).\n` +
|
|
377
|
-
` Refusing to run two installers at once — that is how packages get half-written.`);
|
|
378
|
-
}
|
|
379
|
-
fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
380
|
-
}
|
|
381
|
-
const release = () => { try { fs.unlinkSync(LOCK); } catch { /* already gone */ } };
|
|
382
|
-
process.on('exit', release);
|
|
383
|
-
process.on('SIGINT', () => { release(); process.exit(130); });
|
|
384
|
-
process.on('SIGTERM', () => { release(); process.exit(143); });
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function sync({ dryRun = false } = {}) {
|
|
388
|
-
if (!dryRun) acquireLock();
|
|
389
|
-
const a = audit();
|
|
390
|
-
const { behind, broken, stale } = report(a);
|
|
391
|
-
const toInstall = [...behind, ...broken.filter((r) => r.target)].map((r) => `${r.name}@${r.target}`);
|
|
392
|
-
|
|
393
|
-
if (!toInstall.length && !stale.length) {
|
|
394
|
-
log(` No npm repair selected. Audit: ${JSON.stringify(summarizeAudit(a))}`);
|
|
395
|
-
if (!dryRun) writeReceipt(a, [], []);
|
|
396
|
-
process.exitCode = summarizeAudit(a).exitCode;
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
if (dryRun) {
|
|
400
|
-
if (toInstall.length) log(` would install: ${toInstall.join(' ')}`);
|
|
401
|
-
if (stale.length) log(` would purge ${stale.length} stale npx shadow(s)`);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// INSTALL FIRST, PURGE SECOND. (Adversarial review, 2026-07-14.) The first version purged the
|
|
406
|
-
// shadows before installing — so a failed install left the user with their shadows gone AND the
|
|
407
|
-
// global not yet fixed: strictly WORSE than when they started, with no way back. A repair step
|
|
408
|
-
// that can leave you worse off than not running it is not a repair.
|
|
409
|
-
if (toInstall.length) {
|
|
410
|
-
log(` installing: ${toInstall.join(' ')}`);
|
|
411
|
-
const r = spawnSync('npm', ['install', '-g', '--prefix', PREFIX, ...toInstall],
|
|
412
|
-
{ stdio: 'inherit', timeout: 15 * 60 * 1000 });
|
|
413
|
-
if (r.status !== 0) die(`npm install -g exited ${r.status}; the stack is NOT synced. Shadows left untouched.`);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const purged = [];
|
|
417
|
-
for (const s of stale) {
|
|
418
|
-
// Purge the whole npx dir: it is a disposable resolution cache (npm re-creates it on demand),
|
|
419
|
-
// and a mixed-version dir is exactly how a stale transitive copy survives a targeted delete.
|
|
420
|
-
try { fs.rmSync(s.dir, { recursive: true, force: true }); purged.push(`${s.name}@${s.version}`); } catch { /* best effort */ }
|
|
421
|
-
}
|
|
422
|
-
if (purged.length) log(` purged ${purged.length} stale shadow(s): ${purged.join(', ')}`);
|
|
423
|
-
|
|
424
|
-
// VERIFY AGAINST THE DISK. An installer that trusts its own exit code is a hope, not a guarantee:
|
|
425
|
-
// the old nightly printed "auto-update finished cleanly" from npm's status alone, which is exactly
|
|
426
|
-
// how a stack rots while every log line insists it is healthy.
|
|
427
|
-
const wrong = [];
|
|
428
|
-
for (const r of [...behind, ...broken]) {
|
|
429
|
-
if (!r.target) continue;
|
|
430
|
-
const now = installedVersion(r.name);
|
|
431
|
-
if (now !== r.target) wrong.push(`${r.name}: expected ${r.target}, disk says ${now ?? 'MISSING'}`);
|
|
432
|
-
}
|
|
433
|
-
if (wrong.length) die(`npm reported success but THE DISK DISAGREES:\n - ${wrong.join('\n - ')}`);
|
|
434
|
-
|
|
435
|
-
const after = audit();
|
|
436
|
-
writeReceipt(after, toInstall, purged);
|
|
437
|
-
process.exitCode = summarizeAudit(after).exitCode;
|
|
438
|
-
log(` Remaining audit: ${JSON.stringify(summarizeAudit(after))}`);
|
|
439
|
-
log(`\n synced ${toInstall.length} package(s); purged ${purged.length} shadow(s); verified against disk.`);
|
|
322
|
+
async function sync({ dryRun = false } = {}) {
|
|
323
|
+
const config = readDeveloperUpdateConfig();
|
|
324
|
+
const receipt = await runDeveloperUpdate({ mode: dryRun ? 'check' : 'apply', config });
|
|
325
|
+
log(`Coordinated update ${receipt.state}; ${receipt.steps.length} installed package owners checked.`);
|
|
326
|
+
if (receipt.coverage.notes.length) log(`Excluded or preserved: ${receipt.coverage.notes.join('; ')}`);
|
|
440
327
|
}
|
|
441
328
|
|
|
442
329
|
// Importable as a module by the tests; only acts when run as a CLI.
|
|
@@ -451,13 +338,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('stack-sync.mjs'))
|
|
|
451
338
|
if (summary.shadows) log(' Cached copies are present; inspect invocation paths before removing any cache. No copies removed by audit.');
|
|
452
339
|
process.exitCode = summary.exitCode;
|
|
453
340
|
} else if (args.includes('--sync')) {
|
|
454
|
-
sync({ dryRun: args.includes('--dry-run') });
|
|
341
|
+
sync({ dryRun: args.includes('--dry-run') }).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
455
342
|
} else {
|
|
456
343
|
log(`
|
|
457
344
|
stack-sync — one global copy of the RuvNet stack. Correct. Provable.
|
|
458
345
|
|
|
459
346
|
--audit report drift; exit 1 if anything is behind, broken, or shadowed
|
|
460
|
-
--sync
|
|
347
|
+
--sync update existing owners through the shared coordinator; cleanup is explicit
|
|
461
348
|
--dry-run with --sync: say what it would do, change nothing
|
|
462
349
|
|
|
463
350
|
Tag policy: ${JSON.stringify(TAG_POLICY)}; everything else @${DEFAULT_TAG}
|