ruvnet-brain 4.5.1 → 4.5.3

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.
@@ -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'));
@@ -135,12 +136,27 @@ export function confirm({ footprint, env = process.env, home = os.homedir(), now
135
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')]);
136
137
  if (!recorded) knowledgeAdvice.push([`installed or updated without a recorded signature verification — run ${UPDATE} to verify`, UPDATE]);
137
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
+ }
138
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]);
139
154
  const where = roots.location?.state === 'linked'
140
155
  ? `at ${roots.kbDir} (moved to ${volumeOf(roots.location.real)}, mounted)` : `at ${roots.kbDir}`;
141
156
  const moved = footprint.moveLeftovers || { copies: 0, bytes: 0 };
142
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)})` : ''}`,
143
- 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(' · ');
144
160
  const knowledgeIssues = [...knowledgeProblems, ...knowledgeAdvice];
145
161
  lines.push(line('knowledge', 'Knowledge', knowledgeProblems.length ? 'fail' : knowledgeAdvice.length ? 'warn' : 'ok',
146
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'),
@@ -26,7 +26,8 @@
26
26
  * • BOUNDED. A failure is ONE record per event (attempt count, last error), never a line per attempt;
27
27
  * compact() rewrites the file atomically (lock + size re-check, so a concurrent append is never
28
28
  * lost): committed events older than RETAIN_COMMITTED_MS leave (the store keeps them and dedupes),
29
- * corrupt lines become one notice, and a hard cap of MAX_EVENT_RECORDS drops the oldest, reported.
29
+ * corrupt lines become one notice; MAX_EVENT_RECORDS bounds committed history, never pending events.
30
+ * A prolonged outage can grow the pending file beyond that soft cap; status reports capacity pressure.
30
31
  */
31
32
  import fs from 'node:fs';
32
33
  import os from 'node:os';
@@ -54,7 +55,7 @@ export const STUCK_AFTER_MS = 10 * 60_000;
54
55
  export const QUARANTINE_REPORT_MS = 7 * 86_400_000;
55
56
  export const RETAIN_COMMITTED_MS = 7 * 86_400_000;
56
57
  export const MAX_EVENT_RECORDS = 2_000;
57
- const [COMPACT_AT_BYTES, LOCK_STALE_MS, APPEND_LOCK_WAIT_MS, APPEND_LOCK_STALE_MS] = [256 * 1024, 3 * 60_000, 1_000, 30_000];
58
+ const [LOCK_STALE_MS, APPEND_LOCK_WAIT_MS, APPEND_LOCK_STALE_MS] = [3 * 60_000, 1_000, 30_000];
58
59
  const WAL_REFUSAL = /refusing an unsafe sql\.js|active native WAL|database is locked|SQLITE_BUSY/i;
59
60
  export const CLEAR_COMMAND = `node "${path.join(path.dirname(fileURLToPath(import.meta.url)), 'continuity-brief.mjs')}" --clear`;
60
61
 
@@ -192,11 +193,14 @@ export class ContinuityJournal {
192
193
  /** Does the file carry anything compact() would remove? (cheap: from one scan) */
193
194
  needsCompaction(scan = this.scan()) {
194
195
  const now = this.now();
195
- if (scan.corrupt || scan.bytes > COMPACT_AT_BYTES || scan.events.size > MAX_EVENT_RECORDS) return true;
196
+ if (scan.corrupt) return true;
196
197
  const failureLines = scan.lines - scan.events.size - scan.committed.size - scan.quarantined.size - scan.notices.length - (scan.clearedAt ? 1 : 0) - scan.corrupt;
197
198
  if (failureLines > scan.failures.size) return true;
198
199
  for (const c of scan.committed.values()) if (now - ms(c.committedAt) > RETAIN_COMMITTED_MS) return true;
199
- return false;
200
+ if (scan.notices.some((n) => now - ms(n.at) > QUARANTINE_REPORT_MS || ms(n.at) <= scan.clearedAt)) return true;
201
+ if ([...scan.quarantined.values()].some((q) => now - ms(q.at) > QUARANTINE_REPORT_MS)) return true;
202
+ // An unshrinkable pending backlog must not be rewritten on every capture boundary.
203
+ return scan.events.size > MAX_EVENT_RECORDS && [...scan.events.keys()].some((key) => scan.committed.has(key));
200
204
  }
201
205
 
202
206
  /**
@@ -210,7 +214,6 @@ export class ContinuityJournal {
210
214
  try { before = fs.statSync(this.path); } catch { return { compacted: false, reason: 'no outbox' }; }
211
215
  const scan = this.scan();
212
216
  const out = [];
213
- let dropped = 0;
214
217
  const quarantineLive = (q) => now - ms(q.at) <= QUARANTINE_REPORT_MS;
215
218
  const keep = [...scan.events.values()].filter((rec) => {
216
219
  const c = scan.committed.get(rec.key);
@@ -218,16 +221,9 @@ export class ContinuityJournal {
218
221
  const q = scan.quarantined.get(rec.key);
219
222
  return q ? quarantineLive(q) : true;
220
223
  }).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
221
- // The hard cap: committed events go first (the store holds them), then the oldest pending.
222
- let over = keep.length - MAX_EVENT_RECORDS;
223
- if (over > 0) {
224
- const committedFirst = keep.filter((r) => scan.committed.has(r.key)).slice(0, over);
225
- over -= committedFirst.length;
226
- const pendingDrop = over > 0 ? keep.filter((r) => !scan.committed.has(r.key)).slice(0, over) : [];
227
- dropped = pendingDrop.length;
228
- const gone = new Set([...committedFirst, ...pendingDrop]);
229
- keep.splice(0, keep.length, ...keep.filter((r) => !gone.has(r)));
230
- }
224
+ // Only read-back committed events may leave at the cap. Pending bytes are the recovery copy.
225
+ const gone = new Set(keep.filter((r) => scan.committed.has(r.key)).slice(0, Math.max(0, keep.length - MAX_EVENT_RECORDS)));
226
+ keep.splice(0, keep.length, ...keep.filter((r) => !gone.has(r)));
231
227
  const kept = new Set(keep.map((r) => r.key));
232
228
  for (const rec of keep) {
233
229
  out.push(rec);
@@ -241,8 +237,8 @@ export class ContinuityJournal {
241
237
  notice.corrupt += Number(n.corrupt) || 0; notice.dropped += Number(n.dropped) || 0;
242
238
  notice.at = notice.at && notice.at < n.at ? notice.at : n.at;
243
239
  }
244
- if (scan.corrupt || dropped) {
245
- notice.corrupt += scan.corrupt; notice.dropped += dropped;
240
+ if (scan.corrupt) {
241
+ notice.corrupt += scan.corrupt;
246
242
  notice.at = notice.at || new Date(now).toISOString();
247
243
  }
248
244
  if (notice.corrupt || notice.dropped) out.push({ type: 'notice', ...notice });
@@ -258,7 +254,7 @@ export class ContinuityJournal {
258
254
  return { compacted: false, reason: 'the outbox changed during compaction; left as is' };
259
255
  }
260
256
  fs.renameSync(tmp, this.path);
261
- return { compacted: true, lines: out.length, kept: kept.size, dropped };
257
+ return { compacted: true, lines: out.length, kept: kept.size, dropped: 0 };
262
258
  });
263
259
  }
264
260
 
@@ -279,14 +275,15 @@ export class ContinuityJournal {
279
275
  const ready = storeReady(this.db);
280
276
  const notApplicable = !ready ? 'no AgentDB store in this project (.swarm/memory.db is not initialized)'
281
277
  : !this.ruflo ? 'ruflo is not installed' : null;
278
+ const capacityPressure = pending.length > MAX_EVENT_RECORDS;
282
279
  const problem = quarantined.length ? 'quarantined' : corrupt ? 'corrupt' : dropped ? 'dropped'
283
- : !notApplicable && oldest !== null && now - oldest > STUCK_AFTER_MS ? 'stuck-pending' : null;
280
+ : !notApplicable && capacityPressure ? 'capacity-pressure' : !notApplicable && oldest !== null && now - oldest > STUCK_AFTER_MS ? 'stuck-pending' : null;
284
281
  return {
285
282
  outbox: this.path, db: this.db, storeReady: ready, rufloPresent: Boolean(this.ruflo),
286
283
  applicable: !notApplicable, notApplicable,
287
284
  pending: pending.length, oldestPendingAt: oldest, lastCommitAt, eventsToday,
288
285
  lastFailure: lastFailure && (!lastCommitAt || ms(lastFailure.at) > lastCommitAt) ? lastFailure : null,
289
- quarantined, corrupt, dropped, problem, stuck: Boolean(problem),
286
+ quarantined, corrupt, dropped, capacityPressure, problem, stuck: Boolean(problem),
290
287
  };
291
288
  }
292
289
  }
@@ -301,12 +298,14 @@ export function recordingLine(status, now = Date.now()) {
301
298
  const why = status.problem === 'quarantined' ? `${status.quarantined.length} quarantined (a different row holds its key; never retried — ${clears})`
302
299
  : status.problem === 'corrupt' ? `${status.corrupt} corrupt outbox line(s) removed (${clears})`
303
300
  : status.problem === 'dropped' ? `${status.dropped} uncommitted event(s) dropped at the outbox cap (${clears})`
301
+ : status.problem === 'capacity-pressure' ? `pending backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending events discarded`
304
302
  : status.lastFailure ? `last error: ${status.lastFailure.reason || status.lastFailure.error}` : 'not committing';
305
303
  return `AgentDB: recording stuck — ${status.pending} event(s) pending${status.oldestPendingAt && status.problem === 'stuck-pending' ? ` for ${ago(now - status.oldestPendingAt)}` : ''}, ${why}.`
306
304
  + ` Pending events are durable in ${status.outbox} and retry at every capture boundary.`;
307
305
  }
308
306
  if (status.notApplicable) {
309
- return `AgentDB: recording n/a — ${status.notApplicable}; ${status.pending} event(s) wait in the outbox (not a failure).`;
307
+ return `AgentDB: recording n/a — ${status.notApplicable}; ${status.pending} event(s) wait in the outbox (not a failure).`
308
+ + (status.capacityPressure ? ` Capacity pressure: pending backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending events discarded.` : '');
310
309
  }
311
310
  // ✓ only on evidence: a committed, read-back event. "Nothing failed yet" is not proof of recording.
312
311
  if (!status.lastCommitAt) return `AgentDB: recording not yet proven — no event committed and read back yet (outbox ${status.pending} pending)`;
@@ -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
  });
@@ -67,6 +67,13 @@ if printf '%s' "$TEXT" | grep -qiE '\[Your previous response|\[Request interrupt
67
67
  exit 0
68
68
  fi
69
69
 
70
+ # Read canonical memory before the quiet path; useful evidence may exist even
71
+ # for a worktree with no local .swarm. Acks/off/no-match keep the cheap return.
72
+ _ADB_OUT=""
73
+ if [ "${RUVNET_AGENTDB_FIRST:-on}" != "off" ]; then
74
+ _ADB_OUT=$(printf '%s' "$INPUT" | "${RUVNET_NODE_BIN:-node}" "$(dirname "$0")/agentdb-recall.mjs" 2>/dev/null)
75
+ fi
76
+
70
77
  # ── QUIET-PROMPT FAST PATH. ────────────────────────────────────────────────────────────────────
71
78
  # A hook whose output contract is silence must not pay the full stack-currency/project-state scan
72
79
  # before discovering that nothing can fire. This mattered on a packed Windows install: immediately
@@ -83,7 +90,7 @@ QUICK_RELEVANT=0
83
90
  if printf '%s' "$TEXT" | grep -qiE 'ruvnet|ruflo|ruvector|rvf|agentdb|agenticow|rulake|ruview|rupixel|ruv-fann|agentic-flow|synthlang|dspy|qudag|safla|metaharness|cve-bench|sparc|swarm|claude-flow|pinecone|pgvector|chroma|weaviate|faiss|milvus|qdrant|hnswlib|annoy|vector|langchain|llama|autogen|crew-ai|semantic-kernel|embedding|retrieval|prompt compression|token cost|post-quantum|quantum-resistant|adr|decision|architect|design|plan|spec|refactor|migrat|implement|build|write|add|change|fix|update|deploy|create|enhance|set up|setup|wire|integrate|test|coverage|audit|review|benchmark|lint|scan|debug|optimi|app|feature|service|system|backend|frontend|api|module|pipeline|infra|database|schema|workflow|roadmap|milestone|autonomous|unattended|do not stop|keep working|keep going|soak run|harness|quality|readiness|evolve|self-improv|hardening|cheaper|cheap|lower cost|compute arbitrage|cascade|scorecard|score .*repo'; then
84
91
  QUICK_RELEVANT=1
85
92
  fi
86
- if [ "$QUICK_RUFLO" -eq 0 ] && [ "$QUICK_RELEVANT" -eq 0 ] && [ "${RUVNET_AUTONOMOUS:-0}" != "1" ]; then
93
+ if [ "$QUICK_RUFLO" -eq 0 ] && [ "$QUICK_RELEVANT" -eq 0 ] && [ "${RUVNET_AUTONOMOUS:-0}" != "1" ] && [ -z "$_ADB_OUT" ]; then
87
94
  exit 0
88
95
  fi
89
96
 
@@ -138,6 +145,13 @@ inj_seen() {
138
145
  }
139
146
  inj_mark() { [ -n "$INJ_DIR" ] && mkdir -p "$INJ_DIR" 2>/dev/null && : > "$INJ_DIR/$1" 2>/dev/null; return 0; }
140
147
 
148
+ # ── Canonical AgentDB recall (ADR-101): every nontrivial human prompt. ──────────────
149
+ # Priority 0 and digest dedupe preserve safety output and avoid re-sending evidence.
150
+ if [ -n "$_ADB_OUT" ]; then
151
+ _ADB_ID=$(printf '%s\n' "$_ADB_OUT" | head -n 1 | tr -cd 'A-Za-z0-9' | cut -c1-12)
152
+ [ -n "$_ADB_ID" ] && printf '%s\n' "$_ADB_OUT" | sed '1d' | out_to "0-0-agentdb-recall-$_ADB_ID"
153
+ fi
154
+
141
155
  # ── Gate 0: STACK WATCHDOG (always fires) — filesystem ground truth, not impressions. ───────────
142
156
  # Runs in the project's cwd every prompt, FROM the loaded plugin's own dir — so $CLAUDE_PLUGIN_ROOT
143
157
  # is the RUNNING (in-memory) version by construction, never the staged disk copy. Checks what's
@@ -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 */ }
@@ -23,10 +23,22 @@ export class ProgressionOutbox {
23
23
  appendRecord(record) {
24
24
  fs.mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 });
25
25
  const noFollow = fs.constants.O_NOFOLLOW ?? 0;
26
- const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | noFollow, 0o600);
26
+ const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_RDWR | noFollow, 0o600);
27
27
  try {
28
+ let separator = '';
29
+ const size = fs.fstatSync(fd).size;
30
+ if (size) {
31
+ const last = Buffer.alloc(1);
32
+ fs.readSync(fd, last, 0, 1, size - 1);
33
+ if (last[0] !== 10) {
34
+ const content = fs.readFileSync(fd, 'utf8');
35
+ try { JSON.parse(content.slice(content.lastIndexOf('\n') + 1)); }
36
+ catch { throw new Error('incomplete outbox final record; preserve and recover the torn tail before appending'); }
37
+ separator = '\n';
38
+ }
39
+ }
28
40
  fs.fchmodSync(fd, 0o600);
29
- writeAll(fd, `${JSON.stringify(record)}\n`);
41
+ writeAll(fd, `${separator}${JSON.stringify(record)}\n`);
30
42
  this.fsync(fd);
31
43
  } finally {
32
44
  fs.closeSync(fd);
@@ -58,8 +70,11 @@ export class ProgressionOutbox {
58
70
  if (!fs.existsSync(this.path)) return [];
59
71
  const content = fs.readFileSync(this.path, 'utf8');
60
72
  const lines = content.split('\n');
61
- if (lines.at(-1) !== '') lines.pop();
62
- else lines.pop();
73
+ if (lines.at(-1) === '') lines.pop();
74
+ else {
75
+ // A missing delimiter is not a missing record; only a crash-torn JSON suffix is incomplete.
76
+ try { JSON.parse(lines.at(-1)); } catch { lines.pop(); }
77
+ }
63
78
  return lines.filter(Boolean).map((line, index) => {
64
79
  try { return JSON.parse(line); } catch { throw new Error(`malformed outbox record at line ${index + 1}`); }
65
80
  });
@@ -40,21 +40,24 @@ function isWithin(root, candidate) {
40
40
  return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
41
41
  }
42
42
 
43
- function gitValue(cwd, args) {
43
+ function gitValue(cwd, args, gitTimeoutMs) {
44
44
  try {
45
45
  return execFileSync('git', args, {
46
46
  cwd,
47
47
  encoding: 'utf8',
48
48
  stdio: ['ignore', 'pipe', 'ignore'],
49
+ ...(gitTimeoutMs ? { timeout: gitTimeoutMs, killSignal: 'SIGKILL' } : {}),
49
50
  }).trim();
50
- } catch {
51
+ } catch (error) {
52
+ // A timed-out Git identity check is not evidence for a non-git project.
53
+ if (error.code === 'ETIMEDOUT') throw new Error('Git project identity timed out');
51
54
  return null;
52
55
  }
53
56
  }
54
57
 
55
- function gitProject(projectDir) {
56
- const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
57
- const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel']);
58
+ function gitProject(projectDir, gitTimeoutMs) {
59
+ const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'], gitTimeoutMs);
60
+ const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel'], gitTimeoutMs);
58
61
  if (!commonValue || !checkoutValue) return null;
59
62
  const gitCommonDir = canonicalDirectory(commonValue, 'Git common directory');
60
63
  const checkoutRoot = canonicalDirectory(checkoutValue, 'Git checkout root');
@@ -68,9 +71,10 @@ function gitProject(projectDir) {
68
71
  };
69
72
  }
70
73
 
71
- export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath } = {}) {
74
+ export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath, gitTimeoutMs } = {}) {
72
75
  const canonicalInput = canonicalDirectory(projectDir, 'projectDir');
73
- const git = gitProject(canonicalInput);
76
+ if (gitTimeoutMs !== undefined && (!Number.isSafeInteger(gitTimeoutMs) || gitTimeoutMs <= 0)) throw new TypeError('gitTimeoutMs must be a positive integer');
77
+ const git = gitProject(canonicalInput, gitTimeoutMs);
74
78
  const resolved = git ?? {
75
79
  gitCommonDir: null,
76
80
  checkoutRoot: canonicalInput,
@@ -82,6 +86,11 @@ export function resolveProjectStore({ projectDir = process.cwd(), requestedStore
82
86
  if (!isWithin(resolved.projectRoot, resolvedAgentDbPath)) {
83
87
  throw new Error('store symlink escape rejected');
84
88
  }
89
+ // A hard link passes every path check yet writes into a foreign inode (ADR-0102 G-053, measured: the
90
+ // Stop boundary's progression/continuity writers changed a hard-linked foreign store).
91
+ let storeStat = null;
92
+ try { storeStat = fs.statSync(resolvedAgentDbPath); } catch { /* not created yet */ }
93
+ if (storeStat && storeStat.nlink > 1) throw new Error('store hard link rejected');
85
94
  if (requestedStorePath !== undefined) {
86
95
  if (typeof requestedStorePath !== 'string' || !requestedStorePath.trim()) {
87
96
  throw new TypeError('requestedStorePath must be a non-empty path');
@@ -129,8 +129,8 @@ export function runSessionSnapshotHook(projectDir, event, {
129
129
  const metadataWritten = writeMetadata ? writeSessionSnapshot(projectDir, event) : false;
130
130
  let payload;
131
131
  try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
132
- // TURN OUTCOMES FIRST, and independent of `.swarm`: every turn in every repository is recorded
133
- // (a project without `.swarm` records to the machine-wide db outside it — turn-outcome-capture.mjs).
132
+ // TURN OUTCOMES FIRST: consent and canonical store availability govern whether a turn is queued
133
+ // (a project without a store requires persisted opt-in — turn-outcome-capture.mjs).
134
134
  // It only reads and spawns a detached writer, so it costs the progression budget below nothing.
135
135
  let turn;
136
136
  try { turn = captureTurn({ projectDir, event, payload, host }); } catch (error) {
@@ -27,6 +27,7 @@ import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { restoreWithBrief } from './continuity-brief.mjs';
30
+ import { turnRecordingStatus } from './turn-outcome-capture.mjs';
30
31
  import {
31
32
  read, json, exists, mkdir, write, runNode,
32
33
  } from './session-start-fsutil.mjs';
@@ -120,6 +121,7 @@ export async function runSessionStart({
120
121
  || s.startsWith('[RuvNet Brain v')
121
122
  || s.startsWith('USER-LEVEL:')
122
123
  || s.startsWith('[ASCII→SVG]')
124
+ || s.startsWith('[RuvNet Brain — TURN RECORDING]')
123
125
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
124
126
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
125
127
  || s.startsWith('[RuvNet Brain — COME UP TO SPEED')
@@ -183,6 +185,9 @@ export async function runSessionStart({
183
185
  emit('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]');
184
186
  emit('The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
185
187
  }
188
+ // Turn health remains visible even when the separate progression restore fails.
189
+ const turnStatus = turnRecordingStatus({ projectDir: cwd, env, home });
190
+ if (turnStatus.state === 'warn') emit(`[RuvNet Brain — TURN RECORDING] ${turnStatus.line}`);
186
191
  // Opt-in, matching the pre-existing `trace()` convention below: several other tests assert
187
192
  // SessionStart's stderr is EMPTY in the clean case (hook-battery.test.mjs, hook-hardening.test.mjs
188
193
  // — a real contract, not incidental), so this must never write unconditionally. `restore` shares
@@ -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 });