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.
- package/README.md +2 -2
- package/bin/install.mjs +25 -3
- package/kb/forge-update.mjs +2 -2
- package/kb/lifecycle-evidence-retention.mjs +56 -2
- package/kb/verify-citation.mjs +48 -27
- 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/agentdb-recall.mjs +226 -0
- package/plugin/scripts/brain-confirmation.mjs +18 -2
- package/plugin/scripts/brain-footprint.mjs +10 -5
- package/plugin/scripts/continuity-journal.mjs +20 -21
- package/plugin/scripts/footprint-io.mjs +11 -4
- package/plugin/scripts/ground-ruvnet.sh +15 -1
- package/plugin/scripts/host-update.mjs +57 -1
- package/plugin/scripts/project-progression-outbox.mjs +19 -4
- package/plugin/scripts/project-store-resolver.mjs +16 -7
- package/plugin/scripts/session-snapshot-hook.mjs +2 -2
- package/plugin/scripts/session-start-core.mjs +5 -0
- package/plugin/scripts/session-start-health.mjs +42 -3
- package/plugin/scripts/session-start-update-plane.mjs +58 -23
- package/plugin/scripts/turn-outcome-capture.mjs +154 -56
- package/scripts/learning-replay-cli.mjs +15 -0
- package/scripts/learning-replay.mjs +1 -1
- package/scripts/nightly-wrapper.sh +5 -5
- package/scripts/release-qualification-contract.mjs +74 -0
- package/scripts/release-qualification.mjs +10 -1
- package/scripts/wired-check.mjs +1 -0
|
@@ -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 ?
|
|
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
|
|
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'),
|
|
@@ -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
|
|
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 [
|
|
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
|
|
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
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
245
|
-
notice.corrupt += scan.corrupt;
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
|
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.
|
|
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)
|
|
62
|
-
else
|
|
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
|
-
|
|
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
|
|
133
|
-
// (a project without
|
|
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 });
|