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.
Files changed (36) hide show
  1. package/README.md +15 -19
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +14 -2
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/console/CONTRACT.md +47 -3
  6. package/console/app.js +112 -20
  7. package/package.json +4 -2
  8. package/plugin/.claude-plugin/plugin.json +1 -1
  9. package/plugin/.codex-plugin/plugin.json +1 -1
  10. package/plugin/commands/brain-console.md +13 -1
  11. package/plugin/commands/configure.md +2 -2
  12. package/plugin/commands/rnb.md +13 -1
  13. package/plugin/commands/rnbc.md +13 -1
  14. package/plugin/commands/rvbc.md +13 -1
  15. package/plugin/commands/rvcb.md +13 -1
  16. package/plugin/scripts/automatic-update.mjs +11 -2
  17. package/plugin/scripts/continuation-gate.mjs +2 -1
  18. package/plugin/scripts/decision-gate.mjs +0 -16
  19. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  20. package/plugin/scripts/developer-update-lock.mjs +30 -0
  21. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  22. package/plugin/scripts/developer-update-owner.mjs +24 -0
  23. package/plugin/scripts/developer-update-policy.mjs +48 -0
  24. package/plugin/scripts/developer-update.mjs +435 -0
  25. package/plugin/scripts/host-update.mjs +6 -3
  26. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  27. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  28. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  29. package/scripts/codex-managed-terminal.mjs +1 -1
  30. package/scripts/console-runtime-identity.mjs +2 -0
  31. package/scripts/console-suite-update.mjs +117 -0
  32. package/scripts/hook-qualify-core.mjs +29 -0
  33. package/scripts/onboarding-console.mjs +41 -12
  34. package/scripts/release-qualification-contract.mjs +14 -0
  35. package/scripts/remedy-registry.mjs +1 -0
  36. 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, '.npm-global', 'bin'), path.join(home, '.local', 'bin')];
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 = updateSource(), home = os.homedir(), nodePath = process.execPath,
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
- Object.assign(item, { done: true, state: 'completed', doneAt: at, completionEvidence: { claim: claim.text,
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
+ }