ruvnet-brain 4.5.16 → 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 (86) hide show
  1. package/README.md +15 -19
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +15 -3
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/config/practical-rule-catalog.json +111 -0
  6. package/console/CONTRACT.md +47 -3
  7. package/console/app.js +112 -20
  8. package/package.json +5 -2
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/commands/brain-console.md +13 -1
  12. package/plugin/commands/configure.md +2 -2
  13. package/plugin/commands/rnb.md +13 -1
  14. package/plugin/commands/rnbc.md +13 -1
  15. package/plugin/commands/rvbc.md +13 -1
  16. package/plugin/commands/rvcb.md +13 -1
  17. package/plugin/hooks/codex-hooks.json +13 -1
  18. package/plugin/hooks/hook-contracts.json +20 -4
  19. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  20. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  21. package/plugin/scripts/agentdb-recall.mjs +161 -26
  22. package/plugin/scripts/automatic-update.mjs +11 -2
  23. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  24. package/plugin/scripts/capability-registry.mjs +55 -114
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  26. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  27. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  28. package/plugin/scripts/continuation-gate.mjs +2 -1
  29. package/plugin/scripts/continuity-brief.mjs +55 -22
  30. package/plugin/scripts/continuity-events.mjs +93 -17
  31. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  32. package/plugin/scripts/continuity-journal.mjs +77 -29
  33. package/plugin/scripts/decision-gate.mjs +62 -37
  34. package/plugin/scripts/decision-outcomes.mjs +192 -199
  35. package/plugin/scripts/detach.mjs +53 -13
  36. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  37. package/plugin/scripts/developer-update-lock.mjs +30 -0
  38. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  39. package/plugin/scripts/developer-update-owner.mjs +24 -0
  40. package/plugin/scripts/developer-update-policy.mjs +48 -0
  41. package/plugin/scripts/developer-update.mjs +435 -0
  42. package/plugin/scripts/doc-currency.mjs +993 -0
  43. package/plugin/scripts/ground-ruvnet.sh +16 -3
  44. package/plugin/scripts/grounding-answer.mjs +18 -16
  45. package/plugin/scripts/grounding-stamp.sh +3 -0
  46. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  47. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  48. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  49. package/plugin/scripts/hook-context-budget.mjs +90 -0
  50. package/plugin/scripts/hook-registry.mjs +129 -1
  51. package/plugin/scripts/hook-shim.mjs +20 -2
  52. package/plugin/scripts/host-update.mjs +6 -3
  53. package/plugin/scripts/lesson-gate.mjs +1 -1
  54. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  55. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  56. package/plugin/scripts/project-capture-queue.mjs +22 -15
  57. package/plugin/scripts/project-progression-producer.mjs +15 -8
  58. package/plugin/scripts/project-progression-reader.mjs +14 -4
  59. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  60. package/plugin/scripts/project-progression-sources.mjs +24 -10
  61. package/plugin/scripts/project-progression-store.mjs +7 -2
  62. package/plugin/scripts/project-store-resolver.mjs +10 -7
  63. package/plugin/scripts/project-transition-hook.mjs +20 -12
  64. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  65. package/plugin/scripts/session-start-budget.mjs +18 -0
  66. package/plugin/scripts/session-start-core.mjs +32 -6
  67. package/plugin/scripts/session-start-trace.mjs +2 -1
  68. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  69. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  70. package/plugin/scripts/user-settings.mjs +7 -1
  71. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  72. package/scripts/codex-managed-terminal.mjs +1 -1
  73. package/scripts/console-runtime-identity.mjs +2 -0
  74. package/scripts/console-suite-update.mjs +117 -0
  75. package/scripts/doc-currency.mjs +6 -990
  76. package/scripts/hook-qualify-core.mjs +30 -1
  77. package/scripts/model-managed-prompt.mjs +1 -1
  78. package/scripts/model-managed-workflow-service.mjs +113 -14
  79. package/scripts/model-routing-controller.mjs +112 -21
  80. package/scripts/onboarding-console.mjs +41 -12
  81. package/scripts/practical-rule-selector.mjs +51 -0
  82. package/scripts/product-integrity-contract.mjs +11 -4
  83. package/scripts/release-qualification-contract.mjs +14 -0
  84. package/scripts/remedy-registry.mjs +1 -0
  85. package/scripts/source-scope-receipt.mjs +9 -4
  86. 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; });