ruvnet-brain 4.3.19 → 4.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.3.19 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.19-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.3.20 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.20-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
package/bin/install.mjs CHANGED
@@ -673,8 +673,8 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
673
673
  * Registry reachability identifies candidates; it is not deletion proof. Claude freezes
674
674
  * CLAUDE_PLUGIN_ROOT at session start, so an unregistered generation can remain live until that
675
675
  * session exits. Modern roots expose `.in_use` PID-incarnation leases. Live or ambiguous leases
676
- * retain the complete root; dead leases are collected. Legacy roots without the protocol receive
677
- * a 14-day compatibility grace.
676
+ * retain the complete root; dead leases are collected. Legacy roots without the protocol are
677
+ * retained indefinitely because a frozen host session is not observable without a lease.
678
678
  *
679
679
  * WHAT MAKES DELETION SAFE HERE, since this removes directories from someone's machine:
680
680
  * • The registry is the ONLY authority. Unreadable, missing, or naming no ruvnet-brain install →
@@ -786,7 +786,11 @@ export function prunePluginGenerations({
786
786
  continue;
787
787
  }
788
788
 
789
- if (!leaseCapable && now() - orphanedAt < graceMs) continue;
789
+ if (!leaseCapable) {
790
+ if (now() - orphanedAt < graceMs) continue;
791
+ cleanupBlocked.push({ version: candidate.version, reason: 'legacy generation has no liveness lease; refusing destructive cleanup' });
792
+ continue;
793
+ }
790
794
  let safeToReap = true;
791
795
  for (const leaseName of leaseNames) {
792
796
  const leasePath = path.join(leaseDir, leaseName);
@@ -2915,29 +2919,82 @@ function missingUpdaterHelp(kbDir) {
2915
2919
  console.error(` — the current bundle ships forge-update.mjs; then this command will work.`);
2916
2920
  }
2917
2921
 
2918
- function acquireHostConvergenceLock(lockPath) {
2919
- try {
2920
- fs.mkdirSync(lockPath);
2921
- fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
2922
- return { acquired: true, release: () => fs.rmSync(lockPath, { recursive: true, force: true }) };
2923
- } catch (error) {
2924
- if (error?.code !== 'EEXIST') return { acquired: false, error: error.message };
2922
+ export function acquireHostConvergenceLock(lockPath, {
2923
+ staleMs = 5 * 60 * 1000,
2924
+ now = () => Date.now(),
2925
+ processAlive = (pid, owner) => {
2926
+ if (owner?.host && owner.host !== os.hostname()) return true;
2927
+ try { process.kill(pid, 0); return true; }
2928
+ catch (error) { return error?.code !== 'ESRCH'; }
2929
+ },
2930
+ } = {}) {
2931
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2932
+ const token = crypto.randomBytes(16).toString('hex');
2933
+ const owner = {
2934
+ pid: process.pid,
2935
+ host: os.hostname(),
2936
+ token,
2937
+ startedAt: new Date(now()).toISOString(),
2938
+ };
2939
+ const publish = () => {
2940
+ try {
2941
+ // mkdir is the exclusive operation. The owner write follows it, but an incomplete
2942
+ // directory is treated as busy until the stale threshold; no contender may reclaim it in
2943
+ // that publication window. This avoids rename-over-empty-directory replacement on macOS.
2944
+ fs.mkdirSync(lockPath, { recursive: false });
2945
+ fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
2946
+ return true;
2947
+ } catch (error) {
2948
+ if (error?.code !== 'EEXIST') throw error;
2949
+ return false;
2950
+ }
2951
+ };
2952
+ const release = () => {
2953
+ const quarantine = `${lockPath}.release-${process.pid}-${token}`;
2925
2954
  try {
2926
- const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
2927
- if (Number.isInteger(owner.pid) && owner.pid !== process.pid) {
2928
- try { process.kill(owner.pid, 0); return { acquired: false, error: `another host synchronization is running (pid ${owner.pid})` }; }
2929
- catch (probeError) { if (probeError?.code !== 'ESRCH') return { acquired: false, error: 'another host synchronization owns the lock' }; }
2955
+ const current = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
2956
+ if (current?.token !== token) return false;
2957
+ fs.renameSync(lockPath, quarantine);
2958
+ const moved = JSON.parse(fs.readFileSync(path.join(quarantine, 'owner.json'), 'utf8'));
2959
+ if (moved?.token !== token) {
2960
+ try { fs.renameSync(quarantine, lockPath); } catch { /* preserve evidence if another writer won */ }
2961
+ return false;
2930
2962
  }
2931
- } catch { /* an incomplete lock is safe to reclaim */ }
2932
- fs.rmSync(lockPath, { recursive: true, force: true });
2963
+ fs.rmSync(quarantine, { recursive: true, force: true });
2964
+ return true;
2965
+ } catch { return false; }
2966
+ };
2967
+
2968
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2969
+ try {
2970
+ if (publish()) return { acquired: true, release };
2971
+ } catch (error) {
2972
+ return { acquired: false, error: error.message };
2973
+ }
2974
+
2975
+ let existing;
2976
+ let lockAge = 0;
2977
+ try {
2978
+ const stat = fs.statSync(lockPath);
2979
+ lockAge = Math.max(0, now() - stat.mtimeMs);
2980
+ existing = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
2981
+ } catch { /* an incomplete lock is handled below, with a grace period */ }
2982
+ if (existing && Number.isInteger(existing.pid) && processAlive(existing.pid, existing)) {
2983
+ return { acquired: false, error: `another host synchronization is running (pid ${existing.pid})` };
2984
+ }
2985
+ if (!existing && lockAge < staleMs) {
2986
+ return { acquired: false, error: 'another host synchronization is publishing its lock' };
2987
+ }
2988
+ const quarantine = `${lockPath}.reclaim-${process.pid}-${token}-${attempt}`;
2933
2989
  try {
2934
- fs.mkdirSync(lockPath);
2935
- fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
2936
- return { acquired: true, release: () => fs.rmSync(lockPath, { recursive: true, force: true }) };
2937
- } catch (retryError) {
2938
- return { acquired: false, error: retryError.message };
2990
+ fs.renameSync(lockPath, quarantine);
2991
+ fs.rmSync(quarantine, { recursive: true, force: true });
2992
+ } catch (error) {
2993
+ if (error?.code === 'ENOENT') continue;
2994
+ return { acquired: false, error: `host synchronization lock changed concurrently: ${error.message}` };
2939
2995
  }
2940
2996
  }
2997
+ return { acquired: false, error: 'host synchronization lock changed concurrently' };
2941
2998
  }
2942
2999
 
2943
3000
  export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
@@ -2995,9 +3052,18 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
2995
3052
  if (!fs.existsSync(apply)) return fail({ error: 'Stable Spine updater missing from package' });
2996
3053
  const applied = runStableSpine(apply);
2997
3054
  const okApplied = !applied.error && applied.status === 0;
3055
+ const codexReceipt = {
3056
+ state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'),
3057
+ version: results.codex?.version || null,
3058
+ };
3059
+ if (results.codex?.restartRequired === true) {
3060
+ codexReceipt.restartRequired = true;
3061
+ codexReceipt.sessionSafety = results.codex.sessionSafety || null;
3062
+ codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
3063
+ }
2998
3064
  if (okApplied) {
2999
3065
  // ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
3000
- // modern leases prove no live consumer, or legacy generations past the compatibility grace.
3066
+ // modern leases prove no live consumer; legacy roots without that proof stay on disk.
3001
3067
  try {
3002
3068
  const gens = prunePluginGenerations({ apply: true });
3003
3069
  if (gens.retired?.length) {
@@ -3028,7 +3094,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3028
3094
  verifiedAt: new Date().toISOString(),
3029
3095
  hosts: {
3030
3096
  claude: { state: results.claude.host ? 'ready' : 'absent', version: results.claude.version || null },
3031
- codex: { state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'), version: results.codex?.version || null },
3097
+ codex: codexReceipt,
3032
3098
  },
3033
3099
  consoleRuntime: results.consoleRuntime,
3034
3100
  }, null, 2));
@@ -3043,7 +3109,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3043
3109
  desiredVersion: PACKAGE_VERSION,
3044
3110
  hosts: {
3045
3111
  claude: { state: results.claude.host ? 'ready' : 'absent', version: results.claude.version || null },
3046
- codex: { state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'), version: results.codex?.version || null },
3112
+ codex: codexReceipt,
3047
3113
  },
3048
3114
  consoleRuntime: results.consoleRuntime,
3049
3115
  });
@@ -3061,7 +3127,11 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3061
3127
  }
3062
3128
  const hostStates = Object.values(receipt.hosts || {});
3063
3129
  const badHost = hostStates.find((host) => !['ready', 'disabled', 'absent'].includes(host?.state)
3064
- || (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion)));
3130
+ || (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
3131
+ || (host.state === 'ready' && host.restartRequired === true));
3132
+ if (badHost?.restartRequired === true) {
3133
+ return { healthy: false, state: 'host-restart-required', action: badHost.sessionSafetyReason || 'restart the host, then re-run --doctor' };
3134
+ }
3065
3135
  if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
3066
3136
  if (receipt.consoleRuntime?.state !== 'ready') {
3067
3137
  return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: 'restart Console, then re-run --doctor' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.19",
3
+ "version": "4.3.20",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.",
4
- "version": "4.3.19",
4
+ "version": "4.3.20",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.19",
3
+ "version": "4.3.20",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -248,6 +248,19 @@ const announceVersion = ({ running, off, stateDir, consoleInvoke, emit }) => {
248
248
  write(announced, `${running}\n`);
249
249
  };
250
250
 
251
+ const compareVersions = (a, b) => {
252
+ const left = String(a).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
253
+ const right = String(b).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
254
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
255
+ const x = left[i] ?? 0;
256
+ const y = right[i] ?? 0;
257
+ if (x === y) continue;
258
+ if (typeof x === 'number' && typeof y === 'number') return x - y;
259
+ return String(x) < String(y) ? -1 : 1;
260
+ }
261
+ return 0;
262
+ };
263
+
251
264
  const stableSpine = ({ env, hookDir, stateDir, home, pluginVersion, emit, now }) => {
252
265
  const activeFile = path.join(stateDir, 'active.json');
253
266
  const stamp = path.join(stateDir, '.last-update-check');
@@ -270,7 +283,11 @@ const stableSpine = ({ env, hookDir, stateDir, home, pluginVersion, emit, now })
270
283
  }
271
284
  } else {
272
285
  const active = json(activeFile);
273
- if (active?.shellChanged && pluginVersion && active.version && active.version !== pluginVersion) {
286
+ const shellBoundary = active?.shellChangedAtVersion;
287
+ const shellChangedBeforeHost = shellBoundary && pluginVersion
288
+ ? compareVersions(pluginVersion, shellBoundary) < 0
289
+ : false;
290
+ if ((active?.shellChanged || shellChangedBeforeHost) && pluginVersion && active.version && active.version !== pluginVersion) {
274
291
  emit(`[RuvNet Brain — v${active.version} changed boot-level declarations (the rare case); this session booted v${pluginVersion}'s]`);
275
292
  const host = env.RUVNET_HOOK_HOST || 'claude';
276
293
  const convergence = json(path.join(stateDir, 'host-convergence.json'));
@@ -371,6 +388,8 @@ export async function runSessionStart({
371
388
  || s.startsWith('[RuvNet Brain — grounding not yet PROVEN')
372
389
  || s.startsWith('The last check (')
373
390
  || s.startsWith('[RuvNet Brain — update available')
391
+ || s.startsWith('[RuvNet Brain — v')
392
+ || s.startsWith('Tell the user ONE line: "🧠 RuvNet Brain v')
374
393
  || s.startsWith('[RuvNet Brain — brain OFF by your setting')
375
394
  || s.startsWith('[RuvNet Brain — new in v')
376
395
  || s.startsWith('[RuvNet Brain — first session initialized]')
@@ -212,18 +212,34 @@ function promote(staging, version) {
212
212
  // the update is fully live with no restart and session-start stays silent; if any did, the ONE
213
213
  // honest nag fires (the release classifier computes the same thing publish-side; this is the
214
214
  // client-side truth for locally-applied generations). Red-team finding 18's client half.
215
- const SHELL_PATHS = ['hooks/hooks.json', 'scripts/hook-shim.mjs', 'mcp/server.mjs', '.mcp.json'];
216
- function shellDiff(prevRootAbs, nextRootAbs) {
215
+ export const SHELL_PATHS = [
216
+ 'hooks/hooks.json',
217
+ 'scripts/hook-shim.mjs',
218
+ 'scripts/hook-shim-bash.mjs',
219
+ 'scripts/development-maintenance.mjs',
220
+ 'mcp/server.mjs',
221
+ '.mcp.json',
222
+ ];
223
+
224
+ function treeDigest(root, relative) {
225
+ const target = path.join(root, relative);
226
+ let stat;
227
+ try { stat = fs.lstatSync(target); } catch { return '<missing>'; }
228
+ if (stat.isSymbolicLink()) return '<symlink>';
229
+ if (stat.isFile()) return `file:${crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex')}`;
230
+ if (!stat.isDirectory()) return `<special:${stat.mode}>`;
231
+ const entries = fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
232
+ return `dir:${entries.map((entry) => `${entry.name}:${treeDigest(target, entry.name)}`).join('|')}`;
233
+ }
234
+
235
+ export function shellDiff(prevRootAbs, nextRootAbs) {
217
236
  const changed = [];
218
237
  for (const rel of SHELL_PATHS) {
219
- const a = (() => { try { return fs.readFileSync(path.join(prevRootAbs, rel), 'utf8'); } catch { return null; } })();
220
- const b = (() => { try { return fs.readFileSync(path.join(nextRootAbs, rel), 'utf8'); } catch { return null; } })();
221
- if (a !== b) changed.push(rel);
238
+ if (treeDigest(prevRootAbs, rel) !== treeDigest(nextRootAbs, rel)) changed.push(rel);
222
239
  }
223
240
  // skills/ and commands/ are boot-loaded markdown: any file-set or content difference counts.
224
241
  for (const dir of ['skills', 'commands']) {
225
- const list = (root) => { try { return fs.readdirSync(path.join(root, dir), { recursive: true }).sort().join('|'); } catch { return ''; } };
226
- if (list(prevRootAbs) !== list(nextRootAbs)) changed.push(`${dir}/`);
242
+ if (treeDigest(prevRootAbs, dir) !== treeDigest(nextRootAbs, dir)) changed.push(`${dir}/`);
227
243
  }
228
244
  return changed;
229
245
  }
@@ -239,6 +255,15 @@ function flip(version, codeRootAbs, why) {
239
255
  previous: prev ? { generation: prev.generation, version: prev.version, codeRoot: prev.codeRoot } : null,
240
256
  shellChanged: shellChanged.length > 0,
241
257
  shellChangedPaths: shellChanged,
258
+ // Keep the first boot-level change attached to subsequent body-only generations. A host that
259
+ // still booted the pre-change generation must not lose the restart signal merely because a
260
+ // later update changed only implementation code.
261
+ shellChangedSinceVersion: shellChanged.length > 0
262
+ ? (prev?.version || version)
263
+ : (prev?.shellChangedSinceVersion || null),
264
+ shellChangedAtVersion: shellChanged.length > 0
265
+ ? version
266
+ : (prev?.shellChangedAtVersion || null),
242
267
  flippedAt: new Date().toISOString(),
243
268
  };
244
269
  writeAtomic(TXN, JSON.stringify({ state: 'flipping', from: prev?.version ?? null, to: version }));
@@ -446,4 +471,4 @@ function main() {
446
471
  }
447
472
  }
448
473
 
449
- process.exit(main());
474
+ if (process.env.RUVNET_BRAIN_IMPORT_ONLY !== 'update-shell-test') process.exit(main());
@@ -70,7 +70,7 @@ const argv = process.argv.slice(2);
70
70
  * every entry on every run, below, so they cannot rot unseen.
71
71
  */
72
72
  const STANDALONE = [
73
- ['gate', 'retired automatic-hook helper retained for explicit use and tests'],
73
+ ['gate', 'retired automatic-hook helper and manual benchmark retained for explicit human use; no workflow or scheduler invokes this expensive command'],
74
74
  ['dream-issue-gate', 'pure Dream Cycle disposition policy; invoked by the external issue adapter, never a GitHub writer'],
75
75
  ['sync-census', 'explicit maintainer census writer; a destructive source-to-surface refresh is never scheduled'],
76
76
  ['sync-commands', 'explicit maintainer alias synchronizer; run deliberately before release, never from a lifecycle hook'],
@@ -105,8 +105,6 @@ const STANDALONE = [
105
105
  + 'stops at sealed preparation with contents:read. This entry validates receipt/archive inputs '
106
106
  + 'and delegates mutation to release.mjs protected authority. No operational caller is registered; '
107
107
  + 'classification does not claim publication works or has occurred'],
108
- ['gate', 'manual benchmark harness: rebuilds concepts and runs three routing proof batteries; '
109
- + 'no workflow or scheduler invokes this expensive command'],
110
108
  ['execution-preflight', 'external orchestration boundary — invoked by the host before consequential Ruflo/Codex execution; no in-repo caller exists because the host supplies the live Brain and AgentDB receipts'],
111
109
  ['fix-workstream', 'session-supervised coordination CLI run explicitly by the integration owner or an '
112
110
  + 'isolated writing agent to start and hand off a fix lane. Scheduling it would violate its safety '