ruvnet-brain 4.5.0 → 4.5.2
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 +20 -3
- package/kb/forge-update.mjs +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/server.mjs +27 -1
- package/plugin/scripts/brain-confirmation.mjs +26 -5
- package/plugin/scripts/brain-footprint.mjs +10 -5
- package/plugin/scripts/footprint-io.mjs +11 -4
- package/plugin/scripts/host-update.mjs +57 -1
- package/plugin/scripts/session-start-health.mjs +42 -3
- package/plugin/scripts/session-start-update-plane.mjs +58 -23
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
|
@@ -27,7 +27,7 @@ import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle
|
|
|
27
27
|
import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
28
28
|
import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
29
29
|
import { kbCopyProof } from '../plugin/scripts/kb-copy-proof.mjs';
|
|
30
|
-
import { isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
|
|
30
|
+
import { assessMoveLeftovers, isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
|
|
31
31
|
import { confirm, doctorVerdict, formatBytes, formatConfirmation, signatureEvidenceFromReceipts, signatureRecordValid, writeSignatureRecord } from '../plugin/scripts/brain-confirmation.mjs';
|
|
32
32
|
import {
|
|
33
33
|
requiredEmbedderModels,
|
|
@@ -3890,6 +3890,20 @@ function refuseUnmountedBrain() {
|
|
|
3890
3890
|
die(roots.location.message, 'Nothing was installed, updated or removed.');
|
|
3891
3891
|
}
|
|
3892
3892
|
|
|
3893
|
+
// An interrupted --move-brain left the ONLY Brain at <home>.old-<pid> while the home path is missing or was
|
|
3894
|
+
// recreated without a Brain by a hook (4.5.2): an install or update there would build a second, public-only
|
|
3895
|
+
// Brain beside it. Refuse with the restore command instead. Names only: no tree walk.
|
|
3896
|
+
function refuseOverSetAsideBrain() {
|
|
3897
|
+
let lo = null;
|
|
3898
|
+
try { const roots = footprintRoots(); lo = assessMoveLeftovers({ brainHome: roots.location?.path || roots.brainHome, location: roots.location }).find((l) => l.onlyCopy); }
|
|
3899
|
+
catch { return false; }
|
|
3900
|
+
if (!lo) return false;
|
|
3901
|
+
warn(`the Brain is not at its path, but an interrupted move left the ONLY copy at ${lo.path} — restore it, do NOT reinstall: ${lo.fix}`);
|
|
3902
|
+
info(`then check: ${c.bold('npx ruvnet-brain --doctor')}`);
|
|
3903
|
+
process.exitCode = 1;
|
|
3904
|
+
return true;
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3893
3907
|
async function runUpdate() {
|
|
3894
3908
|
printBanner('update');
|
|
3895
3909
|
refuseUnmountedBrain();
|
|
@@ -4473,7 +4487,9 @@ async function printConfirmation({ footprint = null, json = false, print = true
|
|
|
4473
4487
|
const now = footprintNow();
|
|
4474
4488
|
// Read-only: the inventory only — no copy is proven (no GB-sized hashing) and nothing is written (re-review S4).
|
|
4475
4489
|
const fp = footprint || inventoryFootprint({ now, evidence: footprintEvidence() });
|
|
4476
|
-
|
|
4490
|
+
// The doctor also checks the directory's bytes against the release projection (bounded: ~0.3 s on 1.4 GB).
|
|
4491
|
+
const result = confirm({ footprint: fp, npmLatest: await npmLatestVersion(), installedVersion: PACKAGE_VERSION, now,
|
|
4492
|
+
coverageIntegrity: FLAG_DOCTOR ? (dir) => validateCoverageDirectory(dir) : null });
|
|
4477
4493
|
if (!print) return result;
|
|
4478
4494
|
if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
4479
4495
|
else console.log(`\n${formatConfirmation(result, { color: c })}`);
|
|
@@ -6061,7 +6077,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6061
6077
|
}
|
|
6062
6078
|
if (FLAG_DEMO) return runDemo();
|
|
6063
6079
|
if (FLAG_FEEDBACK) return runFeedback();
|
|
6064
|
-
if (FLAG_UPDATE) return runUpdate();
|
|
6080
|
+
if (FLAG_UPDATE) return refuseOverSetAsideBrain() || runUpdate();
|
|
6065
6081
|
if (FLAG_ENABLE_NIGHTLY) return enableNightly();
|
|
6066
6082
|
if (FLAG_DISABLE_NIGHTLY) return disableNightly();
|
|
6067
6083
|
// Standalone, like the nightly pair above. Without these, the flags existed only as a way to
|
|
@@ -6074,6 +6090,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6074
6090
|
if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
|
|
6075
6091
|
if (FLAG_WHATS_NEW) { showWhatsNew(); return; }
|
|
6076
6092
|
|
|
6093
|
+
if (refuseOverSetAsideBrain()) return;
|
|
6077
6094
|
printBanner('installer');
|
|
6078
6095
|
console.log(c.dim("I'll set up the brain for Claude Code and Codex, explaining each step as I go.\n"));
|
|
6079
6096
|
|
package/kb/forge-update.mjs
CHANGED
|
@@ -1627,7 +1627,7 @@ async function main() {
|
|
|
1627
1627
|
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1628
1628
|
storeCount: targets.length })
|
|
1629
1629
|
: writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
|
|
1630
|
-
candidateKind: candidateIdentity.kind, storeCount: targets.length });
|
|
1630
|
+
candidateKind: candidateIdentity.kind, candidateTag: candidateIdentity.tag, storeCount: targets.length });
|
|
1631
1631
|
if (refusedOutcome?.terminalVerdict === 'recovery-required') die(refusedOutcome.reason);
|
|
1632
1632
|
process.exit(0);
|
|
1633
1633
|
}
|
|
@@ -1649,7 +1649,7 @@ async function main() {
|
|
|
1649
1649
|
|
|
1650
1650
|
if (!APPLY) {
|
|
1651
1651
|
writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
|
|
1652
|
-
candidateKind: candidateIdentity.kind, storeCount: targets.length });
|
|
1652
|
+
candidateKind: candidateIdentity.kind, candidateTag: candidateIdentity.tag, storeCount: targets.length });
|
|
1653
1653
|
// The npx door upgrades this updater before applying; an old installed updater run directly can fail
|
|
1654
1654
|
// the guard on a newer bundle (customer-state-matrix D8, 2026-09-30).
|
|
1655
1655
|
if (anyBehind) { console.log(`\nA newer build exists. Run: npx ruvnet-brain@latest --update`); process.exit(10); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.2",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain \u2014 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 a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.5.
|
|
4
|
+
"version": "4.5.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
package/plugin/mcp/server.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import fs from 'node:fs';
|
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import os from 'node:os';
|
|
35
35
|
import readline from 'node:readline';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
36
37
|
import { writeOwn as writeOwnReadiness } from '../scripts/mcp-readiness.mjs';
|
|
37
38
|
import { callManagedCli, MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
|
|
38
39
|
import { unmountedNotice } from '../scripts/brain-location.mjs';
|
|
@@ -133,7 +134,11 @@ function currentGeneration() {
|
|
|
133
134
|
const a = readJSON(ACTIVE);
|
|
134
135
|
let brainMtime = 0;
|
|
135
136
|
try { brainMtime = fs.statSync(CHILD_MCP).mtimeMs; } catch { /* brain absent */ }
|
|
136
|
-
|
|
137
|
+
// The KB's own identity record: every knowledge update (corpus-only included) rewrites SOURCE.json, so a
|
|
138
|
+
// swapped knowledge base respawns the worker even when its code file is byte- and mtime-identical.
|
|
139
|
+
let knowledgeMtime = 0;
|
|
140
|
+
try { knowledgeMtime = fs.statSync(path.join(KB, 'SOURCE.json')).mtimeMs; } catch { /* brain absent */ }
|
|
141
|
+
return `${a?.generation ?? 0}:${brainMtime}:${knowledgeMtime}`;
|
|
137
142
|
}
|
|
138
143
|
function refreshLease() {
|
|
139
144
|
try {
|
|
@@ -393,6 +398,27 @@ async function handleClient(msg) {
|
|
|
393
398
|
}
|
|
394
399
|
}
|
|
395
400
|
|
|
401
|
+
// KNOWLEDGE CURRENCY TIMER (2026-10-02). A session can stay open for days and SessionStart never fires
|
|
402
|
+
// again, so this process — alive for the whole session on Claude Code and Codex alike — runs the SAME
|
|
403
|
+
// throttled newer-published check SessionStart runs (session-start-update-plane.mjs knowledgeAutoUpdate:
|
|
404
|
+
// per-machine throttle, shared O_EXCL lock, one detached updater). Unref'd (never keeps this process
|
|
405
|
+
// alive), skipped while a query is in flight or the worker is starting, skipped when the brain is off.
|
|
406
|
+
// The new knowledge reaches the worker through currentGeneration() above: the next call after the swap
|
|
407
|
+
// respawns it between requests. announce:false — the once-per-session UPDATED line is SessionStart's.
|
|
408
|
+
const CORPUS_CHECK_INTERVAL_MS = Number(process.env.RUVNET_CORPUS_CHECK_INTERVAL_MS) || 15 * 60_000;
|
|
409
|
+
const SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts');
|
|
410
|
+
const corpusTimer = setInterval(() => {
|
|
411
|
+
if (pendingCount > 0 || childStartup) return;
|
|
412
|
+
Promise.all([import('../scripts/session-start-update-plane.mjs'), import('../scripts/session-start-health.mjs')])
|
|
413
|
+
.then(([plane, health]) => {
|
|
414
|
+
const home = os.homedir();
|
|
415
|
+
if (health.brainState(process.env, home).off) return;
|
|
416
|
+
plane.knowledgeAutoUpdate({ env: process.env, home, now: Date.now(), hookDir: SCRIPTS_DIR, announce: false });
|
|
417
|
+
})
|
|
418
|
+
.catch((e) => console.error(`[ruvnet-brain] knowledge currency check skipped: ${e.message}`));
|
|
419
|
+
}, CORPUS_CHECK_INTERVAL_MS);
|
|
420
|
+
corpusTimer.unref();
|
|
421
|
+
|
|
396
422
|
const clientRl = readline.createInterface({ input: process.stdin });
|
|
397
423
|
clientRl.on('line', (line) => {
|
|
398
424
|
let msg; try { msg = JSON.parse(line); } catch { return; } // malformed line: ignore, never crash
|
|
@@ -15,6 +15,7 @@ import crypto from 'node:crypto';
|
|
|
15
15
|
import { cmpVersion, footprintRoots, physical } from './brain-footprint.mjs';
|
|
16
16
|
import { readAll as readReadiness } from './mcp-readiness.mjs';
|
|
17
17
|
import { volumeOf } from './brain-location.mjs';
|
|
18
|
+
import { AUTO_UPDATE_LOCK_STALE_MS, autoUpdatePaths, newerCorpusPending } from './session-start-health.mjs';
|
|
18
19
|
|
|
19
20
|
export const KNOWLEDGE_MAX_AGE_HOURS = 48;
|
|
20
21
|
export const SIGNATURE_RECORD = 'knowledge-signature.json';
|
|
@@ -84,7 +85,7 @@ const line = (id, label, state, detail, fix = null) => ({ id, label, state, deta
|
|
|
84
85
|
* @param installedVersion the package version the caller runs, used when no spine is active
|
|
85
86
|
*/
|
|
86
87
|
export function confirm({ footprint, env = process.env, home = os.homedir(), now = Date.now(), npmLatest = null,
|
|
87
|
-
installedVersion = null, readiness = null } = {}) {
|
|
88
|
+
installedVersion = null, readiness = null, coverageIntegrity = null } = {}) {
|
|
88
89
|
const roots = footprint?.roots || footprintRoots({ env, home });
|
|
89
90
|
const lines = [];
|
|
90
91
|
const active = readJson(path.join(roots.brainHome, 'active.json'));
|
|
@@ -121,21 +122,41 @@ export function confirm({ footprint, env = process.env, home = os.homedir(), now
|
|
|
121
122
|
}
|
|
122
123
|
const source = readJson(path.join(roots.kbDir, 'SOURCE.json'));
|
|
123
124
|
const builtMs = Date.parse(source?.builtUtc || '');
|
|
124
|
-
const
|
|
125
|
+
const signatureFile = path.join(roots.brainHome, SIGNATURE_RECORD);
|
|
126
|
+
const recorded = fs.existsSync(signatureFile);
|
|
127
|
+
const signature = recorded ? readJson(signatureFile) : null;
|
|
125
128
|
const coverage = sha256File(path.join(roots.kbDir, 'COVERAGE.json'));
|
|
126
129
|
const signed = Boolean(signature?.coverageSha256 && coverage && signature.coverageSha256 === coverage);
|
|
127
130
|
const tag = source?.corpusReleaseTag || source?.releaseTag || null;
|
|
128
|
-
// Structural problems (a second copy,
|
|
131
|
+
// Structural problems (a second copy, a record the live bytes contradict) gate; age is currency and only advises.
|
|
132
|
+
// NO record is provenance unknown, not provably broken (an install from a local sealed artifact and the automatic
|
|
133
|
+
// updater never write one): it advises. A record that is present but unreadable or does not match gates.
|
|
129
134
|
const knowledgeProblems = [];
|
|
130
135
|
const knowledgeAdvice = [];
|
|
131
136
|
if (footprint.kbCopies !== 1) knowledgeProblems.push([`${footprint.kbCopies} copies on disk (must be exactly 1)`, footprint.kbCopyFix || (footprint.kbCopies ? CLEAN : 'npx ruvnet-brain@latest')]);
|
|
132
|
-
if (!
|
|
137
|
+
if (!recorded) knowledgeAdvice.push([`installed or updated without a recorded signature verification — run ${UPDATE} to verify`, UPDATE]);
|
|
138
|
+
else if (!signed) knowledgeProblems.push([signature ? 'signature record does not match the live COVERAGE.json' : 'signature record is unreadable', UPDATE]);
|
|
139
|
+
// The record binds COVERAGE.json's bytes only. The doctor passes the release-coverage check (every store's bytes
|
|
140
|
+
// against the release projection, ~0.3 s on a 1.4 GB KB): a projection that fails it is ✗; a directory without one
|
|
141
|
+
// (an older corpus) cannot be checked, and the line then claims only what the record proves (4.5.2).
|
|
142
|
+
const integrity = signed && coverageIntegrity ? coverageIntegrity(roots.kbDir) : null;
|
|
143
|
+
const checkable = Boolean(integrity) && fs.existsSync(path.join(roots.kbDir, 'CORPUS-COVERAGE.json'));
|
|
144
|
+
if (checkable && !integrity.valid) knowledgeProblems.push([`the bytes on disk fail the release-coverage check (${integrity.failures?.[0] || 'unknown'})`, UPDATE]);
|
|
145
|
+
// A newer corpus is published and not yet installed (identity, not age): advisory, never a gate.
|
|
146
|
+
const pending = newerCorpusPending({ source, corpusCheck: readJson(autoUpdatePaths(roots.brainHome).checkFile) });
|
|
147
|
+
if (pending) {
|
|
148
|
+
const lockAt = readJson(autoUpdatePaths(roots.brainHome).lockFile)?.at;
|
|
149
|
+
const running = Number.isFinite(Date.parse(lockAt || '')) && now - Date.parse(lockAt) < AUTO_UPDATE_LOCK_STALE_MS;
|
|
150
|
+
const short = pending.tag.length > 28 ? `${pending.tag.slice(0, 26)}…` : pending.tag;
|
|
151
|
+
knowledgeAdvice.push([`newer corpus ${short} published (checked ${iso(Date.parse(pending.checkedAt || ''))}); automatic update ${running ? 'running now' : 'pending'}`, UPDATE]);
|
|
152
|
+
}
|
|
133
153
|
if (!Number.isFinite(builtMs) || (now - builtMs) / 3_600_000 >= KNOWLEDGE_MAX_AGE_HOURS) knowledgeAdvice.push([`built ${Number.isFinite(builtMs) ? ago(builtMs, now) : 'at an unknown time'} (limit ${KNOWLEDGE_MAX_AGE_HOURS}h)`, UPDATE]);
|
|
134
154
|
const where = roots.location?.state === 'linked'
|
|
135
155
|
? `at ${roots.kbDir} (moved to ${volumeOf(roots.location.real)}, mounted)` : `at ${roots.kbDir}`;
|
|
136
156
|
const moved = footprint.moveLeftovers || { copies: 0, bytes: 0 };
|
|
137
157
|
const knowledgeDetail = [`${footprint.kbCopies} copy ${where}${moved.copies ? ` (not counted: ${moved.copies} interrupted-move cop${moved.copies === 1 ? 'y' : 'ies'} (${formatBytes(moved.bytes)}) — see the Move lines)` : ''}`, `built ${iso(builtMs)}${Number.isFinite(builtMs) ? ` (${ago(builtMs, now)})` : ''}`,
|
|
138
|
-
signed ?
|
|
158
|
+
!signed ? 'signature NOT verified' : integrity && !checkable ? `install record matches COVERAGE.json ${iso(Date.parse(signature.verifiedAt))}`
|
|
159
|
+
: `signature verified ${iso(Date.parse(signature.verifiedAt))}`, `corpus ${tag ? (tag.length > 28 ? `${tag.slice(0, 26)}…` : tag) : 'unknown'}`].join(' · ');
|
|
139
160
|
const knowledgeIssues = [...knowledgeProblems, ...knowledgeAdvice];
|
|
140
161
|
lines.push(line('knowledge', 'Knowledge', knowledgeProblems.length ? 'fail' : knowledgeAdvice.length ? 'warn' : 'ok',
|
|
141
162
|
knowledgeIssues.length ? `${knowledgeDetail} — ${knowledgeIssues.map(([p]) => p).join('; ')}` : knowledgeDetail, knowledgeIssues[0]?.[1]));
|
|
@@ -52,7 +52,8 @@ export const kbCopyPrefixes = (base) => [
|
|
|
52
52
|
`${base}-pre-gap-rebuild-backup-`, `${base}.install-preserved-`, `${base}.install-prior-`,
|
|
53
53
|
`${base}.pre-update-`, `${base}.next-`, `${base}.rollback-`, `${base}.failed-`,
|
|
54
54
|
];
|
|
55
|
-
const
|
|
55
|
+
const BRAIN_BACKUP_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:-\d{3})?Z$/; // the updater's stamp(); other kb.bak-* are the customer's
|
|
56
|
+
const TRANSACTION_KINDS ={ next: 'candidate', rollback: 'rollback', failed: 'failed' };
|
|
56
57
|
export const LOG_FILES = Object.freeze(['evidence.jsonl', 'token-ledger.jsonl', 'detached-jobs.jsonl',
|
|
57
58
|
'update-receipts.jsonl', 'gate-blocks.jsonl', 'distill-receipts.jsonl', 'assertion-gate-shadow.jsonl',
|
|
58
59
|
'capability-live-evidence.jsonl', 'design-grades.jsonl', 'grounding-overrides.jsonl']);
|
|
@@ -184,6 +185,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
184
185
|
const classifySibling = (name, full, st) => {
|
|
185
186
|
{
|
|
186
187
|
const prefix = kbCopyPrefixes(base).find((p) => name.startsWith(p));
|
|
188
|
+
if (prefix === `${base}.bak-` && !BRAIN_BACKUP_STAMP.test(name.slice(prefix.length))) {
|
|
189
|
+
return void add({ id: 'foreign', path: full, class: 'unowned', kind: 'hand-made-backup', action: 'report', bytes: bytes(full),
|
|
190
|
+
reason: 'a hand-made KB backup (not the Brain\'s own kb.bak-<stamp> name): reported, never deleted' });
|
|
191
|
+
}
|
|
187
192
|
if (prefix) {
|
|
188
193
|
if (st.isSymbolicLink() || !st.isDirectory()) { add({ id: 'kb-copy', path: full, class: 'unowned', kind: 'kb-copy-link', action: 'report', reason: 'a link or file under a KB-copy name; never followed or removed' }); return; }
|
|
189
194
|
const kind = Object.entries(TRANSACTION_KINDS).find(([k]) => prefix === `${base}.${k}-`)?.[1] || null;
|
|
@@ -392,10 +397,10 @@ function summarize({ roots, items, policy, kbCopyDirs, lockHeld }) {
|
|
|
392
397
|
breakdown[group] = (breakdown[group] || 0) + (i.bytes || 0);
|
|
393
398
|
}
|
|
394
399
|
const cruft = items.filter((i) => i.class === 'must-not-exist');
|
|
395
|
-
//
|
|
396
|
-
const extra = items.filter((i) => i.kind === 'kb-copy' || i.kind === 'quarantine');
|
|
397
|
-
const
|
|
398
|
-
|
|
400
|
+
// Every extra copy unremovable → its own fix, not --clean; all owned by an update transaction → --update (4.5.2).
|
|
401
|
+
const extra = items.filter((i) => i.kind === 'kb-copy' || i.kind === 'quarantine'), onlyCopy = items.find((i) => i.kind === 'move-leftover' && i.onlyCopy);
|
|
402
|
+
const inTransaction = items.some((i) => /^transaction-/.test(i.kind)), kbCopyFix = onlyCopy ? onlyCopy.fix : extra.length && extra.every((i) => i.keptUnique) ? extra[0].fix
|
|
403
|
+
: !extra.length && inTransaction ? 'npx ruvnet-brain@latest --update' : null;
|
|
399
404
|
const moved = items.filter((i) => i.kind === 'move-leftover');
|
|
400
405
|
return { schemaVersion: 1, kind: 'ruvnet-brain-footprint', roots, items, policy, lockHeld, kbCopyFix,
|
|
401
406
|
liveKb: items.find((i) => i.kind === 'live-kb'),
|
|
@@ -77,8 +77,8 @@ export function findMoveLeftovers({ brainHome, location = null, isAlive = pidAli
|
|
|
77
77
|
// has no control character (or, on Windows, no double quote); otherwise the advice is to inspect it by hand.
|
|
78
78
|
const shQuote = (p) => `'${String(p).replace(/'/g, `'\\''`)}'`;
|
|
79
79
|
const COMMANDS = {
|
|
80
|
-
posix: { rmTree: (p) => `rm -rf -- ${shQuote(p)}`, rmLink: (p) => `rm -- ${shQuote(p)}`, mv: (a, b) => `mv -- ${shQuote(a)} ${shQuote(b)}` },
|
|
81
|
-
win32: { rmTree: (p) => `rmdir /s /q "${p}"`, rmLink: (p) => `rmdir "${p}"`, mv: (a, b) => `move "${a}" "${b}"` },
|
|
80
|
+
posix: { rmTree: (p) => `rm -rf -- ${shQuote(p)}`, rmLink: (p) => `rm -- ${shQuote(p)}`, mv: (a, b) => `mv -- ${shQuote(a)} ${shQuote(b)}`, rmdir: (p) => `rmdir -- ${shQuote(p)}` },
|
|
81
|
+
win32: { rmTree: (p) => `rmdir /s /q "${p}"`, rmLink: (p) => `rmdir "${p}"`, mv: (a, b) => `move "${a}" "${b}"`, rmdir: (p) => `rmdir "${p}"` },
|
|
82
82
|
};
|
|
83
83
|
const holdsBrain = (dir) => ['SOURCE.json', path.join('kb', 'SOURCE.json')].some((f) => Boolean(lstat(path.join(dir, f))));
|
|
84
84
|
|
|
@@ -93,7 +93,11 @@ export function assessMoveLeftovers({ brainHome, location = null, isAlive = pidA
|
|
|
93
93
|
const safe = (p, dir) => physical(path.dirname(p)) === physical(dir) && !/[\u0000-\u001f\u007f]/.test(p)
|
|
94
94
|
&& !(platform === 'win32' && p.includes('"')) && !/[\u0000-\u001f\u007f]/.test(brainHome);
|
|
95
95
|
const inspect = (p) => `inspect it by hand: ${JSON.stringify(p)} (no command is suggested for this path)`;
|
|
96
|
-
|
|
96
|
+
// A real directory with no Brain in it counts as missing: a hook or the search server recreates the home on a
|
|
97
|
+
// failure path (health.json, a notice file) after an interrupted move (4.5.2). A link is judged by `location`.
|
|
98
|
+
const homeSt = lstat(brainHome);
|
|
99
|
+
const homeMissing = !homeSt || (!homeSt.isSymbolicLink() && !holdsBrain(brainHome));
|
|
100
|
+
const homeEmpty = Boolean(homeSt) && homeMissing && !names(brainHome).length;
|
|
97
101
|
const found = findMoveLeftovers({ brainHome, location, isAlive }).map((lo) => {
|
|
98
102
|
const link = Boolean(lstat(lo.path)?.isSymbolicLink());
|
|
99
103
|
let target = null;
|
|
@@ -104,7 +108,10 @@ export function assessMoveLeftovers({ brainHome, location = null, isAlive = pidA
|
|
|
104
108
|
const restore = homeMissing ? found.filter((f) => f.brain).sort((a, b) => order.indexOf(a.what) - order.indexOf(b.what))[0] : null;
|
|
105
109
|
return found.map((f) => {
|
|
106
110
|
const ok = safe(f.path, f.dir);
|
|
107
|
-
|
|
111
|
+
// The recreated home is cleared out of the way first: removed if empty (rmdir refuses anything else), set aside
|
|
112
|
+
// under a name of its own otherwise — never deleted with its contents, and the leftover is never deleted.
|
|
113
|
+
const clear = !homeSt ? null : homeEmpty ? cmd.rmdir(brainHome) : cmd.mv(brainHome, `${brainHome}.recreated-${f.pid}`);
|
|
114
|
+
if (f === restore) return { ...f, onlyCopy: true, fix: ok ? [clear, cmd.mv(f.path, brainHome)].filter(Boolean).join(' && ') : inspect(f.path) };
|
|
108
115
|
if (restore) return { ...f, onlyCopy: false, fix: `keep it until the Brain is restored at ${JSON.stringify(brainHome)} and --doctor is green` };
|
|
109
116
|
return { ...f, onlyCopy: false, fix: ok ? (f.link ? cmd.rmLink(f.path) : cmd.rmTree(f.path)) : inspect(f.path) };
|
|
110
117
|
});
|
|
@@ -37,7 +37,8 @@ if (process.argv.includes('--check')) {
|
|
|
37
37
|
|
|
38
38
|
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
39
39
|
|
|
40
|
-
// --knowledge <attemptFile> <lockFile
|
|
40
|
+
// --knowledge <attemptFile> <lockFile> [--if-newer <kbDir> <checkFile> <resultFile>]: the SessionStart
|
|
41
|
+
// (and MCP server timer) knowledge self-heal worker (launched by
|
|
41
42
|
// session-start-update-plane.mjs under detach.mjs's TTL). Unlike the default mode below it DOES
|
|
42
43
|
// update knowledge: the same argv bin/nightly-refresh.mjs runs, through the same env boundary.
|
|
43
44
|
// Offline (registry unreachable) is recorded as 'offline' so the next session retries in 30 min
|
|
@@ -56,6 +57,61 @@ if (knowledgeAt >= 0) {
|
|
|
56
57
|
} catch { /* the next session reports a launch with no outcome as a failure */ }
|
|
57
58
|
};
|
|
58
59
|
try {
|
|
60
|
+
// --if-newer <kbDir> <checkFile> <resultFile>: the newer-published identity check (2026-10-02). Run the
|
|
61
|
+
// INSTALLED updater's --check (one GET of the canonical releases/latest pointer, no download) and only a
|
|
62
|
+
// newer identity proceeds to the update below. Exit codes are forge-update.mjs's own: 0 current (or
|
|
63
|
+
// REFUSED: the published corpus is older — never downgrade), 10 newer, 2 network, 5 incompatible.
|
|
64
|
+
const ifNewerAt = process.argv.indexOf('--if-newer');
|
|
65
|
+
if (ifNewerAt >= 0) {
|
|
66
|
+
const path = await import('node:path');
|
|
67
|
+
const [kbDir, checkFile, resultFile] = process.argv.slice(ifNewerAt + 1);
|
|
68
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
69
|
+
const note = (fields) => {
|
|
70
|
+
try {
|
|
71
|
+
const tmp = `${checkFile}.tmp-${process.pid}`;
|
|
72
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ ...(readJson(checkFile) || {}), schemaVersion: 1, ...fields, checkedAt: new Date().toISOString() })}\n`);
|
|
73
|
+
fs.renameSync(tmp, checkFile);
|
|
74
|
+
} catch { /* the next tick re-checks */ }
|
|
75
|
+
};
|
|
76
|
+
// process.exit() skips finally{}, so every early stop releases the shared lock itself.
|
|
77
|
+
const stop = () => { try { fs.rmSync(lockFile, { force: true }); } catch { /* stale-lock rule reclaims it */ } process.exit(0); };
|
|
78
|
+
const started = Date.now();
|
|
79
|
+
const check = spawnSync(process.execPath, [path.join(kbDir, 'forge-update.mjs'), '--check', '--result-file', resultFile], {
|
|
80
|
+
env: childEnvironment(), encoding: 'utf8', timeout: Number(process.env.RUVNET_CORPUS_CHECK_TIMEOUT_MS || 90_000),
|
|
81
|
+
});
|
|
82
|
+
const out = `${check.stdout || ''}\n${check.stderr || ''}`;
|
|
83
|
+
const result = readJson(resultFile);
|
|
84
|
+
const fresh = result && Date.parse(result.recordedAt || '') >= started - 1000 ? result : null;
|
|
85
|
+
const candidateTag = fresh?.candidateTag || /canonical built:\s+(\S+)/.exec(out)?.[1] || null;
|
|
86
|
+
const verdict = fresh?.currencyVerdict || /currency verdict:\s+([A-Z_]+)/.exec(out)?.[1] || null;
|
|
87
|
+
const reason = (out.split('\n').map((l) => l.trim()).filter(Boolean).find((l) => /error|refus|incompatible|fail/i.test(l)) || '').slice(0, 200);
|
|
88
|
+
// Decide by the recorded IDENTITY verdict whenever this run wrote one; the exit code only when it did
|
|
89
|
+
// not (a network failure or an incompatible release dies before recording). Measured against the real
|
|
90
|
+
// API: an install whose profile selects no stores exits 0 on UPDATE_AVAILABLE ("All stores current").
|
|
91
|
+
const newer = verdict ? ['UPDATE_AVAILABLE', 'UNKNOWN'].includes(verdict) : check.status === 10;
|
|
92
|
+
if (!newer && (verdict || check.status === 0)) {
|
|
93
|
+
note({ outcome: verdict === 'REFUSED' ? 'refused' : 'current', verdict: verdict || 'CURRENT', candidateTag, reason: '' });
|
|
94
|
+
stop();
|
|
95
|
+
}
|
|
96
|
+
if (!newer) {
|
|
97
|
+
note({ outcome: check.status === 2 ? 'offline' : check.status === 5 ? 'incompatible' : 'failed',
|
|
98
|
+
verdict: null, candidateTag, reason: reason || `check exited ${check.error ? check.error.message : check.status}` });
|
|
99
|
+
stop();
|
|
100
|
+
}
|
|
101
|
+
// Newer. Loop guard: the same target already "succeeded" within 6h yet is still not installed — do
|
|
102
|
+
// not download it again every hour; a DIFFERENT newer corpus proceeds at once.
|
|
103
|
+
const prior = readJson(attemptFile);
|
|
104
|
+
if (prior?.outcome === 'succeeded' && candidateTag && prior.targetTag === candidateTag
|
|
105
|
+
&& Date.now() - Date.parse(prior.launchedAt || '') < 6 * 3_600_000) {
|
|
106
|
+
note({ outcome: 'not-converged', verdict, candidateTag, reason: 'the last automatic update to this corpus finished without installing it' });
|
|
107
|
+
stop();
|
|
108
|
+
}
|
|
109
|
+
note({ outcome: 'updating', verdict, candidateTag, reason: '' });
|
|
110
|
+
const tmp = `${attemptFile}.tmp-${process.pid}`;
|
|
111
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ schemaVersion: 1, launchedAt: new Date().toISOString(), outcome: 'launched',
|
|
112
|
+
trigger: 'newer-corpus-published', targetTag: candidateTag })}\n`);
|
|
113
|
+
fs.renameSync(tmp, attemptFile);
|
|
114
|
+
}
|
|
59
115
|
const probe = process.env.RUVNET_AUTO_UPDATE_PROBE_URL || 'https://registry.npmjs.org/ruvnet-brain/latest';
|
|
60
116
|
let online = false;
|
|
61
117
|
try { online = (await fetch(probe, { signal: AbortSignal.timeout(5_000) })).ok; } catch { /* offline */ }
|
|
@@ -12,6 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { json, exists, mtimeMs, read } from './session-start-fsutil.mjs';
|
|
14
14
|
import { unmountedNotice } from './brain-location.mjs';
|
|
15
|
+
import { assessMoveLeftovers } from './footprint-io.mjs';
|
|
15
16
|
import {
|
|
16
17
|
describeFailedRefreshRun, readNightlyRegistration, refreshHistory, updateOwnedByAgenticKit,
|
|
17
18
|
} from './nightly-scheduler.mjs';
|
|
@@ -33,6 +34,15 @@ export const brainState = (env, home) => {
|
|
|
33
34
|
return { off, since, stateDir, file };
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
// An interrupted --move-brain may hold the ONLY Brain at <home>.old-<pid>: "reinstall" there would build a
|
|
38
|
+
// second, public-only Brain beside it (4.5.2). Names only; consulted only when the KB is missing or empty.
|
|
39
|
+
const restoreNotice = (home) => {
|
|
40
|
+
try {
|
|
41
|
+
const lo = assessMoveLeftovers({ brainHome: path.join(home, '.cache', 'ruvnet-brain') }).find((l) => l.onlyCopy);
|
|
42
|
+
return lo ? `the Brain is not at its path — an interrupted move left the ONLY copy at ${lo.path}; restore it, do NOT reinstall: ${lo.fix}` : '';
|
|
43
|
+
} catch { return ''; }
|
|
44
|
+
};
|
|
45
|
+
|
|
36
46
|
export const health = (home, off) => {
|
|
37
47
|
// Moved to another disk that is not plugged in: say exactly that — not "MISSING, reinstall", which
|
|
38
48
|
// would re-create a fresh brain in ~/.cache over the link. A problem also stands the self-heal down.
|
|
@@ -45,8 +55,8 @@ export const health = (home, off) => {
|
|
|
45
55
|
catch { /* absent */ }
|
|
46
56
|
const absentByChoice = off && (!exists(kb) || !rvf);
|
|
47
57
|
if (absentByChoice) return { problem: '', absentByChoice };
|
|
48
|
-
if (!exists(kb)) return { problem: `the brain cache directory is MISSING (${kb}) — reinstall: npx github:stuinfla/ruvnet-brain`, absentByChoice };
|
|
49
|
-
if (!rvf) return { problem: `NO vector stores (.rvf) found in ${kb} — the brain is empty; reinstall: npx github:stuinfla/ruvnet-brain --force`, absentByChoice };
|
|
58
|
+
if (!exists(kb)) return { problem: restoreNotice(home) || `the brain cache directory is MISSING (${kb}) — reinstall: npx github:stuinfla/ruvnet-brain`, absentByChoice };
|
|
59
|
+
if (!rvf) return { problem: restoreNotice(home) || `NO vector stores (.rvf) found in ${kb} — the brain is empty; reinstall: npx github:stuinfla/ruvnet-brain --force`, absentByChoice };
|
|
50
60
|
if (!exists(path.join(kb, 'node_modules', '@xenova', 'transformers', 'package.json'))) {
|
|
51
61
|
return { problem: `reader dependencies are MISSING (node_modules gone) — every search WILL fail. Fix: cd ${kb} && npm i`, absentByChoice };
|
|
52
62
|
}
|
|
@@ -78,6 +88,10 @@ export const autoUpdatePaths = (brainHome) => ({
|
|
|
78
88
|
attemptFile: path.join(brainHome, 'auto-update.json'),
|
|
79
89
|
lockFile: path.join(brainHome, 'auto-update.lock'),
|
|
80
90
|
logFile: path.join(brainHome, '.last-auto-update-knowledge.log'),
|
|
91
|
+
// The newer-published identity check (2026-10-02): its own throttle + outcome record, never the update
|
|
92
|
+
// attempt file (a check is not an update; it must not reset the 6h retry or read as an abandoned launch).
|
|
93
|
+
checkFile: path.join(brainHome, 'corpus-check.json'),
|
|
94
|
+
checkResultFile: path.join(brainHome, '.last-kb-check-result.json'),
|
|
81
95
|
});
|
|
82
96
|
export const AUTO_UPDATE_LOCK_STALE_MS = 35 * 60_000; // detach TTL (30 min) + slack
|
|
83
97
|
|
|
@@ -95,7 +109,7 @@ export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {
|
|
|
95
109
|
const provenWithin = (h) => Boolean((history.lastSuccess && hours(history.lastSuccess.at) <= h)
|
|
96
110
|
|| (check?.currencyVerdict === 'CURRENT' && Number.isFinite(checkMs) && hours(checkMs) <= h));
|
|
97
111
|
return { brainHome, kbDir, source, builtMs, history, hours, provenWithin, auto,
|
|
98
|
-
attempt: json(auto.attemptFile),
|
|
112
|
+
attempt: json(auto.attemptFile), corpusCheck: json(auto.checkFile),
|
|
99
113
|
lockMs: Date.parse(json(auto.lockFile)?.at || '') || mtimeMs(auto.lockFile) };
|
|
100
114
|
};
|
|
101
115
|
|
|
@@ -126,6 +140,21 @@ export const autoUpdateOptOut = ({ env = process.env, home, facts }) => {
|
|
|
126
140
|
return '';
|
|
127
141
|
};
|
|
128
142
|
|
|
143
|
+
/**
|
|
144
|
+
* A newer corpus is PUBLISHED and not yet installed — decided by identity, never by age: the last
|
|
145
|
+
* recorded --if-newer check found a release whose tag is newer (UPDATE_AVAILABLE, or UNKNOWN when the
|
|
146
|
+
* installed tree carries no generation stamp) and that tag is not what SOURCE.json now names. A check
|
|
147
|
+
* recorded before the update landed therefore stops reading as pending the moment B is installed.
|
|
148
|
+
*/
|
|
149
|
+
export const newerCorpusPending = (facts) => {
|
|
150
|
+
const c = facts.corpusCheck;
|
|
151
|
+
if (!c?.candidateTag || !['UPDATE_AVAILABLE', 'UNKNOWN'].includes(c.verdict)) return null;
|
|
152
|
+
const installed = [facts.source?.corpusReleaseTag, facts.source?.releaseTag].filter(Boolean);
|
|
153
|
+
if (installed.includes(c.candidateTag)) return null;
|
|
154
|
+
return { tag: c.candidateTag, installed: installed[0] || null, checkedAt: c.checkedAt || c.launchedAt || null, outcome: c.outcome };
|
|
155
|
+
};
|
|
156
|
+
const shortTag = (tag) => (tag && tag.length > 28 ? `${tag.slice(0, 26)}…` : tag || 'unknown');
|
|
157
|
+
|
|
129
158
|
export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), windowHours = 48 } = {}) => {
|
|
130
159
|
const facts = knowledgeFacts({ env, home, now });
|
|
131
160
|
const { brainHome, builtMs, history, hours, attempt } = facts;
|
|
@@ -142,6 +171,16 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
|
|
|
142
171
|
&& !(history.latest && history.latest.at >= launchedMs);
|
|
143
172
|
const failing = latest?.status === 'FAILED' || autoFailed;
|
|
144
173
|
const ageKnown = Number.isFinite(builtMs);
|
|
174
|
+
const pending = newerCorpusPending(facts);
|
|
175
|
+
if (!failing && pending && (proven || (ageKnown && hours(builtMs) <= windowHours))) {
|
|
176
|
+
const optOut = autoUpdateOptOut({ env, home, facts });
|
|
177
|
+
const what = autoRunning ? 'the automatic update is installing it now'
|
|
178
|
+
: optOut ? `automatic update is off (${optOut}). Fix: npx ruvnet-brain@latest --update`
|
|
179
|
+
: pending.outcome === 'not-converged' ? 'an automatic update to it finished without installing it; it retries within 6h'
|
|
180
|
+
: 'the automatic update installs it in the background';
|
|
181
|
+
return `${KNOWLEDGE_LINE_PREFIX}UPDATE PENDING] a newer corpus ${shortTag(pending.tag)} is published `
|
|
182
|
+
+ `(this machine has ${shortTag(pending.installed)}, built ${ageKnown ? age(builtMs) : 'at an UNKNOWN time'}); ${what}.`;
|
|
183
|
+
}
|
|
145
184
|
if (!failing && proven) return '';
|
|
146
185
|
if (!failing && ageKnown && hours(builtMs) <= windowHours) return '';
|
|
147
186
|
const kit = agenticKitUpdates({ home, facts });
|
|
@@ -13,16 +13,30 @@ import { inventoryFootprint } from './brain-footprint.mjs';
|
|
|
13
13
|
import { confirm, footprintAlarm } from './brain-confirmation.mjs';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
16
|
+
* KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old) AND
|
|
17
|
+
* NEWER-PUBLISHED CHECK (owner requirement 2026-10-02: "all accounts auto update anytime a new corpus of
|
|
18
|
+
* knowledge happens"). Two triggers, ONE updater:
|
|
19
|
+
* • 'update' — nothing proves the knowledge current inside 24h: launch the detached, bounded
|
|
20
|
+
* `npx ruvnet-brain@latest --update --no-nightly-prompt` (the exact argv bin/nightly-refresh.mjs runs)
|
|
21
|
+
* through host-update.mjs's credential boundary and detach.mjs's TTL supervisor. At most one launch
|
|
22
|
+
* per 6h (30 min after an offline probe).
|
|
23
|
+
* • 'check' — the knowledge is fresh BY AGE, but age never says whether something newer was published
|
|
24
|
+
* (measured: v4.4.1 stayed live ~13h after v4.5.0 shipped). At most once per checkMinutes per machine,
|
|
25
|
+
* the same detached worker first runs the installed `kb/forge-update.mjs --check` (one GET of the
|
|
26
|
+
* canonical releases/latest pointer, no download) and proceeds to the SAME update only on a newer
|
|
27
|
+
* identity — forge-update's currencyVerdict(): UPDATE_AVAILABLE/UNKNOWN; CURRENT and REFUSED (the
|
|
28
|
+
* published corpus is older: never downgrade) stop there.
|
|
29
|
+
* Per machine (all state under brainHome), never while a refresh lock or another launch is live, never
|
|
30
|
+
* blocks (spawn + unref, no wait). SessionStart calls this; so does the long-lived MCP server on a timer
|
|
31
|
+
* (plugin/mcp/server.mjs, announce:false so it never consumes the once-per-session line). The outcome is
|
|
32
|
+
* recorded by the worker and spoken by the NEXT session's knowledge line.
|
|
24
33
|
*/
|
|
25
|
-
export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 1800 });
|
|
34
|
+
export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 1800, checkMinutes: 60 });
|
|
35
|
+
export const corpusCheckMinutes = (env = {}) => {
|
|
36
|
+
const raw = env.RUVNET_CORPUS_CHECK_MINUTES;
|
|
37
|
+
const n = Number(raw);
|
|
38
|
+
return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 ? n : AUTO_UPDATE_POLICY.checkMinutes;
|
|
39
|
+
};
|
|
26
40
|
|
|
27
41
|
const writeJsonAtomic = (file, value) => {
|
|
28
42
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
@@ -30,14 +44,17 @@ const writeJsonAtomic = (file, value) => {
|
|
|
30
44
|
catch { try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } return false; }
|
|
31
45
|
};
|
|
32
46
|
|
|
33
|
-
export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {}, spawnFn = spawn }) => {
|
|
47
|
+
export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {}, spawnFn = spawn, announce = true }) => {
|
|
34
48
|
const facts = knowledgeFacts({ env, home, now });
|
|
35
|
-
const { attemptFile, lockFile, logFile } = facts.auto;
|
|
49
|
+
const { attemptFile, lockFile, logFile, checkFile, checkResultFile } = facts.auto;
|
|
36
50
|
const attempt = facts.attempt;
|
|
37
51
|
const hoursAgo = (iso) => (now - Date.parse(iso || '')) / 3_600_000;
|
|
38
|
-
if (attempt?.outcome === 'succeeded' && !attempt.reported) {
|
|
52
|
+
if (announce && attempt?.outcome === 'succeeded' && !attempt.reported) {
|
|
39
53
|
const built = facts.builtMs;
|
|
40
|
-
|
|
54
|
+
// A corpus-only release carries its identity in corpusReleaseTag; releaseTag is the code release beside it.
|
|
55
|
+
const id = facts.source?.corpusReleaseTag || facts.source?.releaseTag;
|
|
56
|
+
const short = id && id.length > 28 ? `${id.slice(0, 26)}…` : id;
|
|
57
|
+
const tag = !id ? 'the latest corpus' : id.startsWith('corpus-') ? short : `corpus ${short}`;
|
|
41
58
|
emit(`${KNOWLEDGE_LINE_PREFIX}UPDATED] automatic update finished ${Math.round(hoursAgo(attempt.finishedAt))}h ago: ${tag}, `
|
|
42
59
|
+ `knowledge base built ${Number.isFinite(built) ? `${Math.round(facts.hours(built))}h ago` : 'at an UNKNOWN time'}.`);
|
|
43
60
|
writeJsonAtomic(attemptFile, { ...attempt, reported: true });
|
|
@@ -46,7 +63,14 @@ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {},
|
|
|
46
63
|
if (optOut) return { launched: false, why: optOut };
|
|
47
64
|
const fresh = facts.provenWithin(AUTO_UPDATE_POLICY.staleHours)
|
|
48
65
|
|| (Number.isFinite(facts.builtMs) && facts.hours(facts.builtMs) <= AUTO_UPDATE_POLICY.staleHours);
|
|
49
|
-
|
|
66
|
+
const mode = fresh ? 'check' : 'update';
|
|
67
|
+
if (mode === 'check') {
|
|
68
|
+
const lastCheck = Date.parse(facts.corpusCheck?.launchedAt || '');
|
|
69
|
+
const sinceCheck = now - lastCheck;
|
|
70
|
+
if (Number.isFinite(lastCheck) && sinceCheck >= 0 && sinceCheck < corpusCheckMinutes(env) * 60_000) {
|
|
71
|
+
return { launched: false, why: 'fresh' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
50
74
|
// A nightly or manual --update holds this lock (kb/refresh-run.mjs refreshLockPath, duplicated here
|
|
51
75
|
// because the plugin payload cannot import kb/); --update would refuse anyway, this saves the launch.
|
|
52
76
|
if (exists(path.join(path.dirname(facts.kbDir), `.${path.basename(facts.kbDir)}.refresh-run.lock`))) {
|
|
@@ -54,8 +78,11 @@ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {},
|
|
|
54
78
|
}
|
|
55
79
|
const since = hoursAgo(attempt?.launchedAt);
|
|
56
80
|
const retryHours = attempt?.outcome === 'offline' ? AUTO_UPDATE_POLICY.offlineRetryMinutes / 60 : AUTO_UPDATE_POLICY.retryHours;
|
|
57
|
-
|
|
58
|
-
//
|
|
81
|
+
// A SUCCEEDED update never delays the next newer-published check: a different, newer corpus may proceed
|
|
82
|
+
// at once. The same target is not re-run within 6h (host-update.mjs --if-newer's 'not-converged' guard).
|
|
83
|
+
const retryBlocks = !(mode === 'check' && attempt?.outcome === 'succeeded');
|
|
84
|
+
if (retryBlocks && since >= 0 && since < retryHours) return { launched: false, why: 'throttled' };
|
|
85
|
+
// Cross-process: exactly one session (or MCP server timer) wins the O_EXCL create; a stale lock is reclaimed.
|
|
59
86
|
const claim = () => { try { fs.writeFileSync(lockFile, `${JSON.stringify({ pid: process.pid, at: new Date(now).toISOString() })}\n`, { flag: 'wx' }); return true; } catch { return false; } };
|
|
60
87
|
if (!claim()) {
|
|
61
88
|
const lockMs = Date.parse(json(lockFile)?.at || '') || mtimeMs(lockFile);
|
|
@@ -63,20 +90,26 @@ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {},
|
|
|
63
90
|
try { fs.rmSync(lockFile, { force: true }); } catch { /* raced */ }
|
|
64
91
|
if (!claim()) return { launched: false, why: 'locked' };
|
|
65
92
|
}
|
|
66
|
-
|
|
93
|
+
const at = new Date(now).toISOString();
|
|
94
|
+
// A check is not an update attempt: it must not reset the 6h retry or read as an abandoned launch.
|
|
95
|
+
if (mode === 'update') writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: at, outcome: 'launched' });
|
|
96
|
+
else writeJsonAtomic(checkFile, { ...(facts.corpusCheck || {}), schemaVersion: 1, launchedAt: at, outcome: 'checking' });
|
|
67
97
|
try {
|
|
68
98
|
const child = spawnFn(process.execPath, [path.join(hookDir, 'detach.mjs'), String(AUTO_UPDATE_POLICY.ttlSec), logFile,
|
|
69
|
-
process.execPath, path.join(hookDir, 'host-update.mjs'), '--knowledge', attemptFile, lockFile
|
|
99
|
+
process.execPath, path.join(hookDir, 'host-update.mjs'), '--knowledge', attemptFile, lockFile,
|
|
100
|
+
...(mode === 'check' ? ['--if-newer', facts.kbDir, checkFile, checkResultFile] : [])],
|
|
70
101
|
{ detached: true, stdio: 'ignore', env, windowsHide: true });
|
|
71
102
|
child.on?.('error', () => {});
|
|
72
103
|
child.unref?.();
|
|
73
104
|
} catch (error) {
|
|
74
|
-
|
|
75
|
-
|
|
105
|
+
const reason = `could not launch: ${error.message}`;
|
|
106
|
+
if (mode === 'update') {
|
|
107
|
+
writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: at, outcome: 'failed', code: null, reason, finishedAt: at });
|
|
108
|
+
} else writeJsonAtomic(checkFile, { schemaVersion: 1, launchedAt: at, outcome: 'failed', reason, checkedAt: at });
|
|
76
109
|
try { fs.rmSync(lockFile, { force: true }); } catch { /* ignore */ }
|
|
77
|
-
return { launched: false, why: 'launch failed' };
|
|
110
|
+
return { launched: false, why: 'launch failed', mode };
|
|
78
111
|
}
|
|
79
|
-
return { launched: true, why: 'stale' };
|
|
112
|
+
return { launched: true, why: mode === 'check' ? 'checking for a newer published corpus' : 'stale', mode };
|
|
80
113
|
};
|
|
81
114
|
|
|
82
115
|
/**
|
|
@@ -179,7 +212,9 @@ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatche
|
|
|
179
212
|
const kbDir = path.join(home, '.cache', 'ruvnet-brain', 'kb');
|
|
180
213
|
if (pref === 'yes' && exists(path.join(kbDir, 'forge-update.mjs'))) {
|
|
181
214
|
const kbLog = path.join(stateDir, '.last-kb-check.log');
|
|
182
|
-
|
|
215
|
+
// Only when the automatic knowledge update is OFF on this machine: otherwise it IS applied (signature
|
|
216
|
+
// verified first) and the knowledge line says so — this notice would be a false statement.
|
|
217
|
+
if (/\bBEHIND\b/.test(read(kbLog)) && autoUpdateOptOut({ env, home, facts: knowledgeFacts({ env, home, now }) })) {
|
|
183
218
|
emit('[RuvNet Brain — a newer knowledge bundle is available. It is signed (Ed25519) and the updater verifies that signature before extracting anything. We do NOT auto-apply it: applying replaces executable tool files, which is your call. To update: npx ruvnet-brain@latest --update]');
|
|
184
219
|
}
|
|
185
220
|
// S2 (ONE CURRENCY VERDICT): --result-file records the SAME structured verdict --check/--apply
|