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 CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.0 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.0-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.2 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.2-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
package/bin/install.mjs CHANGED
@@ -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
- const result = confirm({ footprint: fp, npmLatest: await npmLatestVersion(), installedVersion: PACKAGE_VERSION, now });
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
 
@@ -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.0",
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.0",
4
+ "version": "4.5.2",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.0",
3
+ "version": "4.5.2",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -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
- return `${a?.generation ?? 0}:${brainMtime}`;
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 signature = readJson(path.join(roots.brainHome, SIGNATURE_RECORD));
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, unverified bytes) gate; age is currency and only advises.
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 (!signed) knowledgeProblems.push([signature ? 'signature record does not match the live COVERAGE.json' : 'no signature verification recorded for these bytes', UPDATE]);
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 ? `signature verified ${iso(Date.parse(signature.verifiedAt))}` : 'signature NOT verified', `corpus ${tag ? (tag.length > 28 ? `${tag.slice(0, 26)}…` : tag) : 'unknown'}`].join(' · ');
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 TRANSACTION_KINDS = { next: 'candidate', rollback: 'rollback', failed: 'failed' };
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
- // When every extra KB copy is one no command can remove, the copy-count fix is the honest remedy, not --clean.
396
- const extra = items.filter((i) => i.kind === 'kb-copy' || i.kind === 'quarantine');
397
- const onlyCopy = items.find((i) => i.kind === 'move-leftover' && i.onlyCopy);
398
- const kbCopyFix = onlyCopy ? onlyCopy.fix : extra.length && extra.every((i) => i.keptUnique) ? extra[0].fix : null;
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
- const homeMissing = !lstat(brainHome);
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
- if (f === restore) return { ...f, onlyCopy: true, fix: ok ? cmd.mv(f.path, brainHome) : inspect(f.path) };
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>: the SessionStart knowledge self-heal worker (launched by
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). The
17
- * nightly scheduler is opt-in and can silently never register, so this does not depend on it: when
18
- * a session starts and nothing proves the knowledge current inside 24h, launch ONE detached, bounded
19
- * `npx ruvnet-brain@latest --update --no-nightly-prompt` (the exact argv bin/nightly-refresh.mjs
20
- * runs) through the existing host-update.mjs credential boundary and detach.mjs TTL supervisor.
21
- * Per machine (all state under brainHome), at most one launch per 6h (30 min after an offline
22
- * probe), never while a refresh lock or another launch is live, never blocks (spawn + unref, no
23
- * wait). The outcome is recorded by the worker and spoken by the NEXT session's knowledge line.
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
- const tag = facts.source?.releaseTag ? `corpus ${facts.source.releaseTag}` : 'the latest corpus';
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
- if (fresh) return { launched: false, why: 'fresh' };
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
- if (since >= 0 && since < retryHours) return { launched: false, why: 'throttled' };
58
- // Cross-process: exactly one session wins the O_EXCL create; a lock older than the TTL is stale.
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
- writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: new Date(now).toISOString(), outcome: 'launched' });
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
- writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: new Date(now).toISOString(), outcome: 'failed',
75
- code: null, reason: `could not launch: ${error.message}`, finishedAt: new Date(now).toISOString() });
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
- if (/\bBEHIND\b/.test(read(kbLog))) {
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