ruvnet-brain 4.3.40 β 4.4.1
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 +2 -2
- package/bin/install.mjs +179 -36
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +6 -2
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +33 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-reader.mjs +10 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +201 -6
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +383 -37
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +203 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +162 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- package/scripts/wired-check.mjs +17 -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
|
|
|
@@ -562,7 +562,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
|
|
|
562
562
|
|
|
563
563
|
This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β we don't claim βdone,β βcomplete,β or βzero hallucinations.β Where it stands:
|
|
564
564
|
|
|
565
|
-
- β
**The grounding brain is real and proven** β 199 public stores Β· 161,
|
|
565
|
+
- β
**The grounding brain is real and proven** β 199 public stores Β· 161,371 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
|
|
566
566
|
- β
**Code-level depth** β the code-rich repos are indexed to full function bodies; βhow is it implemented?β returns the implementation. Verified in the shipped bundle (clean-room 3/3).
|
|
567
567
|
- β
**Routing holds** β named 47/48, described 26/28, scenario 7/8; behavioral L1βL3 all pass (**L4 downgraded β it measures that the brain spoke, not that anything listened**); private stores fenced out of the public bundle (zero-leak verified).
|
|
568
568
|
- β οΈ **Two routing residuals** (above) β surfaced, not hidden.
|
package/bin/install.mjs
CHANGED
|
@@ -21,9 +21,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
21
21
|
import readline from 'node:readline';
|
|
22
22
|
import crypto from 'node:crypto';
|
|
23
23
|
import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
|
|
24
|
-
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, recordRefreshAdvisory,
|
|
24
|
+
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
|
|
25
25
|
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
26
26
|
import { pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
27
|
+
import { recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
27
28
|
import {
|
|
28
29
|
requiredEmbedderModels,
|
|
29
30
|
missingEmbedderModels,
|
|
@@ -70,6 +71,10 @@ import {
|
|
|
70
71
|
CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
|
|
71
72
|
} from '../scripts/console-runtime-identity.mjs';
|
|
72
73
|
import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
74
|
+
import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
|
|
75
|
+
import { cleanLegacyRufloDebris } from '../plugin/scripts/project-progression-store.mjs';
|
|
76
|
+
import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
|
|
77
|
+
import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
|
|
73
78
|
import {
|
|
74
79
|
writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
|
|
75
80
|
} from '../kb/corpus-release-identity.mjs';
|
|
@@ -228,6 +233,13 @@ function tryRun(cmd, args, opts = {}) {
|
|
|
228
233
|
const r = spawnSync(cmd, args, { stdio: 'inherit', shell: IS_WIN, ...opts });
|
|
229
234
|
return !r.error && r.status === 0;
|
|
230
235
|
}
|
|
236
|
+
// The claude/codex CLIs update themselves and can be absent for seconds (scripts/host-cli.mjs):
|
|
237
|
+
// retried with a bounded backoff, then ONE clear line instead of raw shell errors.
|
|
238
|
+
function tryHostCli(cmd, args, opts = {}) {
|
|
239
|
+
const r = runHostCli(cmd, args, opts);
|
|
240
|
+
if (r.missingBinary) warn(r.message);
|
|
241
|
+
return !r.missingBinary && !r.error && r.status === 0;
|
|
242
|
+
}
|
|
231
243
|
|
|
232
244
|
// ββ download with redirect-following + progress ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
233
245
|
function download(url, dest, redirects = 0) {
|
|
@@ -1271,24 +1283,57 @@ export function installConsoleRuntime(cacheDir, sourceRoot = REPO_ROOT) {
|
|
|
1271
1283
|
}
|
|
1272
1284
|
}
|
|
1273
1285
|
|
|
1286
|
+
// Receipts of Consoles that died (pid gone, port silent) are pruned, not counted: the owner's Mac
|
|
1287
|
+
// reported pending-console-restart forever from two receipts left on 2026-09-16/17
|
|
1288
|
+
// (scripts/console-instances.mjs).
|
|
1289
|
+
/**
|
|
1290
|
+
* --doctor's view of a recorded convergence receipt. Its Console state is a snapshot from the last sync;
|
|
1291
|
+
* a recorded pending-console-restart is re-read against the LIVE receipts, so a Console that has since
|
|
1292
|
+
* exited (or a receipt it left when it died) stops failing --doctor. Returns a new object.
|
|
1293
|
+
*/
|
|
1294
|
+
export function withLiveConsoleState(recorded, { receiptDir, alive, probe } = {}) {
|
|
1295
|
+
if (recorded?.consoleRuntime?.state !== 'pending-console-restart' || !recorded.consoleRuntime.sourceSha256) return recorded;
|
|
1296
|
+
const { replacementFailures, ...kept } = recorded.consoleRuntime;
|
|
1297
|
+
const live = { ...kept, ...consoleRestartState(recorded.consoleRuntime, { receiptDir, alive, probe }) };
|
|
1298
|
+
if (live.state !== 'ready' && replacementFailures) live.replacementFailures = replacementFailures;
|
|
1299
|
+
return { ...recorded, consoleRuntime: live };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* 4.3.40's ruflo leftovers inside this project's `.swarm` (cleanLegacyRufloDebris). --update removes
|
|
1304
|
+
* them; --doctor only reports (dryRun). Either way a REFUSED artifact (unexpected contents, a symlink) is
|
|
1305
|
+
* printed, never dropped silently. Not a project (or no .swarm): nothing to say.
|
|
1306
|
+
*/
|
|
1307
|
+
export function reportLegacyRufloDebris({ projectDir = process.cwd(), dryRun = false } = {}) {
|
|
1308
|
+
let storeDir;
|
|
1309
|
+
try { storeDir = path.dirname(resolveProjectStore({ projectDir }).canonicalAgentDbPath); } catch { return null; }
|
|
1310
|
+
if (!fs.existsSync(storeDir)) return null;
|
|
1311
|
+
const result = cleanLegacyRufloDebris(storeDir, { dryRun });
|
|
1312
|
+
for (const entry of result.removed) {
|
|
1313
|
+
// Dry run applies the same checks (allowlist, mirror proof, in-use window); only the final
|
|
1314
|
+
// unchanged-since-proof comparison can still keep it at --update time.
|
|
1315
|
+
if (dryRun) info(`legacy ruflo debris from 4.3.40 in ${entry}: passes every check; --update removes it unless it is written to before then`);
|
|
1316
|
+
else ok(`removed legacy ruflo debris from 4.3.40: ${entry}`);
|
|
1317
|
+
}
|
|
1318
|
+
for (const { path: entry, reason, kept } of result.refused) {
|
|
1319
|
+
// A KEPT nested AgentDB holds rows (or may still be written): never suggest deleting it by hand.
|
|
1320
|
+
if (kept) warn(`left 4.3.40's nested ruflo store in place β ${entry}: ${reason}. It is checked again on every update.`);
|
|
1321
|
+
else warn(`left legacy ruflo debris in place β ${entry}: ${reason}. Inspect it; remove it yourself if it is ruflo's.`);
|
|
1322
|
+
}
|
|
1323
|
+
return result;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1274
1326
|
export function consoleRestartState(identity, {
|
|
1275
1327
|
receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
|
|
1328
|
+
alive, probe,
|
|
1276
1329
|
} = {}) {
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
receipts = fs.readdirSync(receiptDir)
|
|
1280
|
-
.filter((name) => name.endsWith('.json'))
|
|
1281
|
-
.map((name) => {
|
|
1282
|
-
try { return JSON.parse(fs.readFileSync(path.join(receiptDir, name), 'utf8')); }
|
|
1283
|
-
catch { return null; }
|
|
1284
|
-
})
|
|
1285
|
-
.filter((receipt) => receipt?.product === 'ruvnet-brain-console' && receipt.schema === 1);
|
|
1286
|
-
} catch { /* no running Console receipts is the ordinary ready state */ }
|
|
1287
|
-
const staleInstances = receipts.filter((receipt) => receipt.sourceSha256 !== identity.sourceSha256).length;
|
|
1330
|
+
const { live, pruned } = readConsoleReceipts(receiptDir, { ...(alive ? { alive } : {}), ...(probe ? { probe } : {}) });
|
|
1331
|
+
const staleInstances = live.filter(({ receipt }) => receipt.sourceSha256 !== identity.sourceSha256).length;
|
|
1288
1332
|
return {
|
|
1289
1333
|
state: staleInstances > 0 ? 'pending-console-restart' : 'ready',
|
|
1290
|
-
instanceReceipts:
|
|
1334
|
+
instanceReceipts: live.length,
|
|
1291
1335
|
staleInstances,
|
|
1336
|
+
...(pruned.length ? { prunedDeadReceipts: pruned.length } : {}),
|
|
1292
1337
|
};
|
|
1293
1338
|
}
|
|
1294
1339
|
|
|
@@ -1406,7 +1451,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1406
1451
|
const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
|
|
1407
1452
|
const manualInstall = 'claude plugin install ruvnet-brain@ruvnet-brain --scope user';
|
|
1408
1453
|
|
|
1409
|
-
|
|
1454
|
+
const claudeCli = waitForHostCli('claude');
|
|
1455
|
+
if (!claudeCli.present) {
|
|
1456
|
+
if (claudeCli.message) warn(claudeCli.message);
|
|
1410
1457
|
warn(`I couldn't run the \`claude\` command from this shell.`);
|
|
1411
1458
|
info(`That's normal if you use Claude Code as the ${c.bold('VS Code extension')} or ${c.bold('desktop app')} β the`);
|
|
1412
1459
|
info(`command just isn't on your terminal's PATH. ${c.green('The brain itself is fully downloaded.')}`);
|
|
@@ -1423,15 +1470,15 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1423
1470
|
? inspectPluginShellBoundary(before.installPath)
|
|
1424
1471
|
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
1425
1472
|
const addedMarket = before.managed
|
|
1426
|
-
?
|
|
1427
|
-
:
|
|
1473
|
+
? tryHostCli('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
|
|
1474
|
+
: tryHostCli('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
|
|
1428
1475
|
// Deliberately NOT reassuring here. This used to say "it may already be added β that's fine",
|
|
1429
1476
|
// which is a GUESS about someone else's machine, and when it was wrong the user finished the
|
|
1430
1477
|
// install with a working search_ruvnet, no slash commands, and a message telling them all was
|
|
1431
1478
|
// well. The real state is checked below; nothing is declared fine until it has been looked at.
|
|
1432
1479
|
if (!addedMarket) info(`marketplace add didn't report success β checking what actually landedβ¦`);
|
|
1433
1480
|
|
|
1434
|
-
|
|
1481
|
+
tryHostCli('claude', before.installed
|
|
1435
1482
|
? ['plugin', 'update', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']
|
|
1436
1483
|
: ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
|
|
1437
1484
|
|
|
@@ -1442,17 +1489,22 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1442
1489
|
const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
|
|
1443
1490
|
if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
|
|
1444
1491
|
ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) β exact version ${installed.version}`);
|
|
1445
|
-
if (shellBoundary.restartRequired) {
|
|
1446
|
-
|
|
1492
|
+
if (shellBoundary.restartRequired && shellBoundary.known) {
|
|
1493
|
+
info(` boot-level plugin declarations changed (${shellBoundary.paths.join(', ')}): new Claude Code sessions load them; already-open windows keep the old hook definitions until they are reopened.`);
|
|
1494
|
+
} else if (shellBoundary.restartRequired) {
|
|
1495
|
+
warn(`could not verify the plugin's boot-level declarations (${shellBoundary.reason}); restart Claude Code once to be sure they are loaded.`);
|
|
1447
1496
|
} else if (before.installed && before.version !== installed.version) {
|
|
1448
1497
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1449
1498
|
}
|
|
1450
|
-
info(` commands available${shellBoundary.restartRequired ? '
|
|
1499
|
+
info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
|
|
1451
1500
|
return {
|
|
1452
1501
|
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1453
1502
|
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
1454
1503
|
restartRequired: shellBoundary.restartRequired,
|
|
1455
|
-
|
|
1504
|
+
// 'open-sessions': the new declarations are installed and PROVEN changed β only sessions already
|
|
1505
|
+
// open booted the old ones. 'unproven': the boot surface could not be compared at all.
|
|
1506
|
+
...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason,
|
|
1507
|
+
restartScope: shellBoundary.known ? 'open-sessions' : 'unproven' } : {}),
|
|
1456
1508
|
};
|
|
1457
1509
|
}
|
|
1458
1510
|
|
|
@@ -1856,13 +1908,16 @@ function runCodexJson(args, {
|
|
|
1856
1908
|
codexHome = codexHomeDir(),
|
|
1857
1909
|
cwd = process.cwd(),
|
|
1858
1910
|
} = {}) {
|
|
1859
|
-
const r =
|
|
1911
|
+
const r = runHostCli(codexBin, args, {
|
|
1912
|
+
stdio: 'pipe',
|
|
1913
|
+
shell: false,
|
|
1860
1914
|
cwd,
|
|
1861
1915
|
env: { ...process.env, CODEX_HOME: codexHome },
|
|
1862
1916
|
encoding: 'utf8',
|
|
1863
1917
|
timeout: 30_000,
|
|
1864
1918
|
maxBuffer: 20 * 1024 * 1024,
|
|
1865
1919
|
});
|
|
1920
|
+
if (r.missingBinary) return { ok: false, error: r.message };
|
|
1866
1921
|
if (r.error || r.status !== 0) {
|
|
1867
1922
|
const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
|
|
1868
1923
|
return { ok: false, error: detail };
|
|
@@ -1978,7 +2033,7 @@ export function wireCodexPlugin({
|
|
|
1978
2033
|
if (announce) {
|
|
1979
2034
|
ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
|
|
1980
2035
|
if (shellBoundary.restartRequired) {
|
|
1981
|
-
warn(`boot-level plugin declarations changed; restart Codex
|
|
2036
|
+
warn(`boot-level plugin declarations changed; restart Codex, then review them in /hooks (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
|
|
1982
2037
|
} else if (before.installed && before.version !== after.version) {
|
|
1983
2038
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1984
2039
|
}
|
|
@@ -1993,6 +2048,10 @@ export function wireCodexPlugin({
|
|
|
1993
2048
|
...(shellBoundary.restartRequired ? {
|
|
1994
2049
|
sessionSafety: 'restart-required',
|
|
1995
2050
|
sessionSafetyReason: shellBoundary.reason,
|
|
2051
|
+
// Never 'open-sessions' for Codex: changed hook definitions show as PENDING until reviewed in
|
|
2052
|
+
// /hooks (doctor fails closed on pending trust; the SessionStart notice says to trust them), and no
|
|
2053
|
+
// measurement shows a fresh Codex session running them without that step. Unproven = restart.
|
|
2054
|
+
restartScope: 'unproven',
|
|
1996
2055
|
} : {}),
|
|
1997
2056
|
};
|
|
1998
2057
|
}
|
|
@@ -2754,8 +2813,13 @@ async function doctor() {
|
|
|
2754
2813
|
let hostConvergence = { healthy: true, state: 'not-recorded' };
|
|
2755
2814
|
if (fs.existsSync(convergencePath)) {
|
|
2756
2815
|
try {
|
|
2757
|
-
|
|
2758
|
-
|
|
2816
|
+
const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
|
|
2817
|
+
{ receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
|
|
2818
|
+
hostConvergence = classifyHostConvergence(recorded);
|
|
2819
|
+
if (hostConvergence.healthy) {
|
|
2820
|
+
ok(`host convergence receipt: ${hostConvergence.state}`);
|
|
2821
|
+
if (hostConvergence.notice) info(hostConvergence.notice);
|
|
2822
|
+
}
|
|
2759
2823
|
else {
|
|
2760
2824
|
warn(`host convergence incomplete: ${hostConvergence.state}`);
|
|
2761
2825
|
info(`Retry the same generation: ${c.bold('npx ruvnet-brain --update')}${hostConvergence.action ? `; ${hostConvergence.action}` : ''}`);
|
|
@@ -2765,6 +2829,7 @@ async function doctor() {
|
|
|
2765
2829
|
warn(`host convergence receipt is invalid: ${error.message}`);
|
|
2766
2830
|
}
|
|
2767
2831
|
}
|
|
2832
|
+
try { reportLegacyRufloDebris({ dryRun: true }); } catch (error) { warn(`legacy ruflo debris check failed: ${error.message}`); }
|
|
2768
2833
|
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
|
|
2769
2834
|
const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
|
|
2770
2835
|
brainHome, kbDir: cacheDir });
|
|
@@ -3274,6 +3339,18 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
|
|
|
3274
3339
|
if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
|
|
3275
3340
|
return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
|
|
3276
3341
|
}
|
|
3342
|
+
// The updater refused BECAUSE full-KB copies already sit beside the brain ("refusing to create another
|
|
3343
|
+
// full-KB copy"). A fresh install is exactly another full copy (it preserves the prior generation), so
|
|
3344
|
+
// the fallback would turn the refusal into +1 copy per run (measured: 2 -> 3, +1.3 GB). Report instead.
|
|
3345
|
+
if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
|
|
3346
|
+
return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
|
|
3347
|
+
}
|
|
3348
|
+
// Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
|
|
3349
|
+
// bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
|
|
3350
|
+
// reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
|
|
3351
|
+
if (status === 2 && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
|
|
3352
|
+
return { verdict: 'transient-network', fallback: false, exitCode: 2 };
|
|
3353
|
+
}
|
|
3277
3354
|
return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
|
|
3278
3355
|
}
|
|
3279
3356
|
|
|
@@ -3370,6 +3447,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3370
3447
|
wireCodexHost: detectCodexHost = wireCodexHost,
|
|
3371
3448
|
wireCodexPlugin: installCodexPlugin = wireCodexPlugin,
|
|
3372
3449
|
hostLockPath = path.join(brainHome, 'host-convergence.lock'),
|
|
3450
|
+
replaceConsoles = null, // test seam; production runs replaceStaleConsoles
|
|
3373
3451
|
runStableSpine = (apply) => spawnSync(
|
|
3374
3452
|
process.execPath,
|
|
3375
3453
|
[apply, '--auto', '--expected-version', PACKAGE_VERSION],
|
|
@@ -3425,6 +3503,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3425
3503
|
codexReceipt.restartRequired = true;
|
|
3426
3504
|
codexReceipt.sessionSafety = results.codex.sessionSafety || null;
|
|
3427
3505
|
codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
|
|
3506
|
+
codexReceipt.restartScope = results.codex.restartScope || 'unproven';
|
|
3428
3507
|
}
|
|
3429
3508
|
const claudeReceipt = {
|
|
3430
3509
|
state: results.claude.host ? 'ready' : 'absent',
|
|
@@ -3434,6 +3513,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3434
3513
|
claudeReceipt.restartRequired = true;
|
|
3435
3514
|
claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
|
|
3436
3515
|
claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
|
|
3516
|
+
claudeReceipt.restartScope = results.claude.restartScope || 'unproven';
|
|
3437
3517
|
}
|
|
3438
3518
|
if (okApplied) {
|
|
3439
3519
|
// ISSUE #153 β a running host may freeze an old plugin root. Reclaim only generations whose
|
|
@@ -3455,12 +3535,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3455
3535
|
warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
|
|
3456
3536
|
}
|
|
3457
3537
|
const receiptPath = path.join(brainHome, 'host-convergence.json');
|
|
3458
|
-
|
|
3459
|
-
runtimeTransaction.activate();
|
|
3460
|
-
results.consoleRuntime = {
|
|
3461
|
-
...runtimeTransaction.identity,
|
|
3462
|
-
...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
|
|
3463
|
-
};
|
|
3538
|
+
const writeConvergenceReceipt = () => {
|
|
3464
3539
|
fs.mkdirSync(brainHome, { recursive: true });
|
|
3465
3540
|
const tmp = `${receiptPath}.tmp-${process.pid}`;
|
|
3466
3541
|
fs.writeFileSync(tmp, JSON.stringify({
|
|
@@ -3473,10 +3548,39 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3473
3548
|
consoleRuntime: results.consoleRuntime,
|
|
3474
3549
|
}, null, 2));
|
|
3475
3550
|
fs.renameSync(tmp, receiptPath);
|
|
3551
|
+
};
|
|
3552
|
+
try {
|
|
3553
|
+
runtimeTransaction.activate();
|
|
3554
|
+
results.consoleRuntime = {
|
|
3555
|
+
...runtimeTransaction.identity,
|
|
3556
|
+
...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
|
|
3557
|
+
};
|
|
3558
|
+
writeConvergenceReceipt();
|
|
3476
3559
|
runtimeTransaction.commit();
|
|
3477
3560
|
} catch (error) {
|
|
3478
3561
|
return fail({ applyStatus: applied.status, error: `Console runtime convergence failed: ${error.message}` });
|
|
3479
3562
|
}
|
|
3563
|
+
// A Console still serving the previous runtime is replaced here, by the activated runtime's own
|
|
3564
|
+
// launcher (the 'stale-running' path of scripts/onboarding-console.mjs, without --open), so an
|
|
3565
|
+
// update never ends with "restart Console". Anything it could not replace is recorded with why.
|
|
3566
|
+
if (results.consoleRuntime.state === 'pending-console-restart') {
|
|
3567
|
+
try {
|
|
3568
|
+
const replaceArgs = { entry: runtimeTransaction.entry, identity: runtimeTransaction.identity, receiptDir: consoleReceiptDir };
|
|
3569
|
+
results.consoleReplacement = replaceConsoles ? replaceConsoles(replaceArgs) : replaceStaleConsoles(replaceArgs);
|
|
3570
|
+
const failures = results.consoleReplacement.filter((item) => !item.replaced);
|
|
3571
|
+
for (const item of results.consoleReplacement) {
|
|
3572
|
+
if (item.replaced) ok(`Console on port ${item.port} replaced with the current runtime (pid ${item.newPid})`);
|
|
3573
|
+
}
|
|
3574
|
+
results.consoleRuntime = {
|
|
3575
|
+
...runtimeTransaction.identity,
|
|
3576
|
+
...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
|
|
3577
|
+
...(failures.length ? { replacementFailures: failures.map((item) => `port ${item.port ?? '?'}: ${item.reason}`) } : {}),
|
|
3578
|
+
};
|
|
3579
|
+
writeConvergenceReceipt();
|
|
3580
|
+
} catch (error) {
|
|
3581
|
+
warn(`could not replace the running Console automatically (${error.message})`);
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3480
3584
|
}
|
|
3481
3585
|
if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
|
|
3482
3586
|
const convergence = classifyHostConvergence({
|
|
@@ -3500,19 +3604,36 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3500
3604
|
return { healthy: false, state: 'version-mismatch', action: `required version ${expectedVersion}` };
|
|
3501
3605
|
}
|
|
3502
3606
|
const hostStates = Object.values(receipt.hosts || {});
|
|
3503
|
-
|
|
3607
|
+
// A host that is ready at the expected version and whose ONLY gap is PROVEN changed boot-level
|
|
3608
|
+
// declarations is converged: new sessions load the new hooks; only windows already open booted the
|
|
3609
|
+
// old ones (owner's Mac, 4.3.40 -> 4.4.0: a fresh `claude -p` loaded 4.4.0 and ran its hooks). That is
|
|
3610
|
+
// reported, not failed. An UNPROVEN boot surface (it could not be compared) still requires a restart.
|
|
3611
|
+
const openSessionsOnly = (host) => host?.state === 'ready' && versionSatisfies(host.version, expectedVersion)
|
|
3612
|
+
&& host.restartRequired === true && host.restartScope === 'open-sessions';
|
|
3613
|
+
const openSessions = Object.entries(receipt.hosts || {}).filter(([, host]) => openSessionsOnly(host)).map(([name]) => name);
|
|
3614
|
+
const badHost = hostStates.find((host) => !openSessionsOnly(host) && (!['ready', 'disabled', 'absent'].includes(host?.state)
|
|
3504
3615
|
|| (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
|
|
3505
|
-
|| (host.state === 'ready' && host.restartRequired === true));
|
|
3616
|
+
|| (host.state === 'ready' && host.restartRequired === true)));
|
|
3506
3617
|
if (badHost?.restartRequired === true) {
|
|
3507
3618
|
return { healthy: false, state: 'host-restart-required', action: badHost.sessionSafetyReason || 'restart the host, then re-run --doctor' };
|
|
3508
3619
|
}
|
|
3509
3620
|
if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
|
|
3510
3621
|
if (receipt.consoleRuntime?.state !== 'ready') {
|
|
3511
|
-
|
|
3622
|
+
const why = Array.isArray(receipt.consoleRuntime?.replacementFailures) && receipt.consoleRuntime.replacementFailures.length
|
|
3623
|
+
? `the installer could not replace the running Console (${receipt.consoleRuntime.replacementFailures.join('; ')}); ` : '';
|
|
3624
|
+
return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: `${why}restart Console, then re-run --doctor` };
|
|
3512
3625
|
}
|
|
3626
|
+
if (openSessions.length) return { healthy: true, state: 'channels-converged', openSessions, notice: openSessionsNotice(openSessions, receipt.desiredVersion) };
|
|
3513
3627
|
return { healthy: true, state: 'channels-converged' };
|
|
3514
3628
|
}
|
|
3515
3629
|
|
|
3630
|
+
const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
|
|
3631
|
+
/** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
|
|
3632
|
+
export function openSessionsNotice(hosts, version) {
|
|
3633
|
+
const names = hosts.map((host) => HOST_LABELS[host] || host).join('/');
|
|
3634
|
+
return `new ${names} sessions use ${version}; already-open windows keep the old hook definitions until they are reopened`;
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3516
3637
|
async function runUpdate() {
|
|
3517
3638
|
printBanner('update');
|
|
3518
3639
|
const kbDir = resolvedKbDir();
|
|
@@ -3525,6 +3646,7 @@ async function runUpdate() {
|
|
|
3525
3646
|
process.exitCode = 1;
|
|
3526
3647
|
return;
|
|
3527
3648
|
}
|
|
3649
|
+
if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
3528
3650
|
try {
|
|
3529
3651
|
const managed = applyManagedCatalogUpdate({
|
|
3530
3652
|
routerDir: path.join(os.homedir(), '.claude', 'model-router'),
|
|
@@ -3587,6 +3709,21 @@ async function runUpdate() {
|
|
|
3587
3709
|
};
|
|
3588
3710
|
process.once('exit', exitGuard);
|
|
3589
3711
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
3712
|
+
// RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
|
|
3713
|
+
// reasons: (1) a kill between the two directory renames leaves no usable kb/ β the brain sits in a
|
|
3714
|
+
// receipted kb.rollback-<id>, and the recovery that renames it back lives inside the updater that is
|
|
3715
|
+
// now missing; (2) the preflight below re-stamps RUNTIME-IDENTITY.json into kb/, after which recovery
|
|
3716
|
+
// can never prove kb/ still equals the identity sealed at LOCKED, so any pre-activation kill wedged
|
|
3717
|
+
// every later update at RECOVERY_REQUIRED. Same function the updater runs, under this refresh lock.
|
|
3718
|
+
// Receipts hold the REAL paths the updater knew, so recovery is addressed by the physical path.
|
|
3719
|
+
if (fs.existsSync(path.join(path.dirname(kbDir), `.${path.basename(kbDir)}.update-transactions`))) {
|
|
3720
|
+
try {
|
|
3721
|
+
const recovered = recoverIncompleteStorageTransactions(physicalPath(kbDir));
|
|
3722
|
+
if (recovered.length) ok(`restored the brain from an interrupted update (${recovered.map((r) => `${r.transactionId}: ${r.from}`).join(', ')})`);
|
|
3723
|
+
} catch (error) {
|
|
3724
|
+
warn(`an interrupted update could not be recovered automatically: ${error.message}`);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3590
3727
|
let updateStatus = 1;
|
|
3591
3728
|
// NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
|
|
3592
3729
|
// message, not a fallback trigger: fail LOUD with the re-run-installer help and exit β never
|
|
@@ -3645,6 +3782,10 @@ async function runUpdate() {
|
|
|
3645
3782
|
result: updaterResult,
|
|
3646
3783
|
requireResult: supportsResultReceipt,
|
|
3647
3784
|
});
|
|
3785
|
+
if (outcome.verdict === 'refused-retained-copies') {
|
|
3786
|
+
warn('nothing was changed: full copies of earlier brain generations already sit beside this one (listed above).');
|
|
3787
|
+
info('Check that you no longer need them, remove them, then re-run npx ruvnet-brain --update');
|
|
3788
|
+
}
|
|
3648
3789
|
if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
|
|
3649
3790
|
// Host synchronization has a narrower contract than a full update: it must converge the
|
|
3650
3791
|
// executable plugin/spine to the published package even when an optional large KB asset is
|
|
@@ -3742,9 +3883,11 @@ async function runUpdate() {
|
|
|
3742
3883
|
if (!convergence.ok) {
|
|
3743
3884
|
warn(`host synchronization is incomplete β runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
|
|
3744
3885
|
updateStatus = 1;
|
|
3745
|
-
}
|
|
3886
|
+
} else if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
3887
|
+
try { reportLegacyRufloDebris(); } catch (error) { warn(`legacy ruflo debris cleanup failed: ${error.message}`); }
|
|
3746
3888
|
recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
|
|
3747
3889
|
state: convergence.convergence?.state || null, error: convergence.error || null,
|
|
3890
|
+
...(convergence.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
|
|
3748
3891
|
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
3749
3892
|
});
|
|
3750
3893
|
}
|
|
@@ -4951,7 +5094,7 @@ async function offerStack(env) {
|
|
|
4951
5094
|
for (const m of missing) {
|
|
4952
5095
|
if (m.shell) {
|
|
4953
5096
|
info(`installing ${m.what} β¦ ${c.dim(m.say)}`);
|
|
4954
|
-
const ran = tryRun(m.shell[0], m.shell[1]);
|
|
5097
|
+
const ran = ['claude', 'codex'].includes(m.shell[0]) ? tryHostCli(m.shell[0], m.shell[1]) : tryRun(m.shell[0], m.shell[1]);
|
|
4955
5098
|
// Don't trust the exit code alone β e.g. `claude mcp add` exits non-zero on "already exists",
|
|
4956
5099
|
// which is functionally success. Re-check the real state (m.verify) before warning.
|
|
4957
5100
|
if (ran || (m.verify && m.verify())) ok(`${m.what} added`);
|
package/kb/brain-profile.mjs
CHANGED
|
@@ -61,16 +61,31 @@ function profileOwnership(dir) {
|
|
|
61
61
|
? Object.entries(source.stores) : null;
|
|
62
62
|
if (!entries) throw new Error('invalid SOURCE ownership policy');
|
|
63
63
|
const privateNames = new Set(fence.privateStores.map((s) => s.toLowerCase()));
|
|
64
|
+
// Published store names include dots (`dspy.ts`, `ruv.io`). A name may not start with a dot, hold `..`
|
|
65
|
+
// or a separator, or end in an artifact suffix (`x.big` would collide with store x's `x.big.rvf`).
|
|
66
|
+
const safeName = (name) => typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes('..')
|
|
67
|
+
&& !/\.(?:big|rvf)$/i.test(name);
|
|
64
68
|
const managed = new Set();
|
|
69
|
+
const optedOut = new Set();
|
|
65
70
|
const seen = new Set();
|
|
66
71
|
for (const [name, value] of entries) {
|
|
67
|
-
if (
|
|
72
|
+
if (!safeName(name)
|
|
68
73
|
|| !value || typeof value !== 'object' || Array.isArray(value)
|
|
69
74
|
|| (value.kbName != null && value.kbName !== name)
|
|
70
75
|
|| (value.updateManaged != null && typeof value.updateManaged !== 'boolean')
|
|
71
76
|
|| seen.has(name.toLowerCase())) throw new Error('invalid SOURCE store ownership');
|
|
72
77
|
seen.add(name.toLowerCase());
|
|
73
|
-
if (value.updateManaged
|
|
78
|
+
if (value.updateManaged === false) optedOut.add(name.toLowerCase());
|
|
79
|
+
else if (!privateNames.has(name.toLowerCase())) managed.add(name);
|
|
80
|
+
}
|
|
81
|
+
// The release's PUBLIC ledger is the other ownership record: stores it publishes but SOURCE does not list
|
|
82
|
+
// (concepts, ruv-gists) are still release-owned, and forge-update's profiled check expects them gone.
|
|
83
|
+
const publicFile = path.join(dir, 'PUBLIC-RVF-GENERATIONS.json');
|
|
84
|
+
if (fs.existsSync(publicFile)) {
|
|
85
|
+
for (const name of Object.keys(read('PUBLIC-RVF-GENERATIONS.json').stores || {})) {
|
|
86
|
+
const key = String(name).toLowerCase();
|
|
87
|
+
if (safeName(name) && !privateNames.has(key) && !optedOut.has(key)) managed.add(name);
|
|
88
|
+
}
|
|
74
89
|
}
|
|
75
90
|
return { managed, read };
|
|
76
91
|
}
|
package/kb/forge-update.mjs
CHANGED
|
@@ -650,7 +650,9 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
|
|
|
650
650
|
const cardsFile = path.join(kbDir, 'capability-cards.md');
|
|
651
651
|
const source = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
|
|
652
652
|
const generations = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
|
|
653
|
-
|
|
653
|
+
// A public bundle may ship no repo-aliases.json at all (build-bundle: "aliases will not resolve"); the
|
|
654
|
+
// private aliases then start from an empty map instead of failing the whole update on ENOENT.
|
|
655
|
+
const aliases = fs.existsSync(aliasesFile) ? JSON.parse(fs.readFileSync(aliasesFile, 'utf8')) : {};
|
|
654
656
|
const mergedSource = mergePrivateEntries(source.stores, overlay.sourceStores, 'SOURCE.json');
|
|
655
657
|
const mergedGenerations = mergePrivateEntries(generations.stores, overlay.generationStores, 'RVF-GENERATIONS.json');
|
|
656
658
|
const mergedAliases = mergePrivateEntries(aliases, overlay.aliases, 'repo-aliases.json');
|
|
@@ -1640,7 +1642,9 @@ async function main() {
|
|
|
1640
1642
|
if (!APPLY) {
|
|
1641
1643
|
writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
|
|
1642
1644
|
candidateKind: candidateIdentity.kind, storeCount: targets.length });
|
|
1643
|
-
|
|
1645
|
+
// The npx door upgrades this updater before applying; an old installed updater run directly can fail
|
|
1646
|
+
// the guard on a newer bundle (customer-state-matrix D8, 2026-09-30).
|
|
1647
|
+
if (anyBehind) { console.log(`\nA newer build exists. Run: npx ruvnet-brain@latest --update`); process.exit(10); }
|
|
1644
1648
|
console.log(`\nAll stores current. Nothing to do.`); process.exit(0);
|
|
1645
1649
|
}
|
|
1646
1650
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { physicalPath } from './refresh-run.mjs';
|
|
4
5
|
|
|
5
6
|
const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
|
|
6
7
|
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
@@ -113,10 +114,11 @@ function newest(rows, predicate) {
|
|
|
113
114
|
|
|
114
115
|
function trustedEvidenceRoot(root, unsafe) {
|
|
115
116
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// The PARENT is where the user put the brain (`~/.cache/ruvnet-brain` may be a link to another disk):
|
|
118
|
+
// it must be a directory once resolved. The evidence root itself must not be a link.
|
|
119
|
+
if (!fs.statSync(physicalPath(path.dirname(root))).isDirectory()) throw new Error('evidence root parent is not a directory');
|
|
120
|
+
const stat = fs.lstatSync(root);
|
|
121
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('evidence root is not a trusted directory (symbolic or special entry)');
|
|
120
122
|
return true;
|
|
121
123
|
} catch (error) {
|
|
122
124
|
if (error.code !== 'ENOENT') unsafe.push({ path: root, reason: error.message });
|
|
@@ -130,7 +132,7 @@ function snapshot(options) {
|
|
|
130
132
|
const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe) : [];
|
|
131
133
|
const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe) : [];
|
|
132
134
|
const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
|
|
133
|
-
const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) =>
|
|
135
|
+
const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => physicalPath(entry)));
|
|
134
136
|
const protectedRefresh = new Map();
|
|
135
137
|
const protectRefresh = (row, reason) => { if (row) protectedRefresh.set(row.path, reason); };
|
|
136
138
|
for (const row of refresh) {
|
|
@@ -147,17 +149,18 @@ function snapshot(options) {
|
|
|
147
149
|
if (!protectedRefresh.has(row.path)) continue;
|
|
148
150
|
for (const reference of transactionReferences(row.receipt)) {
|
|
149
151
|
const resolved = path.resolve(String(reference || ''));
|
|
150
|
-
|
|
152
|
+
// The updater records real paths; the caller may spell the brain through a link. Same space, both sides.
|
|
153
|
+
const relative = path.relative(physicalPath(roots.transactions), physicalPath(resolved));
|
|
151
154
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
|
|
152
155
|
unsafe.push({ path: row.path, reason: `forged external transaction reference: ${String(reference)}` });
|
|
153
|
-
} else retainedTransactionPaths.add(resolved);
|
|
156
|
+
} else retainedTransactionPaths.add(physicalPath(resolved));
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
const protectedTransactions = new Map();
|
|
157
160
|
for (const row of transactions) {
|
|
158
161
|
if (!TERMINAL_TRANSACTION.has(row.latest.state)) protectedTransactions.set(row.path, `nonterminal ${row.latest.state}`);
|
|
159
|
-
if (preserveTransactions.has(row.path)) protectedTransactions.set(row.path, 'explicitly preserved transaction');
|
|
160
|
-
if (retainedTransactionPaths.has(row.path)) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
|
|
162
|
+
if (preserveTransactions.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'explicitly preserved transaction');
|
|
163
|
+
if (retainedTransactionPaths.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
|
|
161
164
|
}
|
|
162
165
|
const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
|
|
163
166
|
+ unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
|
package/kb/refresh-run.mjs
CHANGED
|
@@ -78,6 +78,19 @@ export function inspectRefreshOwner(owner, { hostname = os.hostname(), inspectPr
|
|
|
78
78
|
return ownerState(owner, { hostname, inspectProcess, isAlive });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The ONE physical-path rule for brain directories. A user may reach the brain through a symlink
|
|
83
|
+
* (`~/.cache -> /Volumes/<disk>`) while the updater knows it by its real path (Node resolves
|
|
84
|
+
* import.meta.url), so identity checks compare this, never spellings. A directory that does not
|
|
85
|
+
* exist right now (kb/ mid-rename) resolves through its parent, so the answer does not flip.
|
|
86
|
+
*/
|
|
87
|
+
export function physicalPath(dir) {
|
|
88
|
+
const resolved = path.resolve(String(dir || ''));
|
|
89
|
+
try { return fs.realpathSync.native(resolved); } catch { /* absent: resolve through the parent */ }
|
|
90
|
+
try { return path.join(fs.realpathSync.native(path.dirname(resolved)), path.basename(resolved)); }
|
|
91
|
+
catch { return resolved; }
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
export const refreshLockPath = (kbDir) =>
|
|
82
95
|
path.join(path.dirname(path.resolve(kbDir)), `.${path.basename(path.resolve(kbDir))}.refresh-run.lock`);
|
|
83
96
|
|
|
@@ -156,7 +169,10 @@ export function acquireRefreshLock({
|
|
|
156
169
|
const inherited = String(env.RUVNET_REFRESH_RUN_TOKEN || '');
|
|
157
170
|
if (inherited) {
|
|
158
171
|
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
|
|
159
|
-
|
|
172
|
+
// Same DIRECTORY, not same spelling: the installer locks RUVNET_BRAIN_KB as given (possibly through
|
|
173
|
+
// a symlinked ~/.cache), the child updater knows its KB by import.meta.url, which Node resolves to
|
|
174
|
+
// the real path. Compare physical identity; a genuinely different directory still refuses.
|
|
175
|
+
if (owner.schemaVersion !== 3 || owner.token !== inherited || physicalPath(owner.kbDir) !== physicalPath(root)
|
|
160
176
|
|| path.resolve(owner.receiptPath || '') !== refreshReceiptPath(owner.brainHome, owner.runId)) {
|
|
161
177
|
throw new Error(`refresh lock inheritance does not match ${lockPath}`);
|
|
162
178
|
}
|