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
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env node
2
+ // One existing-install owner. Does not install schedulers or remove plugin generations.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import crypto from 'node:crypto';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { cmpVersion as compare, selectTag, FAMILY, PLUGIN_MARKETPLACES, REVIEWED_INSTALL_SCRIPTS, EXECUTION_MODULES } from './developer-update-policy.mjs';
10
+ import { acquireDeveloperLock, sharedLockStatus } from './developer-update-lock.mjs';
11
+ import { maintenance } from './developer-update-maintenance.mjs';
12
+ import { cleanupNpxDuplicates } from './developer-update-cleanup.mjs';
13
+ import { pinnedClaudeTarget, verifyClaudeArtifact } from './plugin-artifact-proof.mjs';
14
+ export { compare, selectTag, sharedLockStatus };
15
+ const HOME = os.homedir();
16
+ const PREFIX = path.join(HOME, '.npm-global');
17
+ const ROOT = path.join(PREFIX, 'lib/node_modules');
18
+ const NODE = process.execPath;
19
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
20
+ const CACHE = path.join(HOME, '.cache/ruvnet-brain');
21
+ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
22
+ const read = file => JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ const exists = file => fs.existsSync(file);
24
+ const inside = (parent, child) => child.startsWith(`${parent}${path.sep}`);
25
+ export function atomic(file, value) {
26
+ fs.mkdirSync(path.dirname(file), { recursive: true });
27
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`;
28
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
29
+ fs.renameSync(tmp, file);
30
+ }
31
+ function invoke(command, args, { cwd = HOME, timeout = 600_000, allowed = [0], env = process.env, capture = false } = {}) {
32
+ const r = spawnSync(command, args, { cwd, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
33
+ env: { ...env, PATH: [...new Set([path.dirname(NODE), path.join(env.npm_config_prefix || PREFIX, process.platform === 'win32' ? '' : 'bin'), ...(env.PATH || '').split(path.delimiter)])].join(path.delimiter),
34
+ CI: '1', DISABLE_AUTOUPDATER: '1', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never', GH_PROMPT_DISABLED: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_AUTO_UPDATE: '1', RUVNET_BRAIN_HOME: env.RUVNET_BRAIN_HOME || CACHE } });
35
+ if (!capture && (r.error || !allowed.includes(r.status))) throw Error(`${path.basename(command)} ${args[0]}: ${r.error?.message || `exit ${r.status}`}: ${(r.stderr || r.stdout || '').trim().slice(-600)}`);
36
+ return { stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), exitCode: r.status, error: r.error?.message || null };
37
+ }
38
+ const invokeText = (command, args, options) => invoke(command, args, options).stdout;
39
+ export function claudeProviderCommand(command, { prefix, run = invokeText, platform = process.platform, arch = process.arch } = {}) {
40
+ const root = prefix && (exists(path.join(prefix, 'lib/node_modules')) ? path.join(prefix, 'lib/node_modules') : path.join(prefix, 'node_modules'));
41
+ const owner = root && path.join(root, '@anthropic-ai/claude-code');
42
+ if (!command || !owner) return { command };
43
+ let ownerStat;
44
+ try { ownerStat = fs.lstatSync(owner); } catch (error) { if (error.code === 'ENOENT') return { command }; throw error; }
45
+ if (!ownerStat.isDirectory() || fs.realpathSync(owner) !== owner) throw Error('Claude npm package owner is aliased or unverified');
46
+ const manifest = read(path.join(owner, 'package.json'));
47
+ if (manifest.name !== '@anthropic-ai/claude-code' || manifest.bin?.claude !== 'bin/claude.exe') return { command };
48
+ const canonicalEntry = path.join(owner, 'bin/claude.exe'), stat = fs.lstatSync(canonicalEntry);
49
+ if (stat.size > 4096) return { command };
50
+ const owned = !stat.isSymbolicLink() && stat.isFile() && fs.realpathSync(owner) === owner && fs.realpathSync(canonicalEntry) === canonicalEntry && fs.realpathSync(command) === canonicalEntry;
51
+ const placeholderSha256 = owned && hash(fs.readFileSync(canonicalEntry));
52
+ if (placeholderSha256 !== '6d7abae055d3b598281300a6c835086dec81bf3048f8a2294c5d3e50c8830d7b') throw Error('Claude canonical entry is an unrecognized or unowned placeholder');
53
+ const name = `@anthropic-ai/claude-code-${platform}-${arch}`, nativeOwner = path.join(owner, 'node_modules', name);
54
+ if (!fs.lstatSync(nativeOwner).isDirectory() || fs.realpathSync(nativeOwner) !== nativeOwner) throw Error('Claude existing native package owner mismatch');
55
+ const native = read(path.join(nativeOwner, 'package.json'));
56
+ if (manifest.optionalDependencies?.[name] !== manifest.version || native.name !== name || native.version !== manifest.version || !Array.isArray(native.os) || !native.os.includes(platform) || !Array.isArray(native.cpu) || !native.cpu.includes(arch) || fs.realpathSync(nativeOwner) !== nativeOwner) throw Error('Claude existing native owner/version mismatch');
57
+ const nativePath = path.join(nativeOwner, platform === 'win32' ? 'claude.exe' : 'claude'), nativeStat = fs.lstatSync(nativePath);
58
+ if (!nativeStat.isFile() || nativeStat.isSymbolicLink() || fs.realpathSync(nativePath) !== nativePath) throw Error('Claude existing native executable owner mismatch');
59
+ fs.accessSync(nativePath, fs.constants.X_OK);
60
+ const versionOutput = run(nativePath, ['--version'], { timeout: 30_000 }).trim();
61
+ if (versionOutput !== `${manifest.version} (Claude Code)`) throw Error('Claude existing native version probe mismatch');
62
+ return { command: nativePath, canonicalEntry, nativePath, version: manifest.version,
63
+ reason: 'stock canonical placeholder; verified existing native binary in the same npm owner', proof: { placeholderSha256, nativeOwner, versionOutput } };
64
+ }
65
+ export function identity(location, prefix = PREFIX, { platform = process.platform } = {}) {
66
+ const root = exists(path.join(prefix, 'lib/node_modules')) ? path.join(prefix, 'lib/node_modules') : path.join(prefix, 'node_modules');
67
+ // Linked source checkouts may be unavailable to an unattended host. Never open their target.
68
+ if (fs.lstatSync(location).isSymbolicLink()) {
69
+ const name = path.relative(root, location).split(path.sep).join('/');
70
+ if (!/^(?:@[\w.-]+\/)?[\w.-]+$/.test(name) || location !== path.join(root, name)) throw Error(`invalid global link identity: ${location}`);
71
+ const rawLink = fs.readlinkSync(location);
72
+ return { name, version: null, location, prefix, manifestSha256: null, realLocation: null,
73
+ localSource: true, rawLink, linkTarget: path.resolve(path.dirname(location), rawLink), launchers: [] };
74
+ }
75
+ const manifestFile = path.join(location, 'package.json');
76
+ const manifest = read(manifestFile);
77
+ if (!/^(?:@[\w.-]+\/)?[\w.-]+$/.test(manifest.name) || location !== path.join(root, manifest.name)) throw Error(`invalid global identity: ${location}`);
78
+ const realLocation = fs.realpathSync(location);
79
+ const localSource = realLocation !== location;
80
+ const bins = typeof manifest.bin === 'string' ? { [manifest.name.split('/').at(-1)]: manifest.bin } : manifest.bin || {};
81
+ const launchers = Object.entries(bins).map(([name, target]) => {
82
+ if ((!/^[^/\\\0]+$/.test(name) || ['.', '..'].includes(name)) || typeof target !== 'string') throw Error(`invalid bin identity: ${manifest.name}`);
83
+ const launcher = platform === 'win32' ? path.join(prefix, `${name}.cmd`) : path.join(prefix, 'bin', name);
84
+ let real = null;
85
+ try { real = fs.realpathSync(launcher); } catch { /* absent executable is unverified */ }
86
+ const expected = path.resolve(location, target);
87
+ if (!inside(location, expected)) throw Error(`escaping executable: ${manifest.name}`);
88
+ return { name, launcher, real, expected, owned: real !== null && (inside(location, real) || (platform === 'win32' && fs.readFileSync(launcher, 'utf8').replaceAll('\\', '/').includes(path.relative(prefix, expected).replaceAll('\\', '/')))) };
89
+ });
90
+ return { name: manifest.name, version: manifest.version, location, prefix,
91
+ manifestSha256: hash(fs.readFileSync(manifestFile)), realLocation, localSource, launchers };
92
+ }
93
+ export function discover(root = ROOT, prefix = PREFIX, scope = 'all') {
94
+ if (![path.join(prefix, 'lib/node_modules'), path.join(prefix, 'node_modules')].includes(root)) throw Error('new prefix/root refused');
95
+ const locations = [];
96
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
97
+ if (entry.name.startsWith('.')) continue;
98
+ if (entry.name.startsWith('@') && entry.isDirectory()) {
99
+ for (const child of fs.readdirSync(path.join(root, entry.name))) locations.push(path.join(root, entry.name, child));
100
+ } else locations.push(path.join(root, entry.name));
101
+ }
102
+ return locations.filter(p => fs.lstatSync(p).isSymbolicLink() || exists(path.join(p, 'package.json'))).map(p => identity(p, prefix))
103
+ .filter(p => FAMILY.test(p.name) || scope === 'all' && (p.localSource || p.launchers.length))
104
+ .sort((a, b) => a.name === '@pacphi/agentic-kit' ? 1 : b.name === '@pacphi/agentic-kit' ? -1 : a.name.localeCompare(b.name));
105
+ }
106
+ function verifyPackage(before, expected, run) {
107
+ const after = identity(before.location, before.prefix);
108
+ if (after.name !== before.name || after.version !== expected || compare(after.version, before.version) < 0) throw Error(`installed identity/version not verified: ${before.name}`);
109
+ for (const launcher of after.launchers) {
110
+ if (!launcher.owned) throw Error(`launcher ownership lost: ${before.name}/${launcher.name}`);
111
+ // MCP-only launchers may block on --version; they are verified through manifest/owned executable, not started.
112
+ fs.accessSync(launcher.real, process.platform === 'win32' ? fs.constants.R_OK : fs.constants.X_OK);
113
+ }
114
+ if (['ruflo', '@pacphi/agentic-kit', '@openai/codex', '@anthropic-ai/claude-code', 'agent-browser'].includes(after.name)) {
115
+ const main = after.launchers[0];
116
+ if (!main) throw Error(`missing primary launcher: ${after.name}`);
117
+ const versionOutput = process.platform === 'win32' && /\.(?:[cm]?js)$/.test(main.expected)
118
+ ? run(process.execPath, [main.expected, '--version'], { timeout: 30_000 }) : run(main.launcher, ['--version'], { timeout: 30_000 });
119
+ if (!versionOutput.includes(expected)) throw Error(`primary executable version unverified: ${after.name}`);
120
+ }
121
+ return after;
122
+ }
123
+ export function upgradePackage(before, tags, scripts = REVIEWED_INSTALL_SCRIPTS, { run = invokeText, dryRun = false, channel = 'latest', npm = NPM, preservePackages = [] } = {}) {
124
+ // Snapshot identity must still exist and own the original paths before any installer runs.
125
+ if (before.localSource && before.rawLink !== undefined) {
126
+ if (!fs.lstatSync(before.location).isSymbolicLink() || fs.readlinkSync(before.location) !== before.rawLink) throw Error(`source link changed before preservation: ${before.name}`);
127
+ const live = identity(before.location, before.prefix);
128
+ if (!live.localSource || live.name !== before.name || live.rawLink !== before.rawLink || live.linkTarget !== before.linkTarget) throw Error(`source link changed before preservation: ${before.name}`);
129
+ return { name: before.name, state: 'local-source-preserved', reason: 'linked source currency unverified; target not opened', before, after: live };
130
+ }
131
+ const live = identity(before.location, before.prefix);
132
+ if (live.name !== before.name || live.manifestSha256 !== before.manifestSha256) throw Error(`source changed before install: ${before.name}`);
133
+ if (live.localSource) return { name: before.name, state: 'local-source-preserved', before, after: live };
134
+ if (live.launchers.some(b => !b.real)) throw Error(`absent launcher: ${before.name}`);
135
+ if (live.launchers.some(b => !b.owned)) return { name: before.name, state: 'shadowed-preserved', before, after: live };
136
+ const target = selectTag(before.name, before.version, tags, channel);
137
+ if (preservePackages.includes(before.name)) return { name: before.name, state: 'local-modification-preserved', reason: 'preservedLocalModification', target, before, after: live };
138
+ if (!target.upgrade || dryRun) return { name: before.name, state: target.upgrade ? 'update-available' : target.ahead ? 'ahead-preserved' : 'current', target, before, after: live };
139
+ const spec = `${before.name}@${target.tag}`;
140
+ run(npm, ['install', '-g', '--prefix', before.prefix, `--allow-scripts=${scripts.join(',')}`, spec]);
141
+ return { name: before.name, state: 'updated', target, before, after: verifyPackage(before, target.version, run) };
142
+ }
143
+ export function pluginScopes(file, { unattended = false } = {}) {
144
+ if (!exists(file)) return [];
145
+ return Object.entries(read(file).plugins || {}).flatMap(([id, entries]) => entries.map(p => {
146
+ if (!['user', 'project', 'local', 'managed'].includes(p.scope)) throw Error(`unknown plugin scope: ${id}`);
147
+ if (['project', 'local'].includes(p.scope) && (!p.projectPath || !path.isAbsolute(p.projectPath) || !unattended && !exists(p.projectPath))) throw Error(`missing plugin project: ${id}`);
148
+ return { id, scope: p.scope, projectPath: p.projectPath || null, installPath: p.installPath, version: p.version, gitCommitSha: p.gitCommitSha || null };
149
+ }));
150
+ }
151
+ export function scopeKey(p) { return `${p.id}\0${p.scope}\0${p.projectPath || ''}`; }
152
+ function pluginFlags(scopes = [], home = HOME) {
153
+ const files = [path.join(home, '.claude/settings.json'), path.join(home, '.claude/settings.local.json'), path.join(home, '.codex/config.toml')];
154
+ for (const project of new Set(scopes.map(p => p.projectPath).filter(Boolean))) {
155
+ files.push(path.join(project, '.claude/settings.json'), path.join(project, '.claude/settings.local.json'));
156
+ }
157
+ const values = {};
158
+ for (const file of [...new Set(files)].filter(exists)) {
159
+ const text = fs.readFileSync(file, 'utf8');
160
+ values[file] = file.endsWith('.json') ? read(file).enabledPlugins || {} : text.split('\n').filter(l => /\[plugins\.|enabled\s*=/.test(l)).join('\n');
161
+ }
162
+ return values;
163
+ }
164
+ export function marketplaceRemoteCommit(owner, run) {
165
+ if (owner?.source !== 'github' || typeof owner.repo !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(owner.repo)) throw Error('unsupported marketplace GitHub owner');
166
+ const declared = owner.ref ?? owner.branch ?? 'HEAD';
167
+ if (typeof declared !== 'string' || !/^(?:HEAD|(?:refs\/heads\/)?[A-Za-z0-9][A-Za-z0-9._/-]*)$/.test(declared) || /\.\.|@\{|\/\/|\.lock(?:\/|$)|[/.]$/.test(declared)) throw Error('unsupported marketplace ref');
168
+ if (declared.startsWith('refs/') && !declared.startsWith('refs/heads/')) throw Error('unsupported marketplace ref');
169
+ const ref = declared === 'HEAD' || declared.startsWith('refs/heads/') ? declared : `refs/heads/${declared}`;
170
+ const url = `https://github.com/${owner.repo}.git`;
171
+ const output = run('git', ['ls-remote', '--exit-code', url, ref], { timeout: 30_000 }).trim();
172
+ const lines = output.split('\n');
173
+ const match = lines.length === 1 ? /^([a-f0-9]{40})\s+(\S+)$/i.exec(lines[0]) : null;
174
+ if (!match || match[2] !== ref) throw Error('marketplace remote commit response is ambiguous or unverified');
175
+ return { commit: match[1].toLowerCase(), url, ref };
176
+ }
177
+ export function resolvePluginTarget(plugin, { home = HOME, run = invokeText } = {}) {
178
+ try {
179
+ const marketplace = plugin.id.split('@').at(-1), name = plugin.id.slice(0, plugin.id.lastIndexOf('@'));
180
+ const knownOwner = read(path.join(home, '.claude/plugins/known_marketplaces.json'))[marketplace];
181
+ const location = knownOwner?.installLocation;
182
+ if (!location || !path.isAbsolute(location)) throw Error('marketplace owner path absent');
183
+ const directory = fs.statSync(location).isDirectory() ? location : path.dirname(location);
184
+ const catalog = fs.statSync(location).isFile() ? location : [path.join(directory, '.claude-plugin/marketplace.json'), path.join(directory, 'marketplace.json')].find(exists);
185
+ if (!catalog) throw Error('marketplace catalogue absent');
186
+ const catalogSha256 = hash(fs.readFileSync(catalog));
187
+ const entry = read(catalog).plugins?.find(p => p.name === name);
188
+ if (!entry) throw Error('installed plugin absent from catalogue');
189
+ let version = typeof entry.version === 'string' && entry.version !== 'unknown' ? entry.version : null;
190
+ let commit = null, catalogCommit = null, remote = null, authority = 'version', proof = 'catalog-version';
191
+ if (typeof entry.source === 'object' && entry.source !== null) {
192
+ if (!['url', 'github'].includes(entry.source.source)) throw Error('unsupported remote plugin source');
193
+ if (entry.source.sha !== undefined && !/^[a-f0-9]{40}$/i.test(entry.source.sha)) throw Error('malformed pinned plugin commit');
194
+ if (/^[a-f0-9]{40}$/i.test(entry.source.sha || '')) {
195
+ commit = entry.source.sha.toLowerCase(); authority = 'commit'; proof = 'catalog-pinned-commit';
196
+ }
197
+ } else if (typeof entry.source === 'string' && entry.source.startsWith('./')) {
198
+ const source = path.resolve(directory, entry.source);
199
+ if (source !== directory && !inside(directory, source)) throw Error('plugin source escapes marketplace');
200
+ const manifest = path.join(source, '.claude-plugin/plugin.json');
201
+ if (!version && exists(manifest)) { const declared = read(manifest).version; version = declared && declared !== 'unknown' ? declared : null; }
202
+ try {
203
+ const resolved = run('git', ['-C', directory, 'rev-parse', 'HEAD'], { timeout: 10_000 }).trim();
204
+ if (/^[a-f0-9]{40}$/i.test(resolved)) catalogCommit = resolved.toLowerCase();
205
+ } catch { /* a refreshed archive can lack Git metadata */ }
206
+ let semanticVersion = false;
207
+ try { semanticVersion = !!version && compare(version, version) === 0; } catch { /* hash versions use commit authority */ }
208
+ if (semanticVersion) { authority = 'version'; proof = 'published-plugin-version'; }
209
+ else {
210
+ if (!catalogCommit && knownOwner?.source?.source === 'github') { remote = marketplaceRemoteCommit(knownOwner.source, run); catalogCommit = remote.commit; }
211
+ commit = catalogCommit; authority = 'commit'; proof = remote ? 'known-marketplace-remote-commit' : 'refreshed-marketplace-commit';
212
+ }
213
+ } else throw Error('unsupported plugin source shape');
214
+ if (!version && !commit) throw Error('opaque or unpinned plugin target');
215
+ if (hash(fs.readFileSync(catalog)) !== catalogSha256) throw Error('marketplace catalogue changed during identity resolution');
216
+ return { supported: true, version, commit, catalogCommit, authority, proof, remote, catalog, catalogSha256, source: entry.source };
217
+ } catch (error) { return { supported: false, reason: error.message }; }
218
+ }
219
+ export function pluginTarget(plugin, home = HOME) { return resolvePluginTarget(plugin, { home }).version || null; }
220
+ export function pluginUpdateDecision(plugin, target) {
221
+ if (!target.supported) return { state: 'UNSUPPORTED', reason: target.reason };
222
+ if (target.version && plugin.version && plugin.version !== 'unknown' && target.version !== plugin.version) {
223
+ try { if (compare(target.version, plugin.version) < 0) return { state: 'AHEAD', reason: 'published plugin version is older' }; }
224
+ catch { if (!target.commit) return { state: 'UNSUPPORTED', reason: 'plugin version ordering is opaque' }; }
225
+ }
226
+ if (target.authority === 'commit' || target.commit) {
227
+ return { state: plugin.gitCommitSha?.toLowerCase() === target.commit ? 'CURRENT' : 'UPDATE_AVAILABLE', proof: target.proof };
228
+ }
229
+ return { state: target.version === plugin.version ? 'CURRENT' : 'UPDATE_AVAILABLE', proof: target.proof,
230
+ sourceCommitMatched: target.catalogCommit ? plugin.gitCommitSha?.toLowerCase() === target.catalogCommit : null };
231
+ }
232
+ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, scope, locate, unattended = false, artifactProvider = pinnedClaudeTarget }) {
233
+ const installed = path.join(home, '.claude/plugins/installed_plugins.json');
234
+ const inventoryOptions = { unattended };
235
+ const all = pluginScopes(installed, inventoryOptions), before = all.filter(p => scope === 'all' || PLUGIN_MARKETPLACES.has(p.id.split('@').at(-1)));
236
+ const writable = before.filter(p => p.scope !== 'managed' && !(unattended && ['project', 'local'].includes(p.scope))), flags = pluginFlags(writable, home);
237
+ const provider = writable.length ? claudeProviderCommand(locate('claude'), { prefix, run }) : { command: locate('claude') };
238
+ const claude = provider.command;
239
+ if (writable.length && !claude) throw Error('installed Claude plugins have no existing Claude command');
240
+ const steps = [], artifactCache = new Map();
241
+ const markets = [...new Set(writable.map(p => p.id.split('@').at(-1)))];
242
+ if (!dryRun) for (const name of markets) run(claude, ['plugin', 'marketplace', 'update', name]);
243
+ for (const p of before) {
244
+ if (!writable.includes(p)) {
245
+ const reason = p.scope === 'managed' ? 'managed scope belongs to its administrator' : 'unattended project settings unverified; original scope preserved';
246
+ steps.push({ id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, after: p, state: 'UNSUPPORTED', reason });
247
+ notes.push(`plugin target unsupported; preserved: ${p.id} (${p.scope}): ${reason}`);
248
+ continue;
249
+ }
250
+ const target = resolvePluginTarget(p, { home, run });
251
+ let artifactTarget = null, artifactProof = null;
252
+ if (target.supported && target.authority === 'commit' && target.proof === 'catalog-pinned-commit' && p.gitCommitSha?.toLowerCase() !== target.commit) {
253
+ artifactTarget = await artifactProvider(target.source, target.commit, { cache: artifactCache });
254
+ artifactProof = verifyClaudeArtifact(artifactTarget, p.installPath);
255
+ target.version = artifactTarget.manifest.version || null;
256
+ }
257
+ let decision = p.scope === 'managed' ? { state: 'UNSUPPORTED', reason: 'managed scope belongs to its administrator' } : pluginUpdateDecision(artifactProof?.actualVersion ? { ...p, version: artifactProof.actualVersion } : p, target);
258
+ if (artifactProof?.ok && decision.state !== 'AHEAD') decision = { state: 'CURRENT', proof: 'pinned-claude-artifact', sourceCommitMatched: false, actualClaudeSourceMatched: true, providerMetadataDiscrepancy: { recordedCommit: p.gitCommitSha, pinnedCommit: target.commit } };
259
+ const step = { artifactProof, id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, target, ...decision }; steps.push(step);
260
+ if (decision.state === 'UNSUPPORTED') { notes.push(`plugin target unsupported; preserved: ${p.id}: ${decision.reason}`); continue; }
261
+ if (decision.state === 'AHEAD') { notes.push(`ahead plugin preserved: ${p.id}`); continue; }
262
+ if (dryRun || decision.state === 'CURRENT') { step.after = p; continue; }
263
+ run(claude, ['plugin', 'update', p.id, '--scope', p.scope, '--json'], { cwd: p.projectPath || home });
264
+ const live = pluginScopes(installed, inventoryOptions).find(item => scopeKey(item) === scopeKey(p));
265
+ if (artifactTarget && live) { artifactProof = verifyClaudeArtifact(artifactTarget, live.installPath); step.artifactProof = artifactProof; }
266
+ const exactCommit = target.commit && live?.gitCommitSha?.toLowerCase() === target.commit;
267
+ if (!live || (target.commit ? !exactCommit && !artifactProof?.ok : live.version !== target.version)) throw Error(`plugin target not verified: ${p.id}`);
268
+ if (target.commit && !exactCommit) Object.assign(step, { sourceCommitMatched: false, actualClaudeSourceMatched: true, providerMetadataDiscrepancy: { recordedCommit: live.gitCommitSha, pinnedCommit: target.commit } });
269
+ Object.assign(step, { state: 'UPDATED', after: live });
270
+ }
271
+ const after = pluginScopes(installed, inventoryOptions).filter(p => before.some(b => scopeKey(b) === scopeKey(p)));
272
+ if (JSON.stringify(all.map(scopeKey).sort()) !== JSON.stringify(pluginScopes(installed, inventoryOptions).map(scopeKey).sort())) throw Error('plugin install set changed');
273
+ if (JSON.stringify(before.map(scopeKey).sort()) !== JSON.stringify(after.map(scopeKey).sort())) throw Error('plugin install scopes changed');
274
+ for (const p of before.filter(p => !writable.includes(p))) if (JSON.stringify(p) !== JSON.stringify(after.find(a => scopeKey(a) === scopeKey(p)))) throw Error(`preserved plugin record changed: ${p.id}`);
275
+ if (JSON.stringify(flags) !== JSON.stringify(pluginFlags(writable, home))) throw Error('plugin enablement changed');
276
+ for (const p of after.filter(p => writable.some(b => scopeKey(b) === scopeKey(p)))) if (!exists(p.installPath)) throw Error(`plugin artifact missing: ${p.id}`);
277
+ const codex = locate('codex');
278
+ if (codex) {
279
+ const result = JSON.parse(run(codex, ['plugin', 'marketplace', 'list', '--json'], { timeout: 30_000 }));
280
+ const git = (result.marketplaces || []).filter(m => m.marketplaceSource?.sourceType === 'git' && (scope === 'all' || PLUGIN_MARKETPLACES.has(m.name)));
281
+ if (!dryRun) for (const m of git) run(codex, ['plugin', 'marketplace', 'upgrade', m.name, '--json']);
282
+ const help = run(codex, ['plugin', '--help'], { timeout: 30_000 });
283
+ if (/^\s+update\s/m.test(help)) throw Error('Codex plugin update now exists; review scope-aware policy before enabling');
284
+ notes.push('Codex has no plugin update command; configured Git catalogues refreshed, installed generations preserved');
285
+ }
286
+ if (JSON.stringify(flags) !== JSON.stringify(pluginFlags(writable, home))) throw Error('Codex plugin enablement changed');
287
+ return { before, after, steps, provider };
288
+ }
289
+ function knowledge(run, dryRun, runId, { brainHome, root, node }) {
290
+ const kb = path.join(brainHome, 'kb'), updater = path.join(kb, 'forge-update.mjs');
291
+ if (!exists(updater)) throw Error('existing Brain KB updater absent; fresh install refused');
292
+ const file = path.join(brainHome, `nightly-suite-corpus-${runId}.json`);
293
+ function check() {
294
+ const started = Date.now();
295
+ run(node, [updater, '--check', '--result-file', file], { timeout: 180_000, allowed: [0, 10] });
296
+ const receipt = read(file);
297
+ if (receipt.kind !== 'ruvnet-brain-check-result' || Date.parse(receipt.recordedAt) < started - 1000) throw Error('fresh corpus check receipt absent');
298
+ return receipt;
299
+ }
300
+ const before = check();
301
+ if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED') return { before, after: before, state: before.currencyVerdict === 'CURRENT' ? 'current' : 'ahead-preserved' };
302
+ if (before.currencyVerdict !== 'UPDATE_AVAILABLE') throw Error(`corpus currency unverified: ${before.currencyVerdict}`);
303
+ if (dryRun) return { before, state: 'update-available' };
304
+ const installer = path.join(root, 'ruvnet-brain/bin/install.mjs');
305
+ if (!exists(installer)) throw Error('existing Brain installer absent');
306
+ run(node, [installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: 1_800_000 });
307
+ const after = check();
308
+ if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error('corpus update failed to converge');
309
+ return { before, after, state: 'updated' };
310
+ }
311
+ export function developerUpdatePaths({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
312
+ return { home, brainHome, config: path.join(brainHome, 'developer-update-config.json'),
313
+ receipt: path.join(brainHome, 'nightly-suite-update.json'), lock: path.join(brainHome, 'developer-update.lock') };
314
+ }
315
+ export function validateDeveloperUpdateConfig(value = {}) {
316
+ const defaults = { schemaVersion: 1, channel: 'latest', scope: 'ruvnet', homebrew: false, uv: false, cargo: false, native: false, managedCallback: null, cleanup: false, preservePackages: [] };
317
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !Object.hasOwn(defaults, k))) throw Error('unknown developer update configuration');
318
+ const config = { ...defaults, ...value };
319
+ if (config.schemaVersion !== 1 || !['latest', 'alpha'].includes(config.channel) || !['ruvnet', 'all'].includes(config.scope)) throw Error('invalid developer update channel/scope');
320
+ for (const key of ['homebrew', 'uv', 'cargo', 'native', 'cleanup']) if (typeof config[key] !== 'boolean') throw Error(`invalid ${key} flag`);
321
+ if (!Array.isArray(config.preservePackages) || config.preservePackages.some(name => typeof name !== 'string' || !/^(?:@[\w.-]+\/)?[\w.-]+$/.test(name))) throw Error('invalid preservePackages');
322
+ if (config.managedCallback !== null && (typeof config.managedCallback !== 'string' || !path.isAbsolute(config.managedCallback))) throw Error('invalid maintenance callback');
323
+ return config;
324
+ }
325
+ export function readDeveloperUpdateConfig(options = {}) {
326
+ const paths = developerUpdatePaths(options);
327
+ return validateDeveloperUpdateConfig(exists(paths.config) ? read(paths.config) : {});
328
+ }
329
+ export function writeDeveloperUpdateConfig(config, options = {}) {
330
+ const value = validateDeveloperUpdateConfig(config);
331
+ atomic(developerUpdatePaths(options).config, value);
332
+ return value;
333
+ }
334
+ export function readDeveloperUpdateReceipt(options = {}) {
335
+ const file = developerUpdatePaths(options).receipt;
336
+ if (!exists(file)) return null;
337
+ const value = read(file);
338
+ if (value.schemaVersion !== 1 || value.kind !== 'nightly-suite-update') throw Error('invalid developer update receipt');
339
+ return value;
340
+ }
341
+ export function locateExecutable(name, { env = process.env, platform = process.platform } = {}) {
342
+ const suffixes = platform === 'win32' ? ['', '.exe', '.cmd'] : [''];
343
+ for (const directory of (env.PATH || '').split(path.delimiter).filter(Boolean)) for (const suffix of suffixes) {
344
+ const file = path.resolve(directory, name + suffix);
345
+ try { fs.accessSync(file, fs.constants.X_OK); if (fs.statSync(file).isFile()) return file; } catch { /* another installed owner */ }
346
+ }
347
+ return null;
348
+ }
349
+ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env } = {}) {
350
+ if (!['check', 'apply'].includes(mode)) throw Error('mode must be check or apply');
351
+ const paths = developerUpdatePaths({ home, brainHome });
352
+ const config = validateDeveloperUpdateConfig(override || readDeveloperUpdateConfig(paths));
353
+ const lock = acquireDeveloperLock({ brainHome: paths.brainHome, token: env.RUVNET_DEVELOPER_UPDATE_TOKEN });
354
+ const childEnv = { ...env, HOME: home, USERPROFILE: home, RUVNET_BRAIN_HOME: paths.brainHome,
355
+ RUVNET_DEVELOPER_UPDATE_TOKEN: lock.token, DISABLE_AUTOUPDATER: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_AUTO_UPDATE: '1' };
356
+ const dispatch = (command, args, options = {}) => {
357
+ if (process.platform === 'win32' && /npm\.cmd$/i.test(command)) {
358
+ const cli = path.join(path.dirname(command), 'node_modules/npm/bin/npm-cli.js');
359
+ if (!exists(cli)) throw Error('Windows npm CLI identity absent');
360
+ return runner(process.execPath, [cli, ...args], { cwd: home, ...options, env: childEnv });
361
+ }
362
+ return runner(command, args, { cwd: home, ...options, env: childEnv });
363
+ };
364
+ const run = (command, args, options = {}) => {
365
+ const result = dispatch(command, args, options);
366
+ if (typeof result === 'string') return result; // existing read-only test adapters
367
+ const exitCode = result?.exitCode ?? result?.status ?? result?.code;
368
+ if (result?.error || !(options.allowed || [0]).includes(exitCode) || typeof result?.stdout !== 'string') throw Error(`command result unverified: ${command}`);
369
+ return result.stdout;
370
+ };
371
+ run.receipt = (command, args, options = {}) => dispatch(command, args, { ...options, capture: true });
372
+ const locate = name => locateExecutable(name, { env: childEnv });
373
+ const receipt = { schemaVersion: 1, kind: 'nightly-suite-update', runId: crypto.randomUUID(), pid: process.pid,
374
+ startedAt: new Date().toISOString(), mode, state: 'running', ok: false, config, steps: [], notes: [],
375
+ sourceSha256: null, ownerToken: lock.token, ownerPid: lock.ownerPid, schedulerIdentity: env.RUVNET_NIGHTLY_IDENTITY || null };
376
+ try {
377
+ receipt.sourceSha256 = hash(fs.readFileSync(new URL(import.meta.url)));
378
+ receipt.sourceSnapshot = Object.fromEntries(EXECUTION_MODULES.map(name => [name, hash(fs.readFileSync(new URL(name, import.meta.url)))]));
379
+ atomic(paths.receipt, receipt);
380
+ const npm = locate('npm');
381
+ if (!npm) throw Error('existing npm owner absent; fresh install refused');
382
+ const prefix = run(npm, ['prefix', '-g'], { timeout: 30_000 }).trim();
383
+ const root = run(npm, ['root', '-g'], { timeout: 30_000 }).trim();
384
+ if (!path.isAbsolute(prefix) || !path.isAbsolute(root)) throw Error('npm global identity unverified');
385
+ // Bind every subsequent install to this exact prefix instead of inherited npm config.
386
+ childEnv.npm_config_prefix = prefix;
387
+ receipt.npmIdentity = { npm, prefix, root, node: process.execPath };
388
+ receipt.before = discover(root, prefix, config.scope);
389
+ atomic(paths.receipt, receipt);
390
+ for (const before of receipt.before) {
391
+ const tags = before.localSource ? {} : JSON.parse(run(npm, ['view', before.name, 'dist-tags', '--json'], { timeout: 90_000 }));
392
+ receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages }));
393
+ atomic(paths.receipt, receipt);
394
+ }
395
+ receipt.plugins = await synchronizePlugins(run, mode === 'check', receipt.notes, { home, prefix, scope: config.scope, locate, unattended: !!env.RUVNET_NIGHTLY_IDENTITY });
396
+ if (exists(path.join(paths.brainHome, 'kb/forge-update.mjs'))) receipt.knowledge = knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath });
397
+ else receipt.notes.push('Brain corpus absent; fresh knowledge install excluded');
398
+ receipt.maintenance = await maintenance(config, run, mode === 'check', { home, brainHome: paths.brainHome, locate, node: process.execPath, progress: result => { receipt.maintenance = result; atomic(paths.receipt, receipt); } });
399
+ receipt.cleanup = cleanupNpxDuplicates({ home, globalRoot: root, enabled: config.cleanup && mode === 'apply', run });
400
+ receipt.after = discover(root, prefix, config.scope);
401
+ receipt.state = mode === 'check' ? 'checked' : 'completed'; receipt.ok = true;
402
+ receipt.coverage = { scope: config.scope, excluded: receipt.steps.filter(s => /preserved$/.test(s.state)).map(s => ({ name: s.name, reason: s.state })),
403
+ ahead: receipt.steps.filter(s => s.state === 'ahead-preserved').map(s => s.name),
404
+ preservedLocalModification: receipt.steps.filter(s => s.reason === 'preservedLocalModification').map(s => s.name),
405
+ unverified: [...new Set([...receipt.notes.filter(note => /unverified|unsupported|no plugin update command/i.test(note)), ...(receipt.plugins?.steps || []).filter(step => step.state === 'UNSUPPORTED').map(step => `${step.id} (${step.scope}): ${step.reason}`)])], notes: [...receipt.notes, ...(receipt.maintenance?.exclusions || [])] };
406
+ return receipt;
407
+ } catch (error) {
408
+ receipt.state = 'failed'; receipt.error = error.message;
409
+ for (const stage of receipt.maintenance?.stages || []) if (stage.state === 'running') { stage.state = 'failed'; stage.error = error.message; }
410
+ receipt.coverage = { scope: config.scope, unverified: [error.message], notes: receipt.notes };
411
+ try { if (receipt.npmIdentity) receipt.after = discover(receipt.npmIdentity.root, receipt.npmIdentity.prefix, config.scope); } catch (e) { receipt.snapshotError = e.message; }
412
+ throw error;
413
+ } finally {
414
+ receipt.finishedAt = new Date().toISOString();
415
+ try { atomic(paths.receipt, receipt); } finally { lock.release(); }
416
+ }
417
+ }
418
+ export async function developerUpdateCli(args = process.argv.slice(2)) {
419
+ if (args.includes('--help') || args.includes('-h')) {
420
+ console.log('Usage: ruvnet-brain-update [--check|--apply] [--channel latest|alpha] [--scope ruvnet|all]');
421
+ return;
422
+ }
423
+ let mode = 'apply'; const override = readDeveloperUpdateConfig();
424
+ for (let i = 0; i < args.length; i++) {
425
+ if (args[i] === '--check' || args[i] === '--dry-run') mode = 'check';
426
+ else if (args[i] === '--apply') mode = 'apply';
427
+ else if (args[i] === '--channel') override.channel = args[++i];
428
+ else if (args[i] === '--scope') override.scope = args[++i];
429
+ else throw Error(`unsupported developer update argument: ${args[i]}`);
430
+ }
431
+ const result = await runDeveloperUpdate({ mode, config: override });
432
+ console.log(JSON.stringify(result));
433
+ return result;
434
+ }
435
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) developerUpdateCli().catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -3,6 +3,7 @@
3
3
  // Code and Codex, so lifecycle updates cannot drift into host-specific shell pipelines again.
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { automaticInvocation, automaticPath, updateSource } from './automatic-update.mjs';
6
+ import { developerCoordinatorOwner } from './developer-update-owner.mjs';
6
7
 
7
8
  const CHILD_ENV_KEYS = new Set([
8
9
  'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
@@ -12,7 +13,7 @@ const CHILD_ENV_KEYS = new Set([
12
13
  'CODEX_HOME', 'CLAUDE_CONFIG_DIR',
13
14
  'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
14
15
  'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB', 'RUVNET_BRAIN_MODEL_CACHE',
15
- 'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_BRAIN_TEST',
16
+ 'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_BRAIN_TEST', 'RUVNET_DEVELOPER_UPDATE_TOKEN',
16
17
  ]);
17
18
 
18
19
  export function childEnvironment(source = process.env) {
@@ -57,7 +58,9 @@ if (knowledgeAt >= 0) {
57
58
  } catch { /* the next session reports a launch with no outcome as a failure */ }
58
59
  };
59
60
  try {
60
- const source = updateSource();
61
+ const coordinator = developerCoordinatorOwner();
62
+ if (coordinator.active && !coordinator.ready) throw new Error(`coordinated updater is not ready: ${coordinator.reason}`);
63
+ const source = coordinator.active ? 'developer-suite' : updateSource();
61
64
  // --if-newer <kbDir> <checkFile> <resultFile>: the newer-published identity check (2026-10-02). Run the
62
65
  // INSTALLED updater's --check (bounded transient GET retries of the canonical releases/latest pointer, no download) and only a
63
66
  // newer identity proceeds to the update below. Exit codes are forge-update.mjs's own: 0 current (or
@@ -114,7 +117,7 @@ if (knowledgeAt >= 0) {
114
117
  fs.renameSync(tmp, attemptFile);
115
118
  }
116
119
  const probe = process.env.RUVNET_AUTO_UPDATE_PROBE_URL || 'https://registry.npmjs.org/ruvnet-brain/latest';
117
- let online = source === 'installed';
120
+ let online = source === 'installed' || source === 'developer-suite';
118
121
  if (!online) try { online = (await fetch(probe, { signal: AbortSignal.timeout(5_000) })).ok; } catch { /* offline */ }
119
122
  if (!online) {
120
123
  record({ outcome: 'offline', code: null, reason: `registry unreachable (${probe})` });
@@ -5,10 +5,12 @@ import path from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { automaticPath } from './automatic-update.mjs';
8
+ import { sharedLockStatus } from './developer-update-lock.mjs';
9
+ import { EXECUTION_MODULES } from './developer-update-policy.mjs';
8
10
 
9
11
  export const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
10
12
  export const NIGHTLY_HOUR = 3;
11
- export const NIGHTLY_MINUTE = 47;
13
+ export const NIGHTLY_MINUTE = 30;
12
14
  export const NIGHTLY_PROOF_LABEL = /^com\.ruvnet\.brain-update\.proof-[A-Za-z0-9._-]+$/;
13
15
  export const NIGHTLY_ENV_ALLOWLIST = Object.freeze([
14
16
  'PATH', 'HOME', 'USERPROFILE', 'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB', 'npm_config_cache', 'NO_COLOR', 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP',
@@ -140,8 +142,13 @@ function inspectRefreshOwner(owner) {
140
142
  } catch { return 'unknown'; }
141
143
  }
142
144
 
143
- function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url))) {
144
- const files = ['automatic-update.mjs', 'user-settings.mjs', 'ruvnet-gate1-pattern.mjs'];
145
+ const HISTORICAL_UPDATE_MODULES = ['automatic-update.mjs', 'user-settings.mjs', 'ruvnet-gate1-pattern.mjs'];
146
+ const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-owner.mjs'];
147
+ const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
148
+ const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs')];
149
+ const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
150
+ function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true) {
151
+ const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
145
152
  const bytes = Object.fromEntries(files.map(file => [file, fs.readFileSync(path.join(sourceDir, file))]));
146
153
  const digest = sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]])));
147
154
  const target = path.join(directory, `update-modules-${digest}`);
@@ -157,7 +164,8 @@ function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.
157
164
 
158
165
  function verifyUpdateModules(modules) {
159
166
  if (!modules) return; // existing schema-2 registrations remain inspectable
160
- if (Object.keys(modules).sort().join(',') !== 'automatic-update.mjs,ruvnet-gate1-pattern.mjs,user-settings.mjs') throw new Error('invalid update module closure');
167
+ if (![HISTORICAL_UPDATE_MODULES, BRIDGED_UPDATE_MODULES, LEGACY_UPDATE_MODULES, PREVIOUS_DEVELOPER_MODULES, DEVELOPER_UPDATE_MODULES].some(names => [...names].sort().join(',') === Object.keys(modules).sort().join(','))) throw new Error('invalid update module closure');
168
+ if (modules['developer-update.mjs'] && !modules['plugin-artifact-proof.mjs'] && fs.readFileSync(modules['developer-update.mjs'].path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw new Error('new coordinator artifact closure is incomplete');
161
169
  for (const [name, item] of Object.entries(modules)) {
162
170
  if (!path.isAbsolute(item.path) || path.basename(item.path) !== name
163
171
  || path.dirname(item.path) !== path.dirname(modules['automatic-update.mjs'].path)
@@ -167,7 +175,7 @@ function verifyUpdateModules(modules) {
167
175
  }
168
176
 
169
177
  export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
170
- identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource } = {}) {
178
+ identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity) } = {}) {
171
179
  validateIdentity(identity);
172
180
  if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
173
181
  const bytes = fs.readFileSync(source);
@@ -179,8 +187,8 @@ export function installNightlyRunner({ brainHome, source, nodePath = process.exe
179
187
  throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
180
188
  }
181
189
  if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
182
- const modules = updateModules(dir, moduleSource);
183
- const record = { updateModules: modules, schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
190
+ const modules = updateModules(dir, moduleSource, developerSuite);
191
+ const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
184
192
  nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
185
193
  environment: Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])),
186
194
  packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
@@ -200,6 +208,7 @@ export function readNightlyRegistration({ brainHome, identity = NIGHTLY_LABEL,
200
208
  || !path.isAbsolute(record.nodePath) || !path.isAbsolute(record.runnerPath)
201
209
  || !Array.isArray(record.argv) || record.argv.length !== 0) throw new Error('invalid registration schema');
202
210
  if (record.environment !== undefined && (!record.environment || Array.isArray(record.environment) || Object.entries(record.environment).some(([key, value]) => !NIGHTLY_ENV_ALLOWLIST.includes(key) || typeof value !== 'string'))) throw new Error('invalid registered environment');
211
+ if (![undefined, 'developer-suite'].includes(record.mode) || (record.mode === 'developer-suite') !== Boolean(record.updateModules?.['developer-update.mjs'])) throw new Error('nightly coordinator mode/module closure mismatch');
203
212
  verifyUpdateModules(record.updateModules);
204
213
  const actual = sha256(fs.readFileSync(record.runnerPath));
205
214
  if (actual !== record.runnerSha256) throw new Error('runner digest mismatch');
@@ -242,8 +251,35 @@ export function resolveNightlyProofBundle({ brainHome, env = process.env } = {})
242
251
  return execution.record.bundleTarget;
243
252
  }
244
253
 
254
+ export function developerRunHealth({ brainHome, registration, now = Date.now(), maxAgeHours = 30 }) {
255
+ const file = path.join(brainHome, 'nightly-suite-update.json');
256
+ if (!fs.existsSync(file)) return { state: 'never-ran', evidence: 'No coordinated update receipt exists yet.', receipt: null };
257
+ let receipt;
258
+ try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); }
259
+ catch (error) { return { state: 'failed', evidence: `Coordinated update receipt unreadable: ${error.message}`, receipt: null }; }
260
+ const validDigest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
261
+ const bound = validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
262
+ && receipt.sourceSha256 === registration.updateModules?.['developer-update.mjs']?.sha256
263
+ && EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || registration.updateModules?.[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === registration.updateModules?.[name]?.sha256);
264
+ const ageHours = (now - Date.parse(receipt.finishedAt || receipt.startedAt || '')) / 3_600_000;
265
+ if (!bound || !Number.isFinite(ageHours)) return { state: 'failed', evidence: 'Coordinated update receipt is not bound to registered source.', receipt };
266
+ if (receipt.state === 'running') {
267
+ const lock = sharedLockStatus({ brainHome });
268
+ const live = lock.state === 'running' && lock.owner.pid === (receipt.ownerPid || receipt.pid) && lock.owner.token === receipt.ownerToken;
269
+ return { state: live ? 'running' : 'failed', ageHours, receipt, evidence: live ? 'Coordinated updates have a live exact owner.' : 'Coordinated update owner is absent or unverified.' };
270
+ }
271
+ if (receipt.state === 'failed' || receipt.ok !== true) return { state: 'failed', ageHours, receipt, evidence: receipt.error || 'Coordinated updates failed.' };
272
+ if (receipt.mode !== 'apply' || receipt.schedulerIdentity !== registration.identity) return { state: 'never-ran', ageHours, receipt, evidence: 'Latest receipt is manual or a currency check; nightly execution remains unproven.' };
273
+ if (receipt.state !== 'completed') return { state: 'failed', ageHours, receipt, evidence: 'Coordinated update terminal state is unverified.' };
274
+ if (ageHours > maxAgeHours) return { state: 'stale', ageHours, receipt, evidence: `Last coordinated update is ${ageHours.toFixed(1)}h old.` };
275
+ const verification = { ok: bound && receipt.mode === 'apply' && receipt.schedulerIdentity === registration.identity && receipt.state === 'completed' && receipt.ok === true && ageHours >= 0 && ageHours <= maxAgeHours };
276
+ return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope.` : 'Coordinated update timestamp or terminal evidence is invalid.' };
277
+ }
278
+
245
279
  export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Date.now(), maxAgeHours = 30,
246
280
  inspectOwner = inspectRefreshOwner } = {}) {
281
+ const registration = readNightlyRegistration({ brainHome, identity });
282
+ if (registration.ok && registration.record.mode === 'developer-suite') return developerRunHealth({ brainHome, registration: registration.record, now, maxAgeHours });
247
283
  const dir = path.join(brainHome, 'refresh-runs');
248
284
  let files = [];
249
285
  try {
@@ -270,13 +306,13 @@ export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Da
270
306
  const stamp = Date.parse(receipt.finishedAt || receipt.startedAt || '');
271
307
  if (!Number.isFinite(stamp)) return { state: 'failed', evidence: 'Latest nightly receipt has no valid timestamp.', receipt };
272
308
  const ageHours = (now - stamp) / 3_600_000;
273
- const registration = readNightlyRegistration({ brainHome, identity });
274
- if (!registration.ok) return { state: 'failed', ageHours,
275
- evidence: `Nightly registration is invalid: ${registration.why}`, receipt };
276
- const expectedIdentity = { schedulerIdentity: registration.record.identity,
277
- registrationPath: registration.record.recordPath, nodePath: registration.record.nodePath,
278
- runnerPath: registration.record.runnerPath, runnerSha256: registration.record.runnerSha256, argv: [] };
279
- if (receipt.schedulerIdentity !== registration.record.identity
309
+ const legacyRegistration = readNightlyRegistration({ brainHome, identity });
310
+ if (!legacyRegistration.ok) return { state: 'failed', ageHours,
311
+ evidence: `Nightly registration is invalid: ${legacyRegistration.why}`, receipt };
312
+ const expectedIdentity = { schedulerIdentity: legacyRegistration.record.identity,
313
+ registrationPath: legacyRegistration.record.recordPath, nodePath: legacyRegistration.record.nodePath,
314
+ runnerPath: legacyRegistration.record.runnerPath, runnerSha256: legacyRegistration.record.runnerSha256, argv: [] };
315
+ if (receipt.schedulerIdentity !== legacyRegistration.record.identity
280
316
  || JSON.stringify(receipt.executableIdentity) !== JSON.stringify(expectedIdentity)) {
281
317
  return { state: 'failed', ageHours, evidence: 'Nightly receipt is not bound to the registered runner bytes.', receipt };
282
318
  }
@@ -334,9 +370,12 @@ export function refreshHistory({ brainHome }) {
334
370
  failuresSinceSuccess: sinceSuccess.filter(({ receipt }) => receipt.status === 'FAILED').length };
335
371
  }
336
372
 
337
- /** agentic-kit owns this machine's Brain updates (`ak sync`), so the Brain's own scheduler is off on purpose. */
373
+ /** Kit retains the legacy update owner unless this machine explicitly assigns it to Brain. */
338
374
  export function updateOwnedByAgenticKit(home = os.homedir()) {
339
- try { return JSON.parse(fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8')).ruvnetBrain === true; }
375
+ try {
376
+ const config = JSON.parse(fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8'));
377
+ return config.ruvnetBrain === true && config.ruvnetBrainUpdateOwner !== 'brain';
378
+ }
340
379
  catch { return false; }
341
380
  }
342
381