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
|
@@ -5,6 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { developerCoordinatorOwner } from './developer-update-owner.mjs';
|
|
8
9
|
import { SETTINGS_VERSION, validate, saveSettings } from './user-settings.mjs';
|
|
9
10
|
|
|
10
11
|
export const ownerSettingsPath = (home = os.homedir()) => path.join(home, '.config', 'ruvnet-brain', 'settings.json');
|
|
@@ -44,7 +45,7 @@ export function saveUpdateSource(source, { home = os.homedir() } = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export function automaticPath({ nodePath = process.execPath, home = os.homedir(), platform = process.platform, env = process.env } = {}) {
|
|
47
|
-
const paths = [path.dirname(nodePath), path.join(home, '.
|
|
48
|
+
const paths = [path.join(home, '.npm-global', 'bin'), path.dirname(nodePath), path.join(home, '.local', 'bin'), path.join(home, '.cargo', 'bin'), path.join(home, '.bun', 'bin')];
|
|
48
49
|
if (platform === 'win32') {
|
|
49
50
|
const system = env.SystemRoot || env.SYSTEMROOT;
|
|
50
51
|
if (system && path.win32.isAbsolute(system)) paths.push(system, path.join(system, 'System32'));
|
|
@@ -91,8 +92,16 @@ export function installedUpdater({ home = os.homedir(), platform = process.platf
|
|
|
91
92
|
return { entry, version: manifest.version, sha256 };
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
export function automaticInvocation(args, { source
|
|
95
|
+
export function automaticInvocation(args, { source, home = os.homedir(), nodePath = process.execPath,
|
|
95
96
|
packageTarget = 'ruvnet-brain@latest' } = {}) {
|
|
97
|
+
if (packageTarget === 'ruvnet-brain@latest') {
|
|
98
|
+
const coordinator = developerCoordinatorOwner({ home });
|
|
99
|
+
if (coordinator.active) {
|
|
100
|
+
if (!coordinator.ready) throw new Error(`coordinated updater is not ready: ${coordinator.reason}`);
|
|
101
|
+
return { executable: nodePath, args: [coordinator.entry, '--apply'], source: 'developer-suite', coordinator };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
source ??= updateSource({ home });
|
|
96
105
|
if (source === 'installed') {
|
|
97
106
|
if (packageTarget !== 'ruvnet-brain@latest') throw new Error('installed mode cannot select a proof tarball');
|
|
98
107
|
const installed = installedUpdater({ home });
|
|
@@ -392,7 +392,8 @@ function promiseBookkeeping() {
|
|
|
392
392
|
if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity)) continue;
|
|
393
393
|
const claim = completion.claims.find((c) => claimClosesPromise(c.text, item.text));
|
|
394
394
|
if (!claim) continue;
|
|
395
|
-
|
|
395
|
+
const verifiedCompletion = completion.verdict === 'PASS' && Boolean(claim);
|
|
396
|
+
Object.assign(item, { done: verifiedCompletion, state: verifiedCompletion ? 'completed' : item.state, doneAt: at, completionEvidence: { claim: claim.text,
|
|
396
397
|
checks: completion.verification.checks.slice(-5).map((c) => c.what),
|
|
397
398
|
transcript: String(hookInput.transcript_path || ''), sessionId: hookInput.session_id } });
|
|
398
399
|
changed = true;
|
|
@@ -289,8 +289,6 @@ function payloadInput(payload) { return parsed(payload).tool_input || {}; }
|
|
|
289
289
|
function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
|
|
290
290
|
|
|
291
291
|
// ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
|
|
292
|
-
|
|
293
|
-
|
|
294
292
|
if (isMain()) {
|
|
295
293
|
const started = Date.now();
|
|
296
294
|
const payload = normalizePayloadText(readPayload());
|
|
@@ -301,13 +299,11 @@ if (isMain()) {
|
|
|
301
299
|
// "never speak on a guess", pointed at the other decision. 'write' is the only registered route
|
|
302
300
|
// (H5 removed the dead 'bash' one — see the REGISTRY comment above), so it is the only exception.
|
|
303
301
|
if (!selected.length && EVENT !== 'write') process.exit(ALLOW);
|
|
304
|
-
|
|
305
302
|
const budgetMs = Number(process.env.RUVNET_DECISION_BUDGET_MS) || DEFAULT_BUDGET_MS;
|
|
306
303
|
const deadline = Math.min(started + budgetMs, Number(process.env.RUVNET_DECISION_DEADLINE) || Infinity);
|
|
307
304
|
// Resolved ONCE. On win32 resolveBash() can shell out to `where.exe`; four bash policies meant up
|
|
308
305
|
// to four of those per tool call, for an answer that cannot change mid-invocation.
|
|
309
306
|
BASH = resolveBash();
|
|
310
|
-
|
|
311
307
|
const trace = []; // one row per policy — surfaced by RUVNET_DECISION_TRACE=1
|
|
312
308
|
const unconsulted = []; // policies the budget cost us. NEVER silent; see reportBudget().
|
|
313
309
|
const toolInput = payloadInput(payload);
|
|
@@ -317,7 +313,6 @@ if (isMain()) {
|
|
|
317
313
|
if (why) trace.push({ id: p.id, ms: 0, skipped: why });
|
|
318
314
|
else consulted.push(p);
|
|
319
315
|
}
|
|
320
|
-
|
|
321
316
|
// ── PARALLEL, and the reason is arithmetic ─────────────────────────────────────────────────────
|
|
322
317
|
// Sequentially the gate's wall time was SUM(policies); run together it is MAX(policies). Measured
|
|
323
318
|
// on a `git push` payload by the 2026-08-13 audit: 182 + 345 + 2145..3990 + 629 + 743 ≈ 4.0-5.9s
|
|
@@ -332,9 +327,7 @@ if (isMain()) {
|
|
|
332
327
|
// A policy that exits SKIPPED (3) did not vote and said why (duplicate-gate under overload). Same
|
|
333
328
|
// two channels as a blown budget, below — never silence standing in for a verdict.
|
|
334
329
|
const selfSkipped = results.filter((r) => r.skipped === 'self');
|
|
335
|
-
|
|
336
330
|
const decision = decide(verdicts);
|
|
337
|
-
|
|
338
331
|
// Measurement follows the FINAL gate verdict; it must never delay a blocking producer.
|
|
339
332
|
const measureFinalDecision = (allowed, policies = decision.refusals) => {
|
|
340
333
|
const metricDeadline = Math.min(deadline, Date.now() + 25);
|
|
@@ -352,9 +345,7 @@ if (isMain()) {
|
|
|
352
345
|
if (!allowed) recordRefusal({ session, key, policies: policies, ts, ...attribution, deadlineAt: metricDeadline, nonBlocking: true });
|
|
353
346
|
}
|
|
354
347
|
} catch { /* a ledger must never break a tool call */ }
|
|
355
|
-
|
|
356
348
|
};
|
|
357
|
-
|
|
358
349
|
reportSelfSkipped({ session, selfSkipped });
|
|
359
350
|
if (decision.allow && shape.truncated) {
|
|
360
351
|
// OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
|
|
@@ -366,7 +357,6 @@ if (isMain()) {
|
|
|
366
357
|
process.stderr.write(`${decision.reason}\n`);
|
|
367
358
|
process.exit(REFUSE);
|
|
368
359
|
}
|
|
369
|
-
|
|
370
360
|
// Nothing refused: run the speech chokepoint and forward its envelope verbatim. It owns its own
|
|
371
361
|
// per-channel policy; this gate does not inspect or re-decide anything it says.
|
|
372
362
|
//
|
|
@@ -397,7 +387,6 @@ if (isMain()) {
|
|
|
397
387
|
if (context) process.stdout.write(context);
|
|
398
388
|
process.exit(ALLOW);
|
|
399
389
|
}
|
|
400
|
-
|
|
401
390
|
/**
|
|
402
391
|
* ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO ──────────────────────────
|
|
403
392
|
*
|
|
@@ -431,7 +420,6 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs, refused
|
|
|
431
420
|
+ `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
|
|
432
421
|
);
|
|
433
422
|
}
|
|
434
|
-
|
|
435
423
|
/** Record and print each policy that skipped itself (exit 3), with its own one-line reason. */
|
|
436
424
|
function reportSelfSkipped({ session, selfSkipped }) {
|
|
437
425
|
for (const r of selfSkipped) {
|
|
@@ -439,7 +427,6 @@ function reportSelfSkipped({ session, selfSkipped }) {
|
|
|
439
427
|
process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
|
|
440
428
|
}
|
|
441
429
|
}
|
|
442
|
-
|
|
443
430
|
/**
|
|
444
431
|
* Run one policy as a CAPTURED child. Never lets its bytes touch the real streams.
|
|
445
432
|
*
|
|
@@ -466,7 +453,6 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
|
|
|
466
453
|
if (extraArg) args.push(extraArg);
|
|
467
454
|
const left = deadline - Date.now();
|
|
468
455
|
if (left <= 0) return Promise.resolve(done({ id: p.id, skipped: 'budget' }));
|
|
469
|
-
|
|
470
456
|
return new Promise((resolve) => {
|
|
471
457
|
let settled = false;
|
|
472
458
|
let timer = null;
|
|
@@ -499,12 +485,10 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
|
|
|
499
485
|
try { child.stdin.end(payload); } catch { /* handled by the stdin error listener above */ }
|
|
500
486
|
});
|
|
501
487
|
}
|
|
502
|
-
|
|
503
488
|
function readPayload() {
|
|
504
489
|
if (process.stdin.isTTY) return '';
|
|
505
490
|
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
506
491
|
}
|
|
507
|
-
|
|
508
492
|
/** Never able to crash a caller that merely imported this (see tests/unit/entrypoint-guard-safety). */
|
|
509
493
|
function isMain() {
|
|
510
494
|
try {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Only closed, attributed npx installer entries qualify. Unknown/project/app/runtime trees never do.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const INSTALLERS = new Set(['ruvnet-brain', 'ruflo']);
|
|
5
|
+
export function cleanupNpxDuplicates({ home, globalRoot, enabled = false, run, active = null }) {
|
|
6
|
+
const root = path.join(home, '.npm/_npx'), result = { enabled, removed: [], retained: [] };
|
|
7
|
+
if (!fs.existsSync(root)) return result;
|
|
8
|
+
if (fs.realpathSync(root) !== path.resolve(root)) { result.retained.push({ directory: root, reason: 'cache root alias is unclassified' }); return result; }
|
|
9
|
+
for (const name of fs.readdirSync(root)) {
|
|
10
|
+
const directory = path.join(root, name);
|
|
11
|
+
if (!/^[a-f0-9]{16,64}$/.test(name) || !fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) continue;
|
|
12
|
+
if (fs.readdirSync(directory).some(entry => !['package.json', 'package-lock.json', 'node_modules'].includes(entry))) { result.retained.push({ directory, reason: 'unknown cache content' }); continue; }
|
|
13
|
+
let manifest;
|
|
14
|
+
try { manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')); } catch { result.retained.push({ directory, reason: 'unknown installer identity' }); continue; }
|
|
15
|
+
const dependencies = Object.keys(manifest.dependencies || {});
|
|
16
|
+
if (!dependencies.length || dependencies.some(pkg => !INSTALLERS.has(pkg) || !fs.existsSync(path.join(globalRoot, pkg, 'package.json')))) continue;
|
|
17
|
+
if (!enabled) { result.retained.push({ directory, reason: 'cleanup disabled' }); continue; }
|
|
18
|
+
let busy = true;
|
|
19
|
+
try {
|
|
20
|
+
if (active) busy = active(directory);
|
|
21
|
+
else {
|
|
22
|
+
const processes = run('ps', ['-axo', 'command='], { timeout: 15_000 });
|
|
23
|
+
if (processes.includes(directory)) busy = true;
|
|
24
|
+
else {
|
|
25
|
+
// lsof exit 1 means no files; any other failure is unknown and retains the tree.
|
|
26
|
+
const opened = run('lsof', ['-nP', '+D', directory], { timeout: 15_000, allowed: [0, 1] });
|
|
27
|
+
busy = opened.trim().length > 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch { busy = true; }
|
|
31
|
+
if (busy) { result.retained.push({ directory, reason: 'live or unknown process/open-file owner' }); continue; }
|
|
32
|
+
// Recheck exact directory type and manifest bytes at the removal boundary.
|
|
33
|
+
const second = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
|
|
34
|
+
if (JSON.stringify(second) !== JSON.stringify(manifest) || fs.lstatSync(directory).isSymbolicLink()) throw Error('npx cleanup identity changed');
|
|
35
|
+
fs.rmSync(directory, { recursive: true }); result.removed.push({ directory, installers: dependencies });
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Directory ownership protocol shared with agentic-kit. Inherited children do not release the parent.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
const brainRoot = brainHome => brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache/ruvnet-brain');
|
|
7
|
+
export function sharedLockStatus({ brainHome, alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } } = {}) {
|
|
8
|
+
const directory = path.join(brainRoot(brainHome), 'developer-update.lock');
|
|
9
|
+
try {
|
|
10
|
+
if (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) throw Error('not an owned directory');
|
|
11
|
+
const owner = JSON.parse(fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'));
|
|
12
|
+
if (!Number.isInteger(owner.pid) || owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length < 16) throw Error('invalid owner');
|
|
13
|
+
return { state: alive(owner.pid) ? 'running' : 'stale', directory, owner };
|
|
14
|
+
} catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message }; }
|
|
15
|
+
}
|
|
16
|
+
export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive } = {}) {
|
|
17
|
+
const status = sharedLockStatus({ brainHome, alive });
|
|
18
|
+
if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
|
|
19
|
+
if (status.state !== 'idle') throw Error(`developer update lock ${status.state}; ${status.directory}`);
|
|
20
|
+
fs.mkdirSync(path.dirname(status.directory), { recursive: true });
|
|
21
|
+
fs.mkdirSync(status.directory, { mode: 0o700 }); // atomic claim; racing owners get EEXIST
|
|
22
|
+
const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString() };
|
|
23
|
+
try { fs.writeFileSync(path.join(status.directory, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 }); }
|
|
24
|
+
catch (error) { fs.rmdirSync(status.directory); throw error; }
|
|
25
|
+
return { token: owner.token, ownerPid: pid, inherited: false, release() {
|
|
26
|
+
const current = sharedLockStatus({ brainHome, alive });
|
|
27
|
+
if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
|
|
28
|
+
fs.unlinkSync(path.join(status.directory, 'owner.json')); fs.rmdirSync(status.directory);
|
|
29
|
+
} };
|
|
30
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Explicitly enabled existing-owner stages. No cleanup, fresh installs or local-source conversion.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { cmpVersion as compare } from './developer-update-policy.mjs';
|
|
6
|
+
const HOME = os.homedir();
|
|
7
|
+
const read = p => JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
8
|
+
function required(p) { fs.accessSync(p, fs.constants.X_OK); return p; }
|
|
9
|
+
export function cargoInventory(root = path.join(HOME, '.cargo')) {
|
|
10
|
+
return Object.entries(read(path.join(root, '.crates2.json')).installs || {}).map(([key, value]) => {
|
|
11
|
+
const m = /^(\S+) (\S+) \(([^)]+)\)$/.exec(key);
|
|
12
|
+
if (!m) throw Error(`unattributable cargo install: ${key}`);
|
|
13
|
+
return { name: m[1], version: m[2], source: m[3], ...value };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function uvInventory(root = path.join(HOME, '.local/share/uv/tools')) {
|
|
17
|
+
if (!fs.existsSync(root)) return [];
|
|
18
|
+
return fs.readdirSync(root).filter(name => fs.existsSync(path.join(root, name, 'uv-receipt.toml'))).map(name => {
|
|
19
|
+
const receipt = fs.readFileSync(path.join(root, name, 'uv-receipt.toml'), 'utf8');
|
|
20
|
+
// Only plain unpinned registry requirements qualify; wheels, paths, git and pins stay unchanged.
|
|
21
|
+
const registry = new RegExp(`requirements\\s*=\\s*\\[\\{\\s*name\\s*=\\s*"${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*\\}\\]`).test(receipt);
|
|
22
|
+
return { name, root: path.join(root, name), registry, receipt };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function nativeOwnerPreserved(file, beforeTarget, afterTarget, home = HOME) {
|
|
26
|
+
if (afterTarget.split(path.sep).includes('node_modules')) return false;
|
|
27
|
+
if (beforeTarget === afterTarget) return true;
|
|
28
|
+
const standalone = path.join(home, '.codex/packages/standalone') + path.sep;
|
|
29
|
+
return file === path.join(home, '.local/bin/codex') && beforeTarget.startsWith(standalone) && afterTarget.startsWith(standalone);
|
|
30
|
+
}
|
|
31
|
+
export function verifyMaintenanceStage(commands, postconditions) {
|
|
32
|
+
const failures = Object.entries(postconditions).filter(([, passed]) => passed !== true).map(([name]) => name);
|
|
33
|
+
const commandFailures = commands.filter(command => command.exitCode !== 0 || command.error);
|
|
34
|
+
return { ok: failures.length === 0 && commandFailures.length === 0, postconditions, commandFailures, failures };
|
|
35
|
+
}
|
|
36
|
+
function commandOutput(run, command, args, options, stage) {
|
|
37
|
+
const result = run.receipt ? run.receipt(command, args, options) : run(command, args, options);
|
|
38
|
+
const exitCode = result?.exitCode ?? result?.status ?? result?.code;
|
|
39
|
+
const captured = { command, args, exitCode, error: result?.error || null, stdout: result?.stdout, stderr: result?.stderr || '' };
|
|
40
|
+
stage?.commands.push(captured);
|
|
41
|
+
if (!Number.isInteger(exitCode) || exitCode !== 0 || captured.error || typeof captured.stdout !== 'string') throw Error(`provider command failed or exit evidence absent: ${command} ${args[0]} (exit ${exitCode})`);
|
|
42
|
+
return captured.stdout;
|
|
43
|
+
}
|
|
44
|
+
function settleStage(stage, after, postconditions, fields = {}) {
|
|
45
|
+
const verification = verifyMaintenanceStage(stage.commands, postconditions);
|
|
46
|
+
Object.assign(stage, { after, verification, ...fields, state: verification.ok ? 'completed' : 'failed' });
|
|
47
|
+
if (!verification.ok) throw Error(`provider postconditions failed: ${stage.owner}: ${verification.failures.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
export async function maintenance(config, run, dryRun, { home = HOME, brainHome, locate = () => null, node = process.execPath, progress = () => {} } = {}) {
|
|
50
|
+
const result = { stages: [], exclusions: [] };
|
|
51
|
+
progress(result);
|
|
52
|
+
for (const [name, enabled] of Object.entries(config)) {
|
|
53
|
+
if (!['schemaVersion', 'scope', 'channel', 'cleanup', 'preservePackages', 'homebrew', 'uv', 'cargo', 'native', 'managedCallback'].includes(name)) throw Error(`unknown maintenance flag: ${name}`);
|
|
54
|
+
if (['homebrew', 'uv', 'cargo', 'native'].includes(name) && typeof enabled !== 'boolean') throw Error(`maintenance flag must be boolean: ${name}`);
|
|
55
|
+
}
|
|
56
|
+
const uvTools = config.uv ? uvInventory(path.join(home, '.local/share/uv/tools')) : [];
|
|
57
|
+
const hasEntries = directory => { try { return fs.readdirSync(directory).length > 0; } catch { return false; } };
|
|
58
|
+
if (config.homebrew && !locate('brew') && [path.join(home, '.linuxbrew/Cellar'), '/opt/homebrew/Cellar', '/usr/local/Cellar', '/home/linuxbrew/.linuxbrew/Cellar'].some(hasEntries)) throw Error('installed Homebrew formulas have no existing Homebrew manager');
|
|
59
|
+
const cratesFile = path.join(home, '.cargo/.crates2.json');
|
|
60
|
+
if (uvTools.length && !locate('uv')) throw Error('installed uv tools have no existing uv manager');
|
|
61
|
+
if (config.cargo && fs.existsSync(cratesFile) && cargoInventory(path.dirname(cratesFile)).length && !locate('cargo')) throw Error('installed Cargo tools have no existing Cargo manager');
|
|
62
|
+
if (config.native) {
|
|
63
|
+
for (const [file, args] of [[path.join(home, '.bun/bin/bun'), ['upgrade']], [path.join(home, '.local/bin/uv'), ['self', 'update']],
|
|
64
|
+
[path.join(home, '.cargo/bin/rustup'), ['self', 'update']], [path.join(home, '.local/bin/codex'), ['update']]]) {
|
|
65
|
+
if (!fs.existsSync(file)) { result.exclusions.push(`native owner absent: ${file}`); continue; }
|
|
66
|
+
const resolved = fs.realpathSync(file);
|
|
67
|
+
if (resolved.split(path.sep).includes('node_modules')) { result.exclusions.push(`npm owns native alias: ${file}`); continue; }
|
|
68
|
+
required(file);
|
|
69
|
+
const owner = fs.realpathSync(file), stage = { owner: file, originalTarget: owner, state: 'running', commands: [] }; result.stages.push(stage);
|
|
70
|
+
const before = commandOutput(run, file, ['--version'], { timeout: 30_000 }, stage); stage.before = before;
|
|
71
|
+
if (!dryRun) commandOutput(run, file, args, { timeout: 900_000 }, stage);
|
|
72
|
+
const after = commandOutput(run, file, ['--version'], { timeout: 30_000 }, stage);
|
|
73
|
+
const version = text => text.match(/\b(\d+\.\d+\.\d+(?:-[\w.-]+)?)/)?.[1];
|
|
74
|
+
const versionVerified = !!version(before) && !!version(after) && compare(version(after), version(before)) >= 0;
|
|
75
|
+
// Native launchers may re-point to a newer version, but stay in the same established root.
|
|
76
|
+
const afterOwner = fs.realpathSync(file);
|
|
77
|
+
const ownerPreserved = nativeOwnerPreserved(file, owner, afterOwner, home);
|
|
78
|
+
settleStage(stage, after, { versionVerified, ownerPreserved }, { currentTarget: afterOwner });
|
|
79
|
+
}
|
|
80
|
+
const rustup = path.join(home, '.cargo/bin/rustup');
|
|
81
|
+
if (fs.existsSync(rustup)) {
|
|
82
|
+
const stage = { owner: 'rust-toolchains', commands: [], state: 'running' }; result.stages.push(stage);
|
|
83
|
+
const before = commandOutput(run, rustup, ['toolchain', 'list'], { timeout: 30_000 }, stage); stage.before = before;
|
|
84
|
+
const channels = before.split('\n').map(l => l.split(/\s/)[0]).filter(n => /^(stable|nightly)(-|$)/.test(n));
|
|
85
|
+
if (!dryRun) for (const channel of channels) commandOutput(run, rustup, ['update', channel, '--no-self-update'], { timeout: 1_800_000 }, stage);
|
|
86
|
+
const after = commandOutput(run, rustup, ['toolchain', 'list'], { timeout: 30_000 }, stage);
|
|
87
|
+
const names = text => text.split('\n').filter(Boolean).map(line => line.split(/\s/)[0]);
|
|
88
|
+
settleStage(stage, after, { toolchainsPreserved: names(before).every(name => names(after).includes(name)) }, { channels });
|
|
89
|
+
result.exclusions.push('Pinned Rust toolchains and all local-source builds preserved');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (config.homebrew && locate('brew')) {
|
|
93
|
+
const brew = required(locate('brew'));
|
|
94
|
+
const stage = { owner: 'homebrew-formulas', commands: [], state: 'running' }; result.stages.push(stage);
|
|
95
|
+
const snapshot = () => JSON.parse(commandOutput(run, brew, ['info', '--json=v2', '--installed'], { timeout: 120_000 }, stage));
|
|
96
|
+
const before = snapshot(); stage.before = before;
|
|
97
|
+
if (!dryRun) {
|
|
98
|
+
// HOMEBREW_NO_INSTALL_CLEANUP also set in coordinator child environment.
|
|
99
|
+
commandOutput(run, brew, ['update'], { timeout: 600_000 }, stage);
|
|
100
|
+
commandOutput(run, brew, ['upgrade', '--formula'], { timeout: 3_600_000 }, stage);
|
|
101
|
+
}
|
|
102
|
+
const after = snapshot();
|
|
103
|
+
const names = x => x.formulae.map(f => f.full_name).sort();
|
|
104
|
+
const currencyChecked = after.formulae.every(formula => typeof formula.outdated === 'boolean');
|
|
105
|
+
const pinned = after.formulae.filter(formula => formula.pinned === true).map(formula => formula.full_name);
|
|
106
|
+
const outdated = after.formulae.filter(formula => formula.outdated === true && formula.pinned !== true).map(formula => formula.full_name);
|
|
107
|
+
settleStage(stage, after, { formulaOwnersPreserved: names(before).every(name => names(after).includes(name)) }, { currencyChecked, pinned, outdated,
|
|
108
|
+
currency: currencyChecked ? outdated.length ? 'updates-available' : 'manager-current' : 'unverified' });
|
|
109
|
+
if (!currencyChecked) result.exclusions.push('Homebrew execution verified; formula currency markers unavailable');
|
|
110
|
+
for (const formula of pinned) result.exclusions.push(`Homebrew pinned formula preserved: ${formula}`);
|
|
111
|
+
result.exclusions.push('Homebrew casks need their application-specific owner (VS Code is the managed callback)');
|
|
112
|
+
}
|
|
113
|
+
if (config.uv && locate('uv')) {
|
|
114
|
+
const uv = required(locate('uv'));
|
|
115
|
+
const before = uvInventory(path.join(home, '.local/share/uv/tools'));
|
|
116
|
+
const stage = { owner: 'uv-registry-tools', before, state: 'running', commands: [] }; result.stages.push(stage);
|
|
117
|
+
if (!dryRun) for (const tool of before) if (tool.registry) commandOutput(run, uv, ['tool', 'upgrade', tool.name], { timeout: 900_000 }, stage);
|
|
118
|
+
const after = uvInventory(path.join(home, '.local/share/uv/tools'));
|
|
119
|
+
if (JSON.stringify(before.map(t => t.name).sort()) !== JSON.stringify(after.map(t => t.name).sort())) throw Error('uv tool install set changed');
|
|
120
|
+
for (const tool of before.filter(t => !t.registry)) {
|
|
121
|
+
if (after.find(t => t.name === tool.name)?.receipt !== tool.receipt) throw Error(`uv source changed: ${tool.name}`);
|
|
122
|
+
result.exclusions.push(`uv source/pin preserved: ${tool.name}`);
|
|
123
|
+
}
|
|
124
|
+
// Provider's --outdated result is captured rather than asserting public registry currency for local wheels.
|
|
125
|
+
const currency = commandOutput(run, uv, ['tool', 'list', '--outdated'], { timeout: 180_000 }, stage);
|
|
126
|
+
settleStage(stage, after, { toolSetPreserved: JSON.stringify(before.map(t => t.name).sort()) === JSON.stringify(after.map(t => t.name).sort()),
|
|
127
|
+
sourceRequirementsPreserved: before.filter(t => !t.registry).every(t => after.find(a => a.name === t.name)?.receipt === t.receipt), currencyChecked: typeof currency === 'string' }, { currency });
|
|
128
|
+
}
|
|
129
|
+
if (config.cargo && fs.existsSync(path.join(home, '.cargo/.crates2.json')) && locate('cargo')) {
|
|
130
|
+
const root = path.join(home, '.cargo'), cargo = required(locate('cargo'));
|
|
131
|
+
const before = cargoInventory(root);
|
|
132
|
+
const stage = { owner: 'cargo-registry-tools', before, state: 'running', commands: [], targets: [] }; result.stages.push(stage);
|
|
133
|
+
for (const tool of before) {
|
|
134
|
+
if (tool.source !== 'registry+https://github.com/rust-lang/crates.io-index' || tool.version_req) {
|
|
135
|
+
result.exclusions.push(`Cargo source/pin preserved: ${tool.name}`); continue;
|
|
136
|
+
}
|
|
137
|
+
for (const bin of tool.bins) required(path.join(root, 'bin', bin));
|
|
138
|
+
const response = await fetch(`https://crates.io/api/v1/crates/${encodeURIComponent(tool.name)}`, { signal: AbortSignal.timeout(45_000), headers: { 'User-Agent': 'ruvnet-brain/developer-update' } });
|
|
139
|
+
if (!response.ok) throw Error(`Cargo registry HTTP ${response.status}`);
|
|
140
|
+
const metadata = await response.json();
|
|
141
|
+
const target = metadata.crate?.max_stable_version;
|
|
142
|
+
if (!target) throw Error(`Cargo release unverified: ${tool.name}`);
|
|
143
|
+
stage.targets.push({ name: tool.name, target, registryOk: response.ok });
|
|
144
|
+
if (!dryRun && compare(target, tool.version) > 0) {
|
|
145
|
+
const args = ['install', tool.name, '--force', '--root', root];
|
|
146
|
+
if (tool.all_features) args.push('--all-features');
|
|
147
|
+
if (tool.no_default_features) args.push('--no-default-features');
|
|
148
|
+
if (tool.features?.length) args.push('--features', tool.features.join(','));
|
|
149
|
+
if (tool.target) args.push('--target', tool.target);
|
|
150
|
+
commandOutput(run, cargo, args, { timeout: 3_600_000 }, stage);
|
|
151
|
+
const live = cargoInventory(root).find(t => t.name === tool.name);
|
|
152
|
+
if (!live || compare(live.version, target) < 0 || compare(live.version, tool.version) < 0) throw Error(`Cargo update not verified: ${tool.name}`);
|
|
153
|
+
for (const bin of tool.bins) required(path.join(root, 'bin', bin));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const after = cargoInventory(root);
|
|
157
|
+
for (const tool of before.filter(t => !t.source.startsWith('registry+'))) {
|
|
158
|
+
const live = after.find(t => t.name === tool.name);
|
|
159
|
+
if (!live || live.version !== tool.version || live.source !== tool.source) throw Error(`Cargo local source changed: ${tool.name}`);
|
|
160
|
+
}
|
|
161
|
+
settleStage(stage, after, { registryTargetsVerified: stage.targets.every(t => t.registryOk && (dryRun || compare(after.find(a => a.name === t.name)?.version || '', t.target) >= 0)),
|
|
162
|
+
sourcesPreserved: before.filter(t => !t.source.startsWith('registry+')).every(t => after.some(a => a.name === t.name && a.version === t.version && a.source === t.source)),
|
|
163
|
+
noDowngrade: before.every(t => after.some(a => a.name === t.name && compare(a.version, t.version) >= 0)) });
|
|
164
|
+
}
|
|
165
|
+
if (config.managedCallback) {
|
|
166
|
+
const file = config.managedCallback;
|
|
167
|
+
if (typeof file !== 'string' || !path.isAbsolute(file) || ![path.join(home, '.codex/scripts/'), path.join(brainHome || home, 'scripts/')].some(root => file.startsWith(root)) || !file.endsWith('.mjs')) throw Error('managed callback must be an existing Codex scripts .mjs owner');
|
|
168
|
+
required(file);
|
|
169
|
+
const stage = { owner: file, state: 'running', commands: [] }; result.stages.push(stage);
|
|
170
|
+
const receipt = JSON.parse(commandOutput(run, node, [file, dryRun ? '--check' : '--apply'], { timeout: 3_600_000 }, stage));
|
|
171
|
+
if (receipt.ok !== true || !receipt.before || !receipt.after) throw Error('managed callback did not prove success with source snapshots');
|
|
172
|
+
stage.before = receipt.before;
|
|
173
|
+
settleStage(stage, receipt.after, { callbackSnapshotsPresent: !!receipt.before && !!receipt.after, callbackVerified: receipt.ok === true }, { receipt });
|
|
174
|
+
}
|
|
175
|
+
for (const name of ['homebrew', 'uv', 'cargo']) if (config[name] && !result.stages.some(s => s.owner.startsWith(name === 'homebrew' ? 'homebrew' : name))) result.exclusions.push(`${name} has no existing attributable owner/tool inventory`);
|
|
176
|
+
for (const name of ['homebrew', 'uv', 'cargo', 'native']) if (!config[name]) result.exclusions.push(`${name} maintenance disabled`);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A configured coordinator owns automatic mutation. Its immutable scheduler module is the real door.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
7
|
+
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
8
|
+
export function developerCoordinatorOwner({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
|
|
9
|
+
const config = path.join(brainHome, 'developer-update-config.json'), registration = path.join(brainHome, 'scheduler/registration.json');
|
|
10
|
+
let record;
|
|
11
|
+
try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch { /* handled below */ }
|
|
12
|
+
const configured = fs.existsSync(config) || record?.mode === 'developer-suite';
|
|
13
|
+
if (!configured) return { active: false, ready: false };
|
|
14
|
+
const entry = record?.updateModules?.['developer-update.mjs'];
|
|
15
|
+
try {
|
|
16
|
+
if (record?.mode !== 'developer-suite' || !path.isAbsolute(entry?.path || '') || !/^[a-f0-9]{64}$/.test(entry?.sha256 || '')
|
|
17
|
+
|| hash(entry.path) !== entry.sha256 || hash(record.runnerPath) !== record.runnerSha256) throw Error('registered source is absent or changed');
|
|
18
|
+
if (!record.updateModules?.['plugin-artifact-proof.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw Error('new coordinator artifact closure is incomplete');
|
|
19
|
+
const generation = EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || record.updateModules?.[name]);
|
|
20
|
+
if (generation.some(name => !record.updateModules?.[name])) throw Error('coordinator execution closure is incomplete');
|
|
21
|
+
for (const module of Object.values(record.updateModules)) if (hash(module.path) !== module.sha256) throw Error('registered module closure changed');
|
|
22
|
+
return { active: true, ready: true, entry: entry.path, node: record.nodePath, registration, sourceSha256: entry.sha256 };
|
|
23
|
+
} catch (error) { return { active: true, ready: false, reason: error.message }; }
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs']);
|
|
2
|
+
// Shared stack classification and ordering: installations are selected, never manufactured.
|
|
3
|
+
export const FAMILY = /^(ruflo|ruvnet-brain|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|flow-nexus|agent-browser|agent-browser-mcp|agentic-flow|agentic-qe|agentic-robotics|agentic-payments|agentdb|ruv-swarm|@pacphi\/agentic-kit$|@ruvector\/|@claude-flow\/|@metaharness\/|@agentic-robotics\/)/;
|
|
4
|
+
export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);
|
|
5
|
+
export const REVIEWED_INSTALL_SCRIPTS = Object.freeze(['ruflo', 'agentic-qe', '@claude-flow/cli', 'better-sqlite3', 'hnswlib-node', 'agentdb', 'agentic-flow', 'argon2', 'onnxruntime-node', 'sharp', 'protobufjs', '@google/genai', 'tldjs', 'vibium', 'agent-browser', '@anthropic-ai/claude-code', 'opencode-ai']);
|
|
6
|
+
export function cmpVersion(a, b) {
|
|
7
|
+
const parse = value => {
|
|
8
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?(?:\+[\w.-]+)?$/.exec(value);
|
|
9
|
+
if (!m) throw Error(`uncomparable version: ${value}`);
|
|
10
|
+
return [m.slice(1, 4).map(Number), m[4]?.split('.')];
|
|
11
|
+
};
|
|
12
|
+
const [ac, ap] = parse(a), [bc, bp] = parse(b);
|
|
13
|
+
for (let i = 0; i < 3; i++) if (ac[i] !== bc[i]) return Math.sign(ac[i] - bc[i]);
|
|
14
|
+
if (!ap || !bp) return ap ? -1 : bp ? 1 : 0;
|
|
15
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
16
|
+
if (ap[i] === undefined || bp[i] === undefined) return ap[i] === undefined ? -1 : 1;
|
|
17
|
+
if (ap[i] === bp[i]) continue;
|
|
18
|
+
const an = /^\d+$/.test(ap[i]), bn = /^\d+$/.test(bp[i]);
|
|
19
|
+
return an && bn ? Math.sign(Number(ap[i]) - Number(bp[i])) : an !== bn ? an ? -1 : 1 : ap[i] < bp[i] ? -1 : 1;
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
export function normalizeNpmDistTags(value) {
|
|
24
|
+
// npm 11 emits an object for a queried field; npm 12 retains its singleton record array.
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
if (value.length !== 1) throw Error('npm dist-tags has ambiguous or empty records');
|
|
27
|
+
[value] = value;
|
|
28
|
+
}
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
30
|
+
|| !Object.keys(value).length || Object.values(value).some(version => typeof version !== 'string' || !version)) {
|
|
31
|
+
throw Error('npm dist-tags record is unverified');
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
export function pickTargetTag(tags, want = 'latest', defaultTag = 'latest') {
|
|
36
|
+
const candidates = [...new Set([want, defaultTag])].filter(tag => typeof tags?.[tag] === 'string');
|
|
37
|
+
candidates.sort((a, b) => cmpVersion(tags[b], tags[a]));
|
|
38
|
+
return { tag: candidates[0] || null, target: tags?.[candidates[0]] || null };
|
|
39
|
+
}
|
|
40
|
+
export function selectTag(name, current, tags, channel = 'latest') {
|
|
41
|
+
if (!['latest', 'alpha'].includes(channel)) throw Error('unsupported update channel');
|
|
42
|
+
// Kit's installed release contract uses next; never convert it to an npm alpha alias.
|
|
43
|
+
tags = normalizeNpmDistTags(tags);
|
|
44
|
+
const wanted = name === '@pacphi/agentic-kit' ? 'next' : FAMILY.test(name) && channel === 'alpha' ? 'alpha' : 'latest';
|
|
45
|
+
const { tag, target: version } = pickTargetTag(tags, wanted);
|
|
46
|
+
if (!tag) throw Error(`no allowed release tag: ${name}`);
|
|
47
|
+
return { tag, version, upgrade: cmpVersion(version, current) > 0, ahead: cmpVersion(version, current) < 0 };
|
|
48
|
+
}
|