ruvnet-brain 4.3.19 → 4.3.21
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 +1 -1
- package/bin/install.mjs +203 -43
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/host-shell-boundary.mjs +43 -0
- package/plugin/scripts/session-start-core.mjs +20 -1
- package/plugin/scripts/update-apply.mjs +12 -17
- package/scripts/wired-check.mjs +1 -3
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 — [](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
|
@@ -67,6 +67,7 @@ const versionSatisfies = (installed, expected) => {
|
|
|
67
67
|
import {
|
|
68
68
|
CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
|
|
69
69
|
} from '../scripts/console-runtime-identity.mjs';
|
|
70
|
+
import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
70
71
|
|
|
71
72
|
// SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
|
|
72
73
|
// trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
|
|
@@ -673,8 +674,8 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
673
674
|
* Registry reachability identifies candidates; it is not deletion proof. Claude freezes
|
|
674
675
|
* CLAUDE_PLUGIN_ROOT at session start, so an unregistered generation can remain live until that
|
|
675
676
|
* 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
|
|
677
|
-
* a
|
|
677
|
+
* retain the complete root; dead leases are collected. Legacy roots without the protocol are
|
|
678
|
+
* retained indefinitely because a frozen host session is not observable without a lease.
|
|
678
679
|
*
|
|
679
680
|
* WHAT MAKES DELETION SAFE HERE, since this removes directories from someone's machine:
|
|
680
681
|
* • The registry is the ONLY authority. Unreadable, missing, or naming no ruvnet-brain install →
|
|
@@ -786,7 +787,11 @@ export function prunePluginGenerations({
|
|
|
786
787
|
continue;
|
|
787
788
|
}
|
|
788
789
|
|
|
789
|
-
if (!leaseCapable
|
|
790
|
+
if (!leaseCapable) {
|
|
791
|
+
if (now() - orphanedAt < graceMs) continue;
|
|
792
|
+
cleanupBlocked.push({ version: candidate.version, reason: 'legacy generation has no liveness lease; refusing destructive cleanup' });
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
790
795
|
let safeToReap = true;
|
|
791
796
|
for (const leaseName of leaseNames) {
|
|
792
797
|
const leasePath = path.join(leaseDir, leaseName);
|
|
@@ -1178,6 +1183,55 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
|
|
|
1178
1183
|
}
|
|
1179
1184
|
}
|
|
1180
1185
|
|
|
1186
|
+
// A plugin version can change without changing anything a running host freezes at boot. Compare
|
|
1187
|
+
// the actual installed payload with this candidate before updating it so body-only releases do not
|
|
1188
|
+
// manufacture a restart requirement. Unknown paths fail closed: if we cannot prove the boundary,
|
|
1189
|
+
// the caller must retain the restart notice rather than silently promise hot loading.
|
|
1190
|
+
function inspectPluginShellBoundary(installedRoot, candidateRoot = path.join(REPO_ROOT, 'plugin')) {
|
|
1191
|
+
if (!installedRoot || !candidateRoot) {
|
|
1192
|
+
return { known: false, changed: true, paths: [], restartRequired: true, reason: 'plugin boot surface could not be located' };
|
|
1193
|
+
}
|
|
1194
|
+
try {
|
|
1195
|
+
const paths = pluginShellDiff(installedRoot, candidateRoot);
|
|
1196
|
+
return {
|
|
1197
|
+
known: true,
|
|
1198
|
+
changed: paths.length > 0,
|
|
1199
|
+
paths,
|
|
1200
|
+
restartRequired: paths.length > 0,
|
|
1201
|
+
reason: paths.length ? `boot-level declarations changed: ${paths.join(', ')}` : 'body-only plugin update',
|
|
1202
|
+
};
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
return { known: false, changed: true, paths: [], restartRequired: true,
|
|
1205
|
+
reason: `plugin boot surface comparison failed: ${error.message}` };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function codexInstalledPluginRoot({ codexHome = codexHomeDir(), status } = {}) {
|
|
1210
|
+
const row = status?.row || {};
|
|
1211
|
+
for (const value of [status?.installPath, status?.path, row.installPath, row.path]) {
|
|
1212
|
+
if (typeof value === 'string' && value) {
|
|
1213
|
+
try {
|
|
1214
|
+
const real = fs.realpathSync(value);
|
|
1215
|
+
if (fs.statSync(real).isDirectory()) return real;
|
|
1216
|
+
} catch { /* try the cache scan below */ }
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
const cacheRoot = path.join(codexHome, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
|
|
1220
|
+
let candidates = [];
|
|
1221
|
+
try {
|
|
1222
|
+
candidates = fs.readdirSync(cacheRoot, { withFileTypes: true })
|
|
1223
|
+
.filter((entry) => entry.isDirectory())
|
|
1224
|
+
.map((entry) => path.join(cacheRoot, entry.name))
|
|
1225
|
+
.filter((dir) => {
|
|
1226
|
+
try {
|
|
1227
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, '.codex-plugin', 'plugin.json'), 'utf8'));
|
|
1228
|
+
return manifest?.name === 'ruvnet-brain' && (!status?.version || manifest.version === status.version);
|
|
1229
|
+
} catch { return false; }
|
|
1230
|
+
});
|
|
1231
|
+
} catch { /* absent cache is handled as unknown below */ }
|
|
1232
|
+
return candidates.sort().at(-1) || null;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1181
1235
|
function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
|
|
1182
1236
|
step(
|
|
1183
1237
|
'Wiring the Claude Code plugin',
|
|
@@ -1200,6 +1254,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1200
1254
|
|
|
1201
1255
|
const before = claudePluginStatus();
|
|
1202
1256
|
if (requireManaged && !before.managed) return { host: false, wired: false, action: 'unmanaged' };
|
|
1257
|
+
const shellBoundary = before.installed
|
|
1258
|
+
? inspectPluginShellBoundary(before.installPath)
|
|
1259
|
+
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
1203
1260
|
const addedMarket = before.managed
|
|
1204
1261
|
? tryRun('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
|
|
1205
1262
|
: tryRun('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
|
|
@@ -1220,8 +1277,18 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1220
1277
|
const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
|
|
1221
1278
|
if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
|
|
1222
1279
|
ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) — exact version ${installed.version}`);
|
|
1223
|
-
|
|
1224
|
-
|
|
1280
|
+
if (shellBoundary.restartRequired) {
|
|
1281
|
+
warn(`boot-level plugin declarations changed; restart Claude Code once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
|
|
1282
|
+
} else if (before.installed && before.version !== installed.version) {
|
|
1283
|
+
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1284
|
+
}
|
|
1285
|
+
info(` commands available${shellBoundary.restartRequired ? ' after a restart' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
|
|
1286
|
+
return {
|
|
1287
|
+
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1288
|
+
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
1289
|
+
restartRequired: shellBoundary.restartRequired,
|
|
1290
|
+
...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason } : {}),
|
|
1291
|
+
};
|
|
1225
1292
|
}
|
|
1226
1293
|
|
|
1227
1294
|
// The honest failure. The brain still WORKS — this is the difference between a broken install and
|
|
@@ -1650,6 +1717,7 @@ export function codexPluginStatus(options = {}) {
|
|
|
1650
1717
|
installed: Boolean(row?.installed),
|
|
1651
1718
|
enabled: Boolean(row?.enabled),
|
|
1652
1719
|
version: row?.version || null,
|
|
1720
|
+
installPath: row?.installPath || row?.path || null,
|
|
1653
1721
|
row: row || null,
|
|
1654
1722
|
};
|
|
1655
1723
|
}
|
|
@@ -1687,9 +1755,22 @@ export function wireCodexPlugin({
|
|
|
1687
1755
|
if (announce) warn(`Codex Brain plugin is installed but disabled by user or policy — left disabled (${CODEX_PLUGIN_ID}).`);
|
|
1688
1756
|
return { host: true, action: 'disabled', ...before };
|
|
1689
1757
|
}
|
|
1758
|
+
// An existing Codex session can keep the plugin generation it loaded at boot. Compare the
|
|
1759
|
+
// installed bytes with the source candidate before mutating the marketplace so body-only updates
|
|
1760
|
+
// remain live while a changed/unknown boot surface gets one explicit restart request.
|
|
1761
|
+
const shellBoundary = before.installed
|
|
1762
|
+
? inspectPluginShellBoundary(
|
|
1763
|
+
codexInstalledPluginRoot({ codexHome, status: before }),
|
|
1764
|
+
path.join(REPO_ROOT, 'plugin'),
|
|
1765
|
+
)
|
|
1766
|
+
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
1690
1767
|
if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
|
|
1691
1768
|
if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
|
|
1692
|
-
return {
|
|
1769
|
+
return {
|
|
1770
|
+
host: true, action: 'unchanged', ...before,
|
|
1771
|
+
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
1772
|
+
restartRequired: false,
|
|
1773
|
+
};
|
|
1693
1774
|
}
|
|
1694
1775
|
|
|
1695
1776
|
if (runJson === runCodexJson && !localMarketplace) {
|
|
@@ -1731,17 +1812,23 @@ export function wireCodexPlugin({
|
|
|
1731
1812
|
}
|
|
1732
1813
|
if (announce) {
|
|
1733
1814
|
ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
|
|
1734
|
-
|
|
1815
|
+
if (shellBoundary.restartRequired) {
|
|
1816
|
+
warn(`boot-level plugin declarations changed; restart Codex once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
|
|
1817
|
+
} else if (before.installed && before.version !== after.version) {
|
|
1818
|
+
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1819
|
+
}
|
|
1735
1820
|
}
|
|
1736
1821
|
return {
|
|
1737
1822
|
host: true,
|
|
1738
1823
|
action: before.installed ? 'updated' : 'installed',
|
|
1739
1824
|
...after,
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
restartRequired
|
|
1744
|
-
|
|
1825
|
+
shellChanged: shellBoundary.changed,
|
|
1826
|
+
shellChangedPaths: shellBoundary.paths,
|
|
1827
|
+
restartRequired: shellBoundary.restartRequired,
|
|
1828
|
+
...(shellBoundary.restartRequired ? {
|
|
1829
|
+
sessionSafety: 'restart-required',
|
|
1830
|
+
sessionSafetyReason: shellBoundary.reason,
|
|
1831
|
+
} : {}),
|
|
1745
1832
|
};
|
|
1746
1833
|
}
|
|
1747
1834
|
|
|
@@ -2798,7 +2885,7 @@ function reportVersionDrift(cacheDir) {
|
|
|
2798
2885
|
if (!state.drift) return state;
|
|
2799
2886
|
warn(`the brain (${c.bold(state.kb)}) and the Claude Code plugin (${c.bold(state.wrapper)}) have drifted apart —`);
|
|
2800
2887
|
info(`that's normal (they update on separate schedules) and neither one is broken. To bring the`);
|
|
2801
|
-
info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(
|
|
2888
|
+
info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(body updates go live without a restart; boot-surface changes are called out)')}`);
|
|
2802
2889
|
return state;
|
|
2803
2890
|
}
|
|
2804
2891
|
|
|
@@ -2915,29 +3002,82 @@ function missingUpdaterHelp(kbDir) {
|
|
|
2915
3002
|
console.error(` — the current bundle ships forge-update.mjs; then this command will work.`);
|
|
2916
3003
|
}
|
|
2917
3004
|
|
|
2918
|
-
function acquireHostConvergenceLock(lockPath
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
3005
|
+
export function acquireHostConvergenceLock(lockPath, {
|
|
3006
|
+
staleMs = 5 * 60 * 1000,
|
|
3007
|
+
now = () => Date.now(),
|
|
3008
|
+
processAlive = (pid, owner) => {
|
|
3009
|
+
if (owner?.host && owner.host !== os.hostname()) return true;
|
|
3010
|
+
try { process.kill(pid, 0); return true; }
|
|
3011
|
+
catch (error) { return error?.code !== 'ESRCH'; }
|
|
3012
|
+
},
|
|
3013
|
+
} = {}) {
|
|
3014
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
3015
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
3016
|
+
const owner = {
|
|
3017
|
+
pid: process.pid,
|
|
3018
|
+
host: os.hostname(),
|
|
3019
|
+
token,
|
|
3020
|
+
startedAt: new Date(now()).toISOString(),
|
|
3021
|
+
};
|
|
3022
|
+
const publish = () => {
|
|
3023
|
+
try {
|
|
3024
|
+
// mkdir is the exclusive operation. The owner write follows it, but an incomplete
|
|
3025
|
+
// directory is treated as busy until the stale threshold; no contender may reclaim it in
|
|
3026
|
+
// that publication window. This avoids rename-over-empty-directory replacement on macOS.
|
|
3027
|
+
fs.mkdirSync(lockPath, { recursive: false });
|
|
3028
|
+
fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
|
|
3029
|
+
return true;
|
|
3030
|
+
} catch (error) {
|
|
3031
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
3032
|
+
return false;
|
|
3033
|
+
}
|
|
3034
|
+
};
|
|
3035
|
+
const release = () => {
|
|
3036
|
+
const quarantine = `${lockPath}.release-${process.pid}-${token}`;
|
|
2925
3037
|
try {
|
|
2926
|
-
const
|
|
2927
|
-
if (
|
|
2928
|
-
|
|
2929
|
-
|
|
3038
|
+
const current = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
|
|
3039
|
+
if (current?.token !== token) return false;
|
|
3040
|
+
fs.renameSync(lockPath, quarantine);
|
|
3041
|
+
const moved = JSON.parse(fs.readFileSync(path.join(quarantine, 'owner.json'), 'utf8'));
|
|
3042
|
+
if (moved?.token !== token) {
|
|
3043
|
+
try { fs.renameSync(quarantine, lockPath); } catch { /* preserve evidence if another writer won */ }
|
|
3044
|
+
return false;
|
|
2930
3045
|
}
|
|
2931
|
-
|
|
2932
|
-
|
|
3046
|
+
fs.rmSync(quarantine, { recursive: true, force: true });
|
|
3047
|
+
return true;
|
|
3048
|
+
} catch { return false; }
|
|
3049
|
+
};
|
|
3050
|
+
|
|
3051
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2933
3052
|
try {
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
return { acquired:
|
|
2937
|
-
}
|
|
2938
|
-
|
|
3053
|
+
if (publish()) return { acquired: true, release };
|
|
3054
|
+
} catch (error) {
|
|
3055
|
+
return { acquired: false, error: error.message };
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
let existing;
|
|
3059
|
+
let lockAge = 0;
|
|
3060
|
+
try {
|
|
3061
|
+
const stat = fs.statSync(lockPath);
|
|
3062
|
+
lockAge = Math.max(0, now() - stat.mtimeMs);
|
|
3063
|
+
existing = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
|
|
3064
|
+
} catch { /* an incomplete lock is handled below, with a grace period */ }
|
|
3065
|
+
if (existing && Number.isInteger(existing.pid) && processAlive(existing.pid, existing)) {
|
|
3066
|
+
return { acquired: false, error: `another host synchronization is running (pid ${existing.pid})` };
|
|
3067
|
+
}
|
|
3068
|
+
if (!existing && lockAge < staleMs) {
|
|
3069
|
+
return { acquired: false, error: 'another host synchronization is publishing its lock' };
|
|
3070
|
+
}
|
|
3071
|
+
const quarantine = `${lockPath}.reclaim-${process.pid}-${token}-${attempt}`;
|
|
3072
|
+
try {
|
|
3073
|
+
fs.renameSync(lockPath, quarantine);
|
|
3074
|
+
fs.rmSync(quarantine, { recursive: true, force: true });
|
|
3075
|
+
} catch (error) {
|
|
3076
|
+
if (error?.code === 'ENOENT') continue;
|
|
3077
|
+
return { acquired: false, error: `host synchronization lock changed concurrently: ${error.message}` };
|
|
2939
3078
|
}
|
|
2940
3079
|
}
|
|
3080
|
+
return { acquired: false, error: 'host synchronization lock changed concurrently' };
|
|
2941
3081
|
}
|
|
2942
3082
|
|
|
2943
3083
|
export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
@@ -2995,9 +3135,27 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
2995
3135
|
if (!fs.existsSync(apply)) return fail({ error: 'Stable Spine updater missing from package' });
|
|
2996
3136
|
const applied = runStableSpine(apply);
|
|
2997
3137
|
const okApplied = !applied.error && applied.status === 0;
|
|
3138
|
+
const codexReceipt = {
|
|
3139
|
+
state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'),
|
|
3140
|
+
version: results.codex?.version || null,
|
|
3141
|
+
};
|
|
3142
|
+
if (results.codex?.restartRequired === true) {
|
|
3143
|
+
codexReceipt.restartRequired = true;
|
|
3144
|
+
codexReceipt.sessionSafety = results.codex.sessionSafety || null;
|
|
3145
|
+
codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
|
|
3146
|
+
}
|
|
3147
|
+
const claudeReceipt = {
|
|
3148
|
+
state: results.claude.host ? 'ready' : 'absent',
|
|
3149
|
+
version: results.claude.version || null,
|
|
3150
|
+
};
|
|
3151
|
+
if (results.claude?.restartRequired === true) {
|
|
3152
|
+
claudeReceipt.restartRequired = true;
|
|
3153
|
+
claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
|
|
3154
|
+
claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
|
|
3155
|
+
}
|
|
2998
3156
|
if (okApplied) {
|
|
2999
3157
|
// ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
|
|
3000
|
-
// modern leases prove no live consumer
|
|
3158
|
+
// modern leases prove no live consumer; legacy roots without that proof stay on disk.
|
|
3001
3159
|
try {
|
|
3002
3160
|
const gens = prunePluginGenerations({ apply: true });
|
|
3003
3161
|
if (gens.retired?.length) {
|
|
@@ -3027,8 +3185,8 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3027
3185
|
desiredVersion: PACKAGE_VERSION,
|
|
3028
3186
|
verifiedAt: new Date().toISOString(),
|
|
3029
3187
|
hosts: {
|
|
3030
|
-
claude:
|
|
3031
|
-
codex:
|
|
3188
|
+
claude: claudeReceipt,
|
|
3189
|
+
codex: codexReceipt,
|
|
3032
3190
|
},
|
|
3033
3191
|
consoleRuntime: results.consoleRuntime,
|
|
3034
3192
|
}, null, 2));
|
|
@@ -3042,8 +3200,8 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3042
3200
|
const convergence = classifyHostConvergence({
|
|
3043
3201
|
desiredVersion: PACKAGE_VERSION,
|
|
3044
3202
|
hosts: {
|
|
3045
|
-
claude:
|
|
3046
|
-
codex:
|
|
3203
|
+
claude: claudeReceipt,
|
|
3204
|
+
codex: codexReceipt,
|
|
3047
3205
|
},
|
|
3048
3206
|
consoleRuntime: results.consoleRuntime,
|
|
3049
3207
|
});
|
|
@@ -3061,7 +3219,11 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3061
3219
|
}
|
|
3062
3220
|
const hostStates = Object.values(receipt.hosts || {});
|
|
3063
3221
|
const badHost = hostStates.find((host) => !['ready', 'disabled', 'absent'].includes(host?.state)
|
|
3064
|
-
|| (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
|
|
3222
|
+
|| (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
|
|
3223
|
+
|| (host.state === 'ready' && host.restartRequired === true));
|
|
3224
|
+
if (badHost?.restartRequired === true) {
|
|
3225
|
+
return { healthy: false, state: 'host-restart-required', action: badHost.sessionSafetyReason || 'restart the host, then re-run --doctor' };
|
|
3226
|
+
}
|
|
3065
3227
|
if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
|
|
3066
3228
|
if (receipt.consoleRuntime?.state !== 'ready') {
|
|
3067
3229
|
return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: 'restart Console, then re-run --doctor' };
|
|
@@ -4801,10 +4963,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
4801
4963
|
console.log(`\n ${c.bold('Keep it fresh:')} re-run ${c.bold('npx ruvnet-brain')} any time — the brain itself always pulls the latest`);
|
|
4802
4964
|
console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
|
|
4803
4965
|
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
console.log(`\n ${c.yellow(c.bold('One required cleanup step:'))} restart any ${hostLabel} window that was open before this update.`);
|
|
4807
|
-
console.log(` ${c.dim('That unloads the old in-memory hook registry; new sessions load the intentional empty registry.')}`);
|
|
4966
|
+
console.log(`\n ${c.bold('Updates take effect:')} body-only releases go live on the next hook/MCP call without a restart.`);
|
|
4967
|
+
console.log(` ${c.dim('If boot-level declarations change, the installer names the affected host and requests one restart.')}`);
|
|
4808
4968
|
|
|
4809
4969
|
// ── what to do now ──
|
|
4810
4970
|
console.log(`\n ${c.bold('What to do now:')}`);
|
|
@@ -4912,8 +5072,8 @@ Env:
|
|
|
4912
5072
|
download or an air-gapped machine is not a broken install). Only ever set
|
|
4913
5073
|
this for a locked-down environment where you want to know immediately.
|
|
4914
5074
|
|
|
4915
|
-
It is safe to re-run at any time.
|
|
4916
|
-
|
|
5075
|
+
It is safe to re-run at any time. Body updates go live on the next hook/MCP call. Restart only when
|
|
5076
|
+
the installer reports that boot-level declarations changed.
|
|
4917
5077
|
`);
|
|
4918
5078
|
}
|
|
4919
5079
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.21",
|
|
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.
|
|
4
|
+
"version": "4.3.21",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// host-shell-boundary.mjs — one definition of the files a running host freezes at boot.
|
|
2
|
+
//
|
|
3
|
+
// Body files are reached through the Stable Spine and can change in place. These paths are the
|
|
4
|
+
// host declarations or boot-loaded surfaces whose change must be reported honestly to a running
|
|
5
|
+
// Claude/Codex session. Both the updater and the installer consume this one list so they cannot
|
|
6
|
+
// disagree about whether a release needs a restart.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const SHELL_PATHS = Object.freeze([
|
|
12
|
+
'hooks/hooks.json',
|
|
13
|
+
'scripts/hook-shim.mjs',
|
|
14
|
+
'scripts/hook-shim-bash.mjs',
|
|
15
|
+
'scripts/development-maintenance.mjs',
|
|
16
|
+
'mcp/server.mjs',
|
|
17
|
+
'.mcp.json',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function treeDigest(root, relative) {
|
|
21
|
+
const target = path.join(root, relative);
|
|
22
|
+
let stat;
|
|
23
|
+
try { stat = fs.lstatSync(target); } catch { return '<missing>'; }
|
|
24
|
+
if (stat.isSymbolicLink()) return '<symlink>';
|
|
25
|
+
if (stat.isFile()) return `file:${crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex')}`;
|
|
26
|
+
if (!stat.isDirectory()) return `<special:${stat.mode}>`;
|
|
27
|
+
const entries = fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
28
|
+
return `dir:${entries.map((entry) => `${entry.name}:${treeDigest(target, entry.name)}`).join('|')}`;
|
|
29
|
+
}
|
|
30
|
+
/** Return the boot-loaded paths that differ between two plugin payload roots. */
|
|
31
|
+
export function shellDiff(prevRootAbs, nextRootAbs) {
|
|
32
|
+
if (!prevRootAbs || !nextRootAbs) throw new Error('both plugin roots are required for shell comparison');
|
|
33
|
+
const changed = [];
|
|
34
|
+
for (const rel of SHELL_PATHS) {
|
|
35
|
+
if (treeDigest(prevRootAbs, rel) !== treeDigest(nextRootAbs, rel)) changed.push(rel);
|
|
36
|
+
}
|
|
37
|
+
// skills/ and commands/ are markdown surfaces loaded at host boot. Any file-set or content
|
|
38
|
+
// difference is a boot-surface change, even though they are not executable hook declarations.
|
|
39
|
+
for (const dir of ['skills', 'commands']) {
|
|
40
|
+
if (treeDigest(prevRootAbs, dir) !== treeDigest(nextRootAbs, dir)) changed.push(`${dir}/`);
|
|
41
|
+
}
|
|
42
|
+
return changed;
|
|
43
|
+
}
|
|
@@ -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
|
-
|
|
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]')
|
|
@@ -41,6 +41,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
41
41
|
// check on every .sh it was about to install — the platform most likely to receive a broken hook
|
|
42
42
|
// was the one platform that never checked. Git-for-Windows bash can run `bash -n` perfectly well.
|
|
43
43
|
import { resolveBash } from './hook-shim-bash.mjs';
|
|
44
|
+
import { SHELL_PATHS, shellDiff } from './host-shell-boundary.mjs';
|
|
45
|
+
export { SHELL_PATHS, shellDiff } from './host-shell-boundary.mjs';
|
|
44
46
|
|
|
45
47
|
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
46
48
|
const ACTIVE = path.join(BRAIN_HOME, 'active.json');
|
|
@@ -212,22 +214,6 @@ function promote(staging, version) {
|
|
|
212
214
|
// the update is fully live with no restart and session-start stays silent; if any did, the ONE
|
|
213
215
|
// honest nag fires (the release classifier computes the same thing publish-side; this is the
|
|
214
216
|
// 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) {
|
|
217
|
-
const changed = [];
|
|
218
|
-
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);
|
|
222
|
-
}
|
|
223
|
-
// skills/ and commands/ are boot-loaded markdown: any file-set or content difference counts.
|
|
224
|
-
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}/`);
|
|
227
|
-
}
|
|
228
|
-
return changed;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
217
|
function flip(version, codeRootAbs, why) {
|
|
232
218
|
const prev = readJSON(ACTIVE);
|
|
233
219
|
const prevRootAbs = prev?.codeRoot ? (path.isAbsolute(prev.codeRoot) ? prev.codeRoot : path.join(BRAIN_HOME, prev.codeRoot)) : null;
|
|
@@ -239,6 +225,15 @@ function flip(version, codeRootAbs, why) {
|
|
|
239
225
|
previous: prev ? { generation: prev.generation, version: prev.version, codeRoot: prev.codeRoot } : null,
|
|
240
226
|
shellChanged: shellChanged.length > 0,
|
|
241
227
|
shellChangedPaths: shellChanged,
|
|
228
|
+
// Keep the first boot-level change attached to subsequent body-only generations. A host that
|
|
229
|
+
// still booted the pre-change generation must not lose the restart signal merely because a
|
|
230
|
+
// later update changed only implementation code.
|
|
231
|
+
shellChangedSinceVersion: shellChanged.length > 0
|
|
232
|
+
? (prev?.version || version)
|
|
233
|
+
: (prev?.shellChangedSinceVersion || null),
|
|
234
|
+
shellChangedAtVersion: shellChanged.length > 0
|
|
235
|
+
? version
|
|
236
|
+
: (prev?.shellChangedAtVersion || null),
|
|
242
237
|
flippedAt: new Date().toISOString(),
|
|
243
238
|
};
|
|
244
239
|
writeAtomic(TXN, JSON.stringify({ state: 'flipping', from: prev?.version ?? null, to: version }));
|
|
@@ -446,4 +441,4 @@ function main() {
|
|
|
446
441
|
}
|
|
447
442
|
}
|
|
448
443
|
|
|
449
|
-
process.exit(main());
|
|
444
|
+
if (process.env.RUVNET_BRAIN_IMPORT_ONLY !== 'update-shell-test') process.exit(main());
|
package/scripts/wired-check.mjs
CHANGED
|
@@ -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
|
|
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 '
|