ruvnet-brain 4.4.0 → 4.5.0
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 +3 -3
- package/bin/install.mjs +679 -121
- package/console/app.js +178 -81
- package/console/index.html +1 -1
- package/console/install-architecture.html +1 -0
- package/console/scope.css +4 -1
- package/console/style.css +13 -0
- package/console/tips.html +4 -4
- package/kb/brain-profile.mjs +1 -0
- package/kb/corpus-release-identity.mjs +1 -1
- package/kb/forge-update.mjs +41 -17
- package/kb/model-requirements.mjs +4 -1
- package/kb/update-storage-transaction.mjs +79 -0
- package/kb/zip-extract.mjs +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +5 -4
- package/plugin/commands/configure.md +5 -4
- package/plugin/commands/rnb-brief.md +41 -0
- package/plugin/commands/rnb.md +80 -0
- package/plugin/commands/rnbc.md +80 -0
- package/plugin/commands/rvbc.md +5 -4
- package/plugin/commands/rvcb.md +5 -4
- package/plugin/commands/whats-new.md +4 -4
- package/plugin/mcp/server.mjs +10 -2
- package/plugin/scripts/advocacy-route.mjs +59 -23
- package/plugin/scripts/anticipate.sh +4 -0
- package/plugin/scripts/brain-confirmation.mjs +258 -0
- package/plugin/scripts/brain-footprint.mjs +494 -0
- package/plugin/scripts/brain-location.mjs +47 -0
- package/plugin/scripts/capability-registry.mjs +11 -1
- package/plugin/scripts/continuity-brief.mjs +324 -0
- package/plugin/scripts/continuity-events.mjs +327 -0
- package/plugin/scripts/continuity-journal.mjs +500 -0
- package/plugin/scripts/decision-gate.mjs +56 -3
- package/plugin/scripts/footprint-io.mjs +186 -0
- package/plugin/scripts/ground-before-write.sh +8 -1
- package/plugin/scripts/ground-ruvnet.sh +103 -12
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-substance.mjs +1 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
- package/plugin/scripts/hook-input.mjs +78 -4
- package/plugin/scripts/kb-copy-proof.mjs +148 -0
- package/plugin/scripts/lesson-bridge.mjs +6 -2
- package/plugin/scripts/nightly-controller.mjs +8 -1
- package/plugin/scripts/node-sqlite.mjs +41 -0
- package/plugin/scripts/package-cards.json +797 -0
- package/plugin/scripts/package-cards.rvf +0 -0
- package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
- package/plugin/scripts/package-cards.rvf.meta.json +1 -0
- package/plugin/scripts/package-recommender-client.mjs +138 -0
- package/plugin/scripts/package-recommender-flag.mjs +30 -0
- package/plugin/scripts/package-recommender.mjs +391 -0
- package/plugin/scripts/project-progression-outbox.mjs +26 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -2
- package/plugin/scripts/project-progression-store.mjs +153 -6
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +225 -41
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +34 -4
- package/plugin/scripts/session-start-health.mjs +7 -1
- package/plugin/scripts/session-start-update-plane.mjs +35 -0
- package/plugin/scripts/turn-outcome-capture.mjs +12 -1
- package/plugin/scripts/unprompted-runtime.mjs +2 -2
- package/plugin/skills/brain-console/SKILL.md +3 -3
- package/plugin/skills/rnbc/SKILL.md +24 -0
- package/plugin/skills/rvbc/SKILL.md +2 -2
- package/scripts/approved-runtime.mjs +2 -2
- package/scripts/ci/warm-brain-models.mjs +28 -0
- package/scripts/codex-hook-trust.mjs +94 -0
- package/scripts/console-instances.mjs +70 -12
- package/scripts/console-runtime-identity.mjs +5 -0
- package/scripts/corpus-canary.mjs +46 -6
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +1 -1
- package/scripts/full-suite-gate.mjs +9 -2
- package/scripts/hook-qualify-hosts.mjs +15 -3
- package/scripts/host-install-matrix.mjs +63 -2
- package/scripts/human-approval-phrases.mjs +46 -0
- package/scripts/installed-brain-health.mjs +53 -0
- package/scripts/move-brain.mjs +310 -0
- package/scripts/onboarding-console.mjs +93 -10
- package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
- package/scripts/oracle/abstain-trace.mjs +139 -0
- package/scripts/oracle/doc2query-generate.mjs +162 -0
- package/scripts/oracle/doc2query-reach.mjs +110 -0
- package/scripts/oracle/judge-train.mjs +158 -0
- package/scripts/oracle/need-set-split.mjs +48 -0
- package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
- package/scripts/package-cards.mjs +374 -0
- package/scripts/publication-receipt.mjs +37 -9
- package/scripts/recommendation-e2e.mjs +110 -0
- package/scripts/recommendation-eval.mjs +105 -0
- package/scripts/recommendation-floor.mjs +56 -0
- package/scripts/recommendation-judge-score.mjs +74 -0
- package/scripts/recommendation-latency.mjs +95 -0
- package/scripts/recommendation-real-host-score.mjs +76 -0
- package/scripts/recommendation-real-host.mjs +137 -0
- package/scripts/release-channel-kind.mjs +1 -1
- package/scripts/release-environment-policy.mjs +33 -0
- package/scripts/single-source-check.mjs +15 -10
- package/scripts/sync-commands.mjs +5 -2
- package/scripts/wired-check.mjs +17 -2
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// DISTINCT-FROM: kb/forge-update.mjs reclaimBackups — that proof keeps a full-KB copy unless EVERY byte of it survives in the live brain, which an older generation never satisfies (measured 2026-10-01: 3.6 GB in three copies kept forever). This module releases public bytes (signed, re-downloadable) and demands byte-identity in live only for private-store files, per the owner's rule (ADR-0098).
|
|
3
|
+
// brain-footprint.mjs — THE FOOTPRINT GUARANTEE (ADR-0098). One classifier for everything the Brain
|
|
4
|
+
// owns on a machine, in three classes, and one sweep that keeps it that way:
|
|
5
|
+
//
|
|
6
|
+
// must-exist the live KB, the active Stable Spine generation, each registered plugin generation
|
|
7
|
+
// may-exist bounded state: logs under a size cap, lease-held plugin generations, the newest npx
|
|
8
|
+
// copy, lifecycle evidence under its own retention policy, models, small state files
|
|
9
|
+
// must-not-exist every other full-KB copy (kb.bak-*, kb.install-preserved-*, kb.pre-update-*, …,
|
|
10
|
+
// *-quarantine-* dirs), stale install stages and forge candidates, older npx copies,
|
|
11
|
+
// stale leases, ruflo scratch debris, rotated-away log bytes
|
|
12
|
+
// unowned things in our directories we did not create and cannot classify: REPORTED, never removed
|
|
13
|
+
//
|
|
14
|
+
// SAFETY (non-negotiable, each one has a test that breaks it and goes red):
|
|
15
|
+
// * A KB copy is removed only after kbCopyProof() shows nothing in it is unique (every private-store file
|
|
16
|
+
// byte-identical in live, everything else of public provenance); a KEPT proof is cached (footprint-io).
|
|
17
|
+
// * Nothing is followed through a symlink; a removal target must sit directly inside the real directory it
|
|
18
|
+
// was inventoried in, itself inside an owned root (footprint-io removeWithin).
|
|
19
|
+
// * Kept: non-terminal transaction trees; every KB sibling and npx copy while someone else holds the refresh
|
|
20
|
+
// lock or an install is activating; hand-made backups (unowned). The live KB is never written here.
|
|
21
|
+
// * Plugin generations go only through the caller's lease-aware collector (prunePluginGenerations).
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
import { isKbTree, kbCopyProof } from './kb-copy-proof.mjs';
|
|
27
|
+
import { brainLocation } from './brain-location.mjs';
|
|
28
|
+
import { assessMoveLeftovers, cachedKept, cmpVersion, isVolumeMetadata, keptCopyFix, physical, pidAlive, readProofCache, rememberKept, removeWithin, rotate,
|
|
29
|
+
treeBytes, truncateToTail } from './footprint-io.mjs';
|
|
30
|
+
|
|
31
|
+
export { cmpVersion, physical, treeBytes } from './footprint-io.mjs';
|
|
32
|
+
|
|
33
|
+
export { kbCopyProof, privateStoreNames } from './kb-copy-proof.mjs';
|
|
34
|
+
|
|
35
|
+
const MIB = 1024 * 1024;
|
|
36
|
+
export const FOOTPRINT_POLICY = Object.freeze({
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
logCapBytes: 2 * MIB, // an append-only ledger rotates to <name>.1 past this (bound: 2x cap)
|
|
39
|
+
textLogCapBytes: 512 * 1024, // a .last-*.log is truncated to its newest tail past this
|
|
40
|
+
fixedAllowanceBytes: 512 * MIB, // plugin + spine generations, logs, evidence, scratch, state files
|
|
41
|
+
staleStageMs: 2 * 3_600_000, // an install stage nobody activated in 2h is debris
|
|
42
|
+
staleForgeCandidateMs: 24 * 3_600_000,
|
|
43
|
+
staleLeaseMs: 6 * 3_600_000, // plugin/scripts/update-apply.mjs LEASE_FRESH_MS
|
|
44
|
+
staleRufloRunMs: 3_600_000, // plugin/scripts/project-progression-store.mjs STALE_RUN_MS
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Every full-KB copy name this product (or a recovery) has ever created beside the live KB. A superset of
|
|
48
|
+
* kb/update-storage-transaction.mjs managedStorageInventory's names and kb/forge-update.mjs reclaimBackups'
|
|
49
|
+
* prefixes — tests/unit/brain-footprint.test.mjs fails if either grows a name this table lacks. */
|
|
50
|
+
export const kbCopyPrefixes = (base) => [
|
|
51
|
+
`${base}.bak-`, `${base}.pre-reset-backup-`, `${base}.agent-harness-generator-backup-`,
|
|
52
|
+
`${base}-pre-gap-rebuild-backup-`, `${base}.install-preserved-`, `${base}.install-prior-`,
|
|
53
|
+
`${base}.pre-update-`, `${base}.next-`, `${base}.rollback-`, `${base}.failed-`,
|
|
54
|
+
];
|
|
55
|
+
const TRANSACTION_KINDS = { next: 'candidate', rollback: 'rollback', failed: 'failed' };
|
|
56
|
+
export const LOG_FILES = Object.freeze(['evidence.jsonl', 'token-ledger.jsonl', 'detached-jobs.jsonl',
|
|
57
|
+
'update-receipts.jsonl', 'gate-blocks.jsonl', 'distill-receipts.jsonl', 'assertion-gate-shadow.jsonl',
|
|
58
|
+
'capability-live-evidence.jsonl', 'design-grades.jsonl', 'grounding-overrides.jsonl']);
|
|
59
|
+
const TEXT_LOG = /^\.(?:last-[A-Za-z0-9-]+|seed)\.log$/;
|
|
60
|
+
const RECOVERY_LEFTOVER = /(?:\.(?:bak|retired|dead)-\d{6,}|^bootstrap-backup-\d)/;
|
|
61
|
+
const RUFLO_DEBRIS = new Set(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
|
|
62
|
+
// Directories in the brain home that hold data another tool owns: measured, shown, never budgeted or removed.
|
|
63
|
+
const FOREIGN = { 'ruvector-mcp': 'RuVector MCP server working store (its own live memory)',
|
|
64
|
+
'worktree-recovery': 'hand-made recovery archives, not created by the Brain' };
|
|
65
|
+
const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
66
|
+
|
|
67
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
68
|
+
const lstat = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
|
|
69
|
+
const names = (dir) => { try { return fs.readdirSync(dir).filter((n) => !isVolumeMetadata(n)).sort(); } catch { return []; } };
|
|
70
|
+
/** A lease's process is gone only when the OS says so (ESRCH); anything else counts as alive. */
|
|
71
|
+
|
|
72
|
+
export function footprintRoots({ env = process.env, home = os.homedir() } = {}) {
|
|
73
|
+
const brainHomeSpelled = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
74
|
+
const brainHome = physical(brainHomeSpelled);
|
|
75
|
+
const kbSpelled = env.RUVNET_BRAIN_KB || path.join(brainHomeSpelled, 'kb');
|
|
76
|
+
const kbDir = physical(kbSpelled);
|
|
77
|
+
// WHERE THE BRAIN REALLY IS and whether its disk is there: the ONE reader, plugin/scripts/brain-location.mjs
|
|
78
|
+
// (owned by `--move-brain`). An 'unmounted' brain is reported and nothing is cleaned, created or reinstalled.
|
|
79
|
+
const location = brainLocation({ home, brainHome: path.resolve(brainHomeSpelled) });
|
|
80
|
+
const claude = env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
|
|
81
|
+
const codex = env.CODEX_HOME || path.join(home, '.codex');
|
|
82
|
+
// `npm run`/`npx` export npm_config_cache to every child — including a test that swapped HOME for a temp
|
|
83
|
+
// dir, where honouring it would point the sweep at the REAL npm cache. It is honoured only inside HOME.
|
|
84
|
+
const defaultCache = process.platform === 'win32'
|
|
85
|
+
? path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm-cache') : path.join(home, '.npm');
|
|
86
|
+
const configured = env.npm_config_cache || env.NPM_CONFIG_CACHE;
|
|
87
|
+
const npmCache = configured && physical(configured).startsWith(`${physical(home)}${path.sep}`) ? configured : defaultCache;
|
|
88
|
+
return { brainHome, kbDir, kbParent: path.dirname(kbDir), brainHomeParent: path.dirname(brainHome),
|
|
89
|
+
// A moved brain's quarantines may sit beside the SPELLED home (~/.cache) as well as the real one.
|
|
90
|
+
brainHomeParents: [...new Set([path.dirname(brainHome), physical(path.dirname(path.resolve(brainHomeSpelled)))])],
|
|
91
|
+
location, dangling: location.state === 'unmounted',
|
|
92
|
+
claudeRegistry: path.join(claude, 'plugins', 'installed_plugins.json'),
|
|
93
|
+
claudePluginCache: path.join(claude, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
|
|
94
|
+
codexPluginCache: path.join(codex, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
|
|
95
|
+
npxRoot: path.join(npmCache, '_npx') };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
function transactionState(kbParent, base, id) {
|
|
100
|
+
const dir = path.join(kbParent, `.${base}.update-transactions`, id);
|
|
101
|
+
const phases = names(dir).filter((n) => /^\d{3}-[A-Z_]+\.json$/.test(n));
|
|
102
|
+
if (!phases.length) return null;
|
|
103
|
+
return readJson(path.join(dir, phases.at(-1)))?.state || 'UNREADABLE';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Registered Claude generations (installPaths) and the version each one carries. */
|
|
107
|
+
function claudeRegistered(registryFile) {
|
|
108
|
+
const registry = readJson(registryFile);
|
|
109
|
+
return Object.entries(registry?.plugins || {}).filter(([name]) => /^ruvnet-brain@/.test(name))
|
|
110
|
+
.flatMap(([, v]) => (Array.isArray(v) ? v : [])).map((e) => e?.installPath).filter((p) => typeof p === 'string' && p)
|
|
111
|
+
.map((p) => physical(p));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const pluginVersion = (dir) => readJson(path.join(dir, '.claude-plugin', 'plugin.json'))?.version || null;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The classified inventory. Pure read. `measure:false` skips recursive byte counts (SessionStart's fast
|
|
118
|
+
* path); classes and actions do not depend on bytes. `evidence` is kb/lifecycle-evidence-retention.mjs
|
|
119
|
+
* assessLifecycleEvidence (injected: the plugin payload cannot import kb/).
|
|
120
|
+
*/
|
|
121
|
+
export function inventoryFootprint({ env = process.env, home = os.homedir(), now = Date.now(), measure = true,
|
|
122
|
+
evidence = null, npmLatest = null, holdingRefreshLock = false, selfPath = process.argv[1] || '' } = {}) {
|
|
123
|
+
const roots = footprintRoots({ env, home });
|
|
124
|
+
const policy = FOOTPRINT_POLICY;
|
|
125
|
+
const items = [];
|
|
126
|
+
const bytes = (p) => (measure ? treeBytes(p) : 0);
|
|
127
|
+
// Each item remembers the REAL directory it was found in; removal refuses if that changed (review S7).
|
|
128
|
+
const add = (item) => { items.push({ bytes: 0, realParent: physical(path.dirname(item.path)), ...item }); return item; };
|
|
129
|
+
const base = path.basename(roots.kbDir);
|
|
130
|
+
const refreshLock = path.join(roots.kbParent, `.${base}.refresh-run.lock`);
|
|
131
|
+
const lockHeld = !holdingRefreshLock && Boolean(lstat(refreshLock));
|
|
132
|
+
// A plain `npx ruvnet-brain` install holds no refresh lock (review S6). It is IN PROGRESS while its activation
|
|
133
|
+
// marker, its young stage, or a kb.install-prior-<ts>-<pid> rollback copy names a live pid: nothing beside the KB moves.
|
|
134
|
+
// Proof of life is bounded by TIME too (re-review S5): an old marker or a reused / EPERM pid no longer freezes it.
|
|
135
|
+
const young = (at) => Number.isFinite(at) && now - at >= -60_000 && now - at <= policy.staleStageMs; // a FUTURE stamp is stale
|
|
136
|
+
const priorParts = (name) => name.slice(`${base}.install-prior-`.length).split('-').map(Number);
|
|
137
|
+
const installing = (() => {
|
|
138
|
+
const marker = readJson(path.join(roots.kbParent, `.${base}.install-activation.lock`));
|
|
139
|
+
if (marker && young(Number(marker.at)) && pidAlive(Number(marker.pid))) return true;
|
|
140
|
+
return names(roots.kbParent).some((n) => (n.startsWith(`.${base}.install-stage-`) && young(lstat(path.join(roots.kbParent, n))?.mtimeMs))
|
|
141
|
+
|| (n.startsWith(`${base}.install-prior-`) && young(priorParts(n)[0]) && pidAlive(priorParts(n).at(-1))));
|
|
142
|
+
})();
|
|
143
|
+
const kbBlocked = lockHeld ? 'an update holds the refresh lock; KB copies are never touched while it runs'
|
|
144
|
+
: installing ? 'an install is activating a new generation; KB copies and installer copies are never touched while it runs' : null;
|
|
145
|
+
|
|
146
|
+
// ── a moved brain whose volume is not mounted: report it, touch NOTHING (no sweep, no reinstall) ─
|
|
147
|
+
if (roots.dangling) {
|
|
148
|
+
add({ id: 'kb', path: roots.location.path, class: 'must-exist', kind: 'live-kb', action: 'report', present: false, dangling: true,
|
|
149
|
+
reason: roots.location.message });
|
|
150
|
+
return summarize({ roots, items, policy, kbCopyDirs: [], lockHeld });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── the live KB and its siblings ────────────────────────────────────────────────────────────
|
|
154
|
+
const liveOk = isKbTree(roots.kbDir);
|
|
155
|
+
add({ id: 'kb', path: roots.kbDir, class: 'must-exist', kind: 'live-kb', action: 'keep',
|
|
156
|
+
bytes: bytes(roots.kbDir), present: liveOk, reason: liveOk ? 'the one live knowledge base' : 'MISSING or incomplete' });
|
|
157
|
+
const kbCopyDirs = [];
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
const quarantineCopies = (dir) => names(dir).filter((n) => isKbTree(path.join(dir, n))).length;
|
|
160
|
+
const proofCache = readProofCache(roots.brainHome);
|
|
161
|
+
// A quarantine whose every entry is a KB copy already proven KEPT has nothing a sweep could remove.
|
|
162
|
+
const quarantineKept = (dir) => {
|
|
163
|
+
const children = names(dir);
|
|
164
|
+
const hits = children.map((n) => isKbTree(path.join(dir, n)) && cachedKept(proofCache, path.join(dir, n), roots.kbDir));
|
|
165
|
+
return children.length && hits.every(Boolean) ? { hit: hits[0], file: path.join(dir, children[0]) } : null;
|
|
166
|
+
};
|
|
167
|
+
const addQuarantine = (full) => {
|
|
168
|
+
const kept = quarantineKept(full);
|
|
169
|
+
add({ id: 'quarantine', path: full, class: 'must-not-exist', kind: 'quarantine', action: kbBlocked || kept ? 'report' : 'remove-if-proven',
|
|
170
|
+
blocked: kbBlocked, bytes: bytes(full), copies: quarantineCopies(full), keptUnique: Boolean(kept), fix: kept ? keptCopyFix(kept.file, kept.hit) : null,
|
|
171
|
+
reason: kept ? `KEPT: ${kept.hit.reason}` : 'a recovery quarantine of earlier KB copies' });
|
|
172
|
+
};
|
|
173
|
+
const scanSiblings = (dir) => {
|
|
174
|
+
for (const name of names(dir)) {
|
|
175
|
+
const full = path.join(dir, name);
|
|
176
|
+
if (seen.has(full) || full === roots.kbDir) continue;
|
|
177
|
+
const st = lstat(full);
|
|
178
|
+
if (!st) continue;
|
|
179
|
+
const before = items.length;
|
|
180
|
+
classifySibling(name, full, st);
|
|
181
|
+
if (items.length > before) seen.add(full);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const classifySibling = (name, full, st) => {
|
|
185
|
+
{
|
|
186
|
+
const prefix = kbCopyPrefixes(base).find((p) => name.startsWith(p));
|
|
187
|
+
if (prefix) {
|
|
188
|
+
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
|
+
const kind = Object.entries(TRANSACTION_KINDS).find(([k]) => prefix === `${base}.${k}-`)?.[1] || null;
|
|
190
|
+
if (kind) {
|
|
191
|
+
const state = transactionState(roots.kbParent, base, name.slice(prefix.length));
|
|
192
|
+
if (state && !TERMINAL.has(state)) {
|
|
193
|
+
add({ id: 'kb-copy', path: full, class: 'may-exist', kind: `transaction-${kind}`, action: 'keep', bytes: bytes(full),
|
|
194
|
+
reason: `owned by an in-progress update transaction (${state}); its recovery decides` });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
kbCopyDirs.push(full);
|
|
199
|
+
const hit = cachedKept(proofCache, full, roots.kbDir);
|
|
200
|
+
add({ id: 'kb-copy', path: full, class: 'must-not-exist', kind: 'kb-copy', action: kbBlocked || hit ? 'report' : 'remove-if-proven',
|
|
201
|
+
blocked: kbBlocked, bytes: bytes(full), keptUnique: Boolean(hit), fix: hit ? keptCopyFix(full, hit) : null,
|
|
202
|
+
reason: hit ? `KEPT: ${hit.reason}` : 'a second full copy of the knowledge base' });
|
|
203
|
+
} else if (name.startsWith(`.${base}.install-stage-`) && st.isDirectory() && !st.isSymbolicLink()) {
|
|
204
|
+
const stale = now - st.mtimeMs > policy.staleStageMs;
|
|
205
|
+
add({ id: 'install-stage', path: full, class: stale ? 'must-not-exist' : 'may-exist', kind: 'install-stage',
|
|
206
|
+
action: stale && !kbBlocked ? 'remove' : 'keep', bytes: bytes(full), reason: stale ? 'an install stage nobody activated' : 'an install may be extracting into it now' });
|
|
207
|
+
} else if (/^\.forge-.+-candidate-/.test(name) && st.isDirectory() && !st.isSymbolicLink()) {
|
|
208
|
+
const empty = !names(full).length;
|
|
209
|
+
const stale = empty || now - st.mtimeMs > policy.staleForgeCandidateMs;
|
|
210
|
+
add({ id: 'forge-candidate', path: full, class: stale ? 'must-not-exist' : 'may-exist', kind: 'forge-candidate',
|
|
211
|
+
action: stale && !kbBlocked ? 'remove' : 'keep', bytes: bytes(full), reason: stale ? 'a local rebuild candidate left by a killed run' : 'a local rebuild may be using it' });
|
|
212
|
+
} else if (/quarantine/i.test(name) && st.isDirectory() && !st.isSymbolicLink()) {
|
|
213
|
+
addQuarantine(full);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
scanSiblings(roots.kbParent);
|
|
218
|
+
if (roots.brainHome !== roots.kbParent) scanSiblings(roots.brainHome);
|
|
219
|
+
for (const parent of roots.brainHomeParents) for (const name of names(parent)) {
|
|
220
|
+
if (!/^ruvnet-brain.*quarantine/i.test(name)) continue;
|
|
221
|
+
const full = path.join(parent, name);
|
|
222
|
+
if (seen.has(full)) continue;
|
|
223
|
+
seen.add(full);
|
|
224
|
+
const st = lstat(full);
|
|
225
|
+
if (!st || st.isSymbolicLink() || !st.isDirectory()) continue;
|
|
226
|
+
addQuarantine(full);
|
|
227
|
+
}
|
|
228
|
+
// Interrupted `--move-brain` leftovers (dead pid only; none while a refresh lock may mean a move is running):
|
|
229
|
+
// REPORTED with the exact next step, never removed. The set-aside original is the ONLY copy if the Brain is gone.
|
|
230
|
+
if (!lockHeld) for (const lo of assessMoveLeftovers({ brainHome: roots.location.path, location: roots.location, isAlive: pidAlive })) {
|
|
231
|
+
add({ id: 'move-leftover', path: lo.path, class: 'unowned', kind: 'move-leftover', action: 'report', what: lo.what, onlyCopy: lo.onlyCopy,
|
|
232
|
+
bytes: lo.link ? 0 : bytes(lo.path), copies: !lo.link && (isKbTree(path.join(lo.path, 'kb')) || isKbTree(lo.path)) ? 1 : 0, fix: lo.fix, reason: lo.reason });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── brain home state ────────────────────────────────────────────────────────────────────────
|
|
236
|
+
const homeEntries = names(roots.brainHome);
|
|
237
|
+
for (const name of homeEntries) {
|
|
238
|
+
const full = path.join(roots.brainHome, name);
|
|
239
|
+
const st = lstat(full);
|
|
240
|
+
if (!st || full === roots.kbDir || seen.has(full)) continue;
|
|
241
|
+
const entryStart = items.length;
|
|
242
|
+
// Lifecycle evidence has its own retention policy (kb/lifecycle-evidence-retention.mjs); with the
|
|
243
|
+
// assessment injected it is ONE item below, otherwise it is measured here — never counted twice.
|
|
244
|
+
if (name === 'refresh-runs' || name === `.${base}.update-transactions`) {
|
|
245
|
+
if (!evidence) add({ id: 'evidence', path: full, class: 'may-exist', kind: 'lifecycle-evidence', action: 'keep', bytes: bytes(full), reason: 'refresh and transaction receipts (retention: lifecycle-evidence-v1)' });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (FOREIGN[name]) { add({ id: 'foreign', path: full, class: 'unowned', kind: name, action: 'report', bytes: bytes(full), reason: FOREIGN[name] }); continue; }
|
|
249
|
+
if (LOG_FILES.includes(name) && st.isFile()) {
|
|
250
|
+
const over = st.size > policy.logCapBytes;
|
|
251
|
+
add({ id: 'log', path: full, class: over ? 'must-not-exist' : 'may-exist', kind: 'log', action: over ? 'rotate' : 'keep', bytes: st.size,
|
|
252
|
+
reason: over ? `${(st.size / MIB).toFixed(1)} MiB over its ${policy.logCapBytes / MIB} MiB cap` : 'within its size cap' });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (LOG_FILES.some((log) => name === `${log}.1`) && st.isFile()) {
|
|
256
|
+
add({ id: 'log', path: full, class: 'may-exist', kind: 'log-rotation', action: 'keep', bytes: st.size,
|
|
257
|
+
reason: 'the one retained rotation of a capped log (replaced at the next rotation)' });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (TEXT_LOG.test(name) && st.isFile()) {
|
|
261
|
+
const over = st.size > policy.textLogCapBytes;
|
|
262
|
+
add({ id: 'log', path: full, class: over ? 'must-not-exist' : 'may-exist', kind: 'text-log', action: over ? 'truncate' : 'keep', bytes: st.size,
|
|
263
|
+
reason: over ? 'over its size cap' : 'within its size cap' });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// A hand-made backup (X.bak-20260808, .retired-, .dead-, bootstrap-backup-*): the Brain never writes
|
|
267
|
+
// these names (its own backups are ISO-stamped, `.bak-2026-10-01T…Z`, and live outside the brain home),
|
|
268
|
+
// so there is no proof it is ours to judge. REPORTED, never removed, never counted as Brain cruft.
|
|
269
|
+
if (RECOVERY_LEFTOVER.test(name) && !st.isSymbolicLink()) {
|
|
270
|
+
const holdsKb = st.isDirectory() && (isKbTree(full) || names(full).some((n) => isKbTree(path.join(full, n))));
|
|
271
|
+
add({ id: 'leftover', path: full, class: 'unowned', kind: 'recovery-leftover', action: 'report', bytes: bytes(full),
|
|
272
|
+
reason: holdsKb ? 'a hand-made recovery copy that holds a KB tree, not created by the Brain; remove it yourself once inspected'
|
|
273
|
+
: 'a hand-made backup, not created by the Brain; kept — remove it yourself if you no longer need it' });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (name === 'leases' && st.isDirectory()) {
|
|
277
|
+
for (const lease of names(full)) {
|
|
278
|
+
const lp = path.join(full, lease); const ls = lstat(lp);
|
|
279
|
+
if (ls && ls.isFile() && now - ls.mtimeMs > policy.staleLeaseMs && !pidAlive(readJson(lp)?.pid)) {
|
|
280
|
+
add({ id: 'lease', path: lp, class: 'must-not-exist', kind: 'stale-lease', action: 'remove', bytes: ls.size, reason: 'a spine lease older than 6h whose process is gone' });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (name === 'versions' && st.isDirectory() && !st.isSymbolicLink()) {
|
|
285
|
+
const active = readJson(path.join(roots.brainHome, 'active.json'));
|
|
286
|
+
const keep = new Set([active?.codeRoot, active?.previous?.codeRoot].filter(Boolean).map((p) => path.basename(p)));
|
|
287
|
+
for (const leaseName of names(path.join(roots.brainHome, 'leases'))) {
|
|
288
|
+
const lp = path.join(roots.brainHome, 'leases', leaseName); const ls = lstat(lp);
|
|
289
|
+
const lease = ls ? readJson(lp) : null;
|
|
290
|
+
if (lease?.version && (now - ls.mtimeMs < policy.staleLeaseMs || pidAlive(lease.pid))) keep.add(lease.version);
|
|
291
|
+
}
|
|
292
|
+
for (const v of names(full)) {
|
|
293
|
+
const vp = path.join(full, v);
|
|
294
|
+
const kept = keep.has(v);
|
|
295
|
+
add({ id: 'spine', path: vp, class: kept ? (path.basename(active?.codeRoot || '') === v ? 'must-exist' : 'may-exist') : 'must-not-exist',
|
|
296
|
+
kind: 'spine-generation', action: kept ? 'keep' : 'report', bytes: bytes(vp), fix: kept ? null : 'npx ruvnet-brain@latest --update',
|
|
297
|
+
reason: kept ? 'active, previous, or leased Stable Spine generation' : 'unreferenced spine generation (collected by the next update)' });
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (name === 'ruflo-cwd' && st.isDirectory() && !st.isSymbolicLink()) {
|
|
302
|
+
for (const project of names(full)) {
|
|
303
|
+
const pp = path.join(full, project); const ps = lstat(pp);
|
|
304
|
+
if (!ps || ps.isSymbolicLink() || !ps.isDirectory()) continue;
|
|
305
|
+
for (const entry of names(pp)) {
|
|
306
|
+
const ep = path.join(pp, entry); const es = lstat(ep);
|
|
307
|
+
if (!es || es.isSymbolicLink()) continue;
|
|
308
|
+
const debris = RUFLO_DEBRIS.has(entry) || (entry.startsWith('run-') && es.isDirectory() && now - es.mtimeMs > policy.staleRufloRunMs);
|
|
309
|
+
if (debris) add({ id: 'ruflo-scratch', path: ep, class: 'must-not-exist', kind: 'ruflo-scratch', action: 'remove', bytes: bytes(ep), reason: 'ruflo working-directory debris (never read back)' });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Debris items carved out of this directory above are counted once, as debris, not again as state.
|
|
314
|
+
const carved = items.slice(entryStart).reduce((n, i) => n + (i.bytes || 0), 0);
|
|
315
|
+
add({ id: 'state', path: full, class: 'may-exist', kind: name === 'models' ? 'models' : 'state', action: 'keep',
|
|
316
|
+
bytes: Math.max(0, bytes(full) - carved), reason: name === 'models' ? 'embedder and reranker model cache' : 'Brain state' });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── host plugin caches ──────────────────────────────────────────────────────────────────────
|
|
320
|
+
const registered = new Set(claudeRegistered(roots.claudeRegistry));
|
|
321
|
+
for (const v of names(roots.claudePluginCache)) {
|
|
322
|
+
const vp = physical(path.join(roots.claudePluginCache, v)); const vs = lstat(path.join(roots.claudePluginCache, v));
|
|
323
|
+
if (!vs || vs.isSymbolicLink() || !vs.isDirectory() || !pluginVersion(vp)) continue;
|
|
324
|
+
const leases = names(path.join(vp, '.in_use')).length;
|
|
325
|
+
const isRegistered = registered.has(vp);
|
|
326
|
+
add({ id: 'plugin', path: vp, host: 'claude', version: pluginVersion(vp), bytes: bytes(vp),
|
|
327
|
+
class: isRegistered ? 'must-exist' : leases ? 'may-exist' : 'must-not-exist', kind: 'claude-plugin-generation',
|
|
328
|
+
action: isRegistered ? 'keep' : 'collect', reason: isRegistered ? 'the registered Claude Code plugin'
|
|
329
|
+
: leases ? `kept while ${leases} session lease(s) may still run it` : 'an unregistered generation with no live session' });
|
|
330
|
+
}
|
|
331
|
+
const codexVersions = names(roots.codexPluginCache).filter((v) => pluginVersion(path.join(roots.codexPluginCache, v)));
|
|
332
|
+
const codexNewest = codexVersions.slice().sort(cmpVersion).at(-1);
|
|
333
|
+
for (const v of codexVersions) {
|
|
334
|
+
const vp = path.join(roots.codexPluginCache, v);
|
|
335
|
+
add({ id: 'plugin', path: vp, host: 'codex', version: pluginVersion(vp), bytes: bytes(vp), class: v === codexNewest ? 'must-exist' : 'must-not-exist',
|
|
336
|
+
kind: 'codex-plugin-generation', action: v === codexNewest ? 'keep' : 'report', fix: v === codexNewest ? null : 'codex plugin update ruvnet-brain@ruvnet-brain',
|
|
337
|
+
reason: v === codexNewest ? 'the current Codex plugin' : 'an older Codex plugin generation' });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── npx copies of the installer ─────────────────────────────────────────────────────────────
|
|
341
|
+
const self = physical(selfPath);
|
|
342
|
+
const npx = [];
|
|
343
|
+
for (const hash of names(roots.npxRoot)) {
|
|
344
|
+
const dir = path.join(roots.npxRoot, hash);
|
|
345
|
+
const manifest = readJson(path.join(dir, 'package.json'));
|
|
346
|
+
const specs = manifest?._npx?.packages || Object.keys(manifest?.dependencies || {});
|
|
347
|
+
if (!specs.length || !specs.every((s) => /^ruvnet-brain(?:@[^/\\]*)?$/.test(String(s)))) {
|
|
348
|
+
if (lstat(path.join(dir, 'node_modules', 'ruvnet-brain'))) add({ id: 'npx', path: dir, class: 'unowned', kind: 'npx-dev-copy', action: 'report', bytes: bytes(dir), reason: 'an npx copy of a local checkout (not a registry install)' });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const version = readJson(path.join(dir, 'node_modules', 'ruvnet-brain', 'package.json'))?.version;
|
|
352
|
+
if (!version) continue;
|
|
353
|
+
npx.push({ dir, version, mtime: lstat(dir)?.mtimeMs || 0, running: self.startsWith(`${physical(dir)}${path.sep}`) });
|
|
354
|
+
}
|
|
355
|
+
// Keep at most ONE copy: the running installer, else the newest copy that is not older than the current
|
|
356
|
+
// version (npm latest when known, else the installed runtime). Every older copy goes.
|
|
357
|
+
const current = npmLatest || readJson(path.join(roots.brainHome, 'active.json'))?.version
|
|
358
|
+
|| readJson(path.join(roots.kbDir, 'RUNTIME-IDENTITY.json'))?.brainVersion || null;
|
|
359
|
+
const newestVersion = npx.map((n) => n.version).sort(cmpVersion).at(-1);
|
|
360
|
+
const keepVersion = current && cmpVersion(newestVersion, current) < 0 ? null : newestVersion;
|
|
361
|
+
const keeper = npx.find((n) => n.running) || npx.filter((n) => n.version === keepVersion).sort((a, b) => b.mtime - a.mtime)[0];
|
|
362
|
+
for (const n of npx) {
|
|
363
|
+
const keep = n === keeper || n.running;
|
|
364
|
+
// An older copy fetched < 2h ago may be running in another terminal; none goes during an update/install (S6).
|
|
365
|
+
const recent = now - n.mtime <= policy.staleStageMs;
|
|
366
|
+
const hold = !keep && (kbBlocked || (recent ? 'fetched in the last 2h; it may be running now' : null));
|
|
367
|
+
add({ id: 'npx', path: n.dir, version: n.version, bytes: bytes(n.dir), class: keep ? 'may-exist' : 'must-not-exist', kind: 'npx-copy',
|
|
368
|
+
action: keep || hold ? (keep ? 'keep' : 'report') : 'remove', blocked: hold || null,
|
|
369
|
+
reason: keep ? (n.running ? 'the installer running now' : 'the newest installer copy') : `an older installer copy (${n.version})` });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── lifecycle evidence, judged by its own policy ────────────────────────────────────────────
|
|
373
|
+
if (evidence) {
|
|
374
|
+
add({ id: 'evidence', path: path.join(roots.brainHome, 'refresh-runs'), class: evidence.withinBudget ? 'may-exist' : 'must-not-exist',
|
|
375
|
+
kind: 'lifecycle-evidence', action: evidence.withinBudget ? 'keep' : 'prune', bytes: evidence.before?.bytes || 0,
|
|
376
|
+
reason: evidence.withinBudget ? 'within lifecycle-evidence-v1 retention' : `over retention (${evidence.unsafe?.length || 0} unsafe entr(ies))` });
|
|
377
|
+
}
|
|
378
|
+
return summarize({ roots, items, policy, kbCopyDirs, lockHeld });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function summarize({ roots, items, policy, kbCopyDirs, lockHeld }) {
|
|
382
|
+
const sum = (list) => list.reduce((n, i) => n + (i.bytes || 0), 0);
|
|
383
|
+
const owned = items.filter((i) => i.class !== 'unowned');
|
|
384
|
+
const kbBytes = sum(items.filter((i) => i.kind === 'live-kb'));
|
|
385
|
+
const modelBytes = sum(items.filter((i) => i.kind === 'models'));
|
|
386
|
+
const budgetBytes = kbBytes + modelBytes + policy.fixedAllowanceBytes;
|
|
387
|
+
const breakdown = {};
|
|
388
|
+
for (const i of owned) {
|
|
389
|
+
const group = i.kind === 'live-kb' ? 'knowledge base' : i.kind === 'models' ? 'models' : /plugin|spine/.test(i.kind) ? 'plugins'
|
|
390
|
+
: i.kind === 'lifecycle-evidence' && i.class !== 'must-not-exist' ? 'receipts'
|
|
391
|
+
: i.kind === 'npx-copy' ? 'installer' : /log/.test(i.kind) ? 'logs' : i.class === 'must-not-exist' ? 'cruft' : 'state';
|
|
392
|
+
breakdown[group] = (breakdown[group] || 0) + (i.bytes || 0);
|
|
393
|
+
}
|
|
394
|
+
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;
|
|
399
|
+
const moved = items.filter((i) => i.kind === 'move-leftover');
|
|
400
|
+
return { schemaVersion: 1, kind: 'ruvnet-brain-footprint', roots, items, policy, lockHeld, kbCopyFix,
|
|
401
|
+
liveKb: items.find((i) => i.kind === 'live-kb'),
|
|
402
|
+
kbCopies: (items.find((i) => i.kind === 'live-kb')?.present ? 1 : 0)
|
|
403
|
+
+ items.filter((i) => i.kind === 'kb-copy' || /^transaction-/.test(i.kind)).length
|
|
404
|
+
+ items.filter((i) => i.kind === 'quarantine').reduce((n, i) => n + (i.copies || 0), 0),
|
|
405
|
+
kbCopyDirs, cruft, totalBytes: sum(owned), budgetBytes, withinBudget: sum(owned) <= budgetBytes, breakdown,
|
|
406
|
+
moveLeftovers: { items: moved.length, copies: moved.reduce((n, i) => n + (i.copies || 0), 0), bytes: sum(moved) },
|
|
407
|
+
unowned: items.filter((i) => i.class === 'unowned') };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Enforce the classification. `apply:false` reports what would happen. Every removal is returned by name;
|
|
412
|
+
* every refusal is returned with its reason. `collectPluginGenerations` is the caller's lease-aware
|
|
413
|
+
* collector (bin/install.mjs prunePluginGenerations); `pruneEvidence` is kb's pruneLifecycleEvidence.
|
|
414
|
+
*/
|
|
415
|
+
export function sweepFootprint({ apply = false, collectPluginGenerations = null, pruneEvidence = null,
|
|
416
|
+
proveCopy = kbCopyProof, beforeRemove = null, ...options } = {}) {
|
|
417
|
+
const before = inventoryFootprint(options);
|
|
418
|
+
const { roots } = before;
|
|
419
|
+
const owned = [...new Set([roots.kbParent, roots.brainHome, ...roots.brainHomeParents, roots.npxRoot].map(physical))];
|
|
420
|
+
const prove = (copyDir) => {
|
|
421
|
+
const proof = proveCopy({ copyDir, liveDir: roots.kbDir });
|
|
422
|
+
if (apply && !proof.disposable && isKbTree(roots.kbDir)) rememberKept(roots.brainHome, copyDir, roots.kbDir, proof); // a dry run writes nothing
|
|
423
|
+
return proof;
|
|
424
|
+
};
|
|
425
|
+
const removed = []; const kept = []; const rotated = []; const errors = [];
|
|
426
|
+
if (roots.dangling) {
|
|
427
|
+
return { schemaVersion: 1, kind: 'ruvnet-brain-footprint-sweep', apply, removed, rotated, errors, plugins: null, evidence: null,
|
|
428
|
+
kept: [{ path: roots.location.path, kind: 'unmounted-brain', reason: before.items[0].reason }], freedBytes: 0, before, after: before };
|
|
429
|
+
}
|
|
430
|
+
const record = (item, freed, note) => removed.push({ path: item.path, kind: item.kind, bytes: freed ?? item.bytes, reason: note || item.reason });
|
|
431
|
+
for (const item of before.items) {
|
|
432
|
+
if (!['remove', 'remove-if-proven', 'rotate', 'truncate'].includes(item.action)) {
|
|
433
|
+
if (item.class === 'must-not-exist' || item.class === 'unowned') kept.push({ path: item.path, kind: item.kind, reason: item.blocked || item.reason, fix: item.fix || null });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
if (item.action === 'rotate') { if (apply) rotate(item.path); rotated.push({ path: item.path, bytes: item.bytes }); continue; }
|
|
438
|
+
if (item.action === 'truncate') { if (apply) truncateToTail(item.path, before.policy.textLogCapBytes / 2); rotated.push({ path: item.path, bytes: item.bytes }); continue; }
|
|
439
|
+
if (apply) beforeRemove?.(item);
|
|
440
|
+
if (item.action === 'remove') { record(item, apply ? removeWithin(item.path, item.realParent, owned) : item.bytes); continue; }
|
|
441
|
+
// remove-if-proven: a KB copy, or a quarantine holding KB copies.
|
|
442
|
+
if (item.kind === 'kb-copy') {
|
|
443
|
+
const proof = prove(item.path);
|
|
444
|
+
if (!proof.disposable) { kept.push({ path: item.path, kind: item.kind, reason: `KEPT: ${proof.reason}`, unique: proof.unique }); continue; }
|
|
445
|
+
record(item, apply ? removeWithin(item.path, item.realParent, owned) : item.bytes, proof.reason);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
let uniqueLeft = false;
|
|
449
|
+
const quarantineReal = physical(item.path);
|
|
450
|
+
for (const child of names(item.path)) {
|
|
451
|
+
const childPath = path.join(item.path, child);
|
|
452
|
+
const proof = isKbTree(childPath) ? prove(childPath)
|
|
453
|
+
: { disposable: false, unique: [], reason: 'not a KB copy; not ours to judge' };
|
|
454
|
+
if (!proof.disposable) { uniqueLeft = true; kept.push({ path: childPath, kind: 'quarantined-copy', reason: `KEPT: ${proof.reason}`, unique: proof.unique }); continue; }
|
|
455
|
+
record({ path: childPath, kind: 'quarantined-copy', bytes: treeBytes(childPath) }, apply ? removeWithin(childPath, quarantineReal, owned) : undefined, proof.reason);
|
|
456
|
+
}
|
|
457
|
+
if (!uniqueLeft && apply) removeWithin(item.path, item.realParent, owned);
|
|
458
|
+
} catch (error) { errors.push({ path: item.path, reason: error.message }); kept.push({ path: item.path, kind: item.kind, reason: `could not act: ${error.message}` }); }
|
|
459
|
+
}
|
|
460
|
+
let plugins = null;
|
|
461
|
+
if (collectPluginGenerations && before.items.some((i) => i.kind === 'claude-plugin-generation' && i.class !== 'must-exist')) {
|
|
462
|
+
try { plugins = collectPluginGenerations({ registryPath: roots.claudeRegistry, apply }); }
|
|
463
|
+
catch (error) { errors.push({ path: roots.claudePluginCache, reason: error.message }); }
|
|
464
|
+
}
|
|
465
|
+
let evidence = null;
|
|
466
|
+
if (pruneEvidence && apply) {
|
|
467
|
+
try { evidence = pruneEvidence({ brainHome: roots.brainHome, kbDir: roots.kbDir }); }
|
|
468
|
+
catch (error) { errors.push({ path: roots.brainHome, reason: error.message }); }
|
|
469
|
+
}
|
|
470
|
+
const after = apply ? inventoryFootprint({ ...options, evidence: evidence ? { withinBudget: evidence.withinBudget, before: evidence.after, unsafe: evidence.unsafe } : options.evidence }) : before;
|
|
471
|
+
return { schemaVersion: 1, kind: 'ruvnet-brain-footprint-sweep', apply, removed, kept, rotated, errors, plugins, evidence,
|
|
472
|
+
freedBytes: removed.reduce((n, r) => n + (r.bytes || 0), 0), before, after };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The KB's own sanctioned readers, loaded from the live KB (where the updater keeps them). */
|
|
476
|
+
export async function loadKbReaders(kbDir) {
|
|
477
|
+
try {
|
|
478
|
+
const evidence = await import(pathToFileURL(path.join(kbDir, 'lifecycle-evidence-retention.mjs')).href);
|
|
479
|
+
return { assessLifecycleEvidence: evidence.assessLifecycleEvidence, pruneLifecycleEvidence: evidence.pruneLifecycleEvidence };
|
|
480
|
+
} catch { return {}; }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// CLI: the detached SessionStart sweep. `node brain-footprint.mjs --sweep [--apply] [--json]`.
|
|
484
|
+
if (process.argv[1] && pathToFileURL(physical(process.argv[1])).href === pathToFileURL(physical(fileURLToPath(import.meta.url))).href) {
|
|
485
|
+
const argv = process.argv.slice(2);
|
|
486
|
+
const roots = footprintRoots();
|
|
487
|
+
const readers = await loadKbReaders(roots.kbDir);
|
|
488
|
+
const evidence = readers.assessLifecycleEvidence ? (() => { try { return readers.assessLifecycleEvidence({ brainHome: roots.brainHome, kbDir: roots.kbDir }); } catch { return null; } })() : null;
|
|
489
|
+
const result = sweepFootprint({ apply: argv.includes('--apply'), evidence, pruneEvidence: readers.pruneLifecycleEvidence || null });
|
|
490
|
+
const { before: _b, after, ...rest } = result;
|
|
491
|
+
const out = { ...rest, after: { kbCopies: after.kbCopies, totalBytes: after.totalBytes, budgetBytes: after.budgetBytes, cruft: after.cruft.length } };
|
|
492
|
+
process.stdout.write(argv.includes('--json') ? `${JSON.stringify(out)}\n`
|
|
493
|
+
: `footprint sweep${result.apply ? '' : ' (dry run)'}: removed ${result.removed.length} (${(result.freedBytes / MIB).toFixed(1)} MiB), rotated ${result.rotated.length}, kept ${result.kept.length}\n`);
|
|
494
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// brain-location.mjs — where the Brain really lives, and whether that disk is there.
|
|
2
|
+
//
|
|
3
|
+
// The supported way to put the Brain on another disk is a symlink at the default path:
|
|
4
|
+
// ~/.cache/ruvnet-brain -> /Volumes/<disk>/ruvnet-brain (made by `npx ruvnet-brain --move-brain <dir>`).
|
|
5
|
+
// Every reader keeps using the default path and follows the link; nothing else has to know. The one new
|
|
6
|
+
// failure is the disk being absent (unplugged, unmounted). Then the link dangles, and every caller must
|
|
7
|
+
// say so in ONE line — never an ENOENT, never "reinstall", never a fresh brain re-created in ~/.cache.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const defaultBrainHome = (home = os.homedir()) => path.join(home, '.cache', 'ruvnet-brain');
|
|
13
|
+
|
|
14
|
+
/** The volume a path lives on, as a person would name it (the mount point). */
|
|
15
|
+
export function volumeOf(target) {
|
|
16
|
+
const p = path.resolve(target);
|
|
17
|
+
const mac = p.match(/^\/Volumes\/[^/]+/);
|
|
18
|
+
if (mac) return mac[0];
|
|
19
|
+
const media = p.match(/^\/(?:run\/)?media\/[^/]+\/[^/]+/) || p.match(/^\/mnt\/[^/]+/);
|
|
20
|
+
if (media) return media[0];
|
|
21
|
+
const drive = p.match(/^[A-Za-z]:\\/);
|
|
22
|
+
if (drive) return drive[0];
|
|
23
|
+
let missing = p; // the highest missing ancestor is what is "not there"
|
|
24
|
+
while (path.dirname(missing) !== missing && !fs.existsSync(path.dirname(missing))) missing = path.dirname(missing);
|
|
25
|
+
return missing;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @returns {{ path: string, state: 'absent'|'local'|'linked'|'unmounted', real?: string, target?: string,
|
|
30
|
+
* volume?: string, message?: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function brainLocation({ home = os.homedir(), brainHome = defaultBrainHome(home) } = {}) {
|
|
33
|
+
let stat;
|
|
34
|
+
try { stat = fs.lstatSync(brainHome); } catch { return { path: brainHome, state: 'absent' }; }
|
|
35
|
+
if (!stat.isSymbolicLink()) return { path: brainHome, state: 'local', real: brainHome };
|
|
36
|
+
const target = path.resolve(path.dirname(brainHome), fs.readlinkSync(brainHome));
|
|
37
|
+
if (fs.existsSync(target)) return { path: brainHome, state: 'linked', real: fs.realpathSync(brainHome), target };
|
|
38
|
+
const volume = volumeOf(target);
|
|
39
|
+
return { path: brainHome, state: 'unmounted', target, volume,
|
|
40
|
+
message: `RuvNet Brain's disk ${volume} is not mounted (${brainHome} -> ${target}). Mount it, then retry; nothing was changed. Do NOT reinstall.` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One clear line when the Brain's disk is missing, '' otherwise. For hooks and readers. */
|
|
44
|
+
export function unmountedNotice(options) {
|
|
45
|
+
const where = brainLocation(options);
|
|
46
|
+
return where.state === 'unmounted' ? where.message : '';
|
|
47
|
+
}
|
|
@@ -458,6 +458,9 @@ export const CAPABILITIES = [
|
|
|
458
458
|
if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows — distillation state could not be established');
|
|
459
459
|
|
|
460
460
|
if (d.total === 0) return row(STATE.ABSENT, 'the memory store is empty, so there is nothing to distill yet');
|
|
461
|
+
// No countable pattern table is an UNMEASURED distillation, not "null patterns" (RNBC QA
|
|
462
|
+
// 2026-10-01: the card printed "only null patterns from 12 memories" and called it ON).
|
|
463
|
+
if (typeof d.patterns !== 'number') return row(STATE.UNKNOWN, `${d.total} memories stored, but this store has no pattern table that could be counted — whether distillation has run is not established`);
|
|
461
464
|
if (d.learns) return row(STATE.ON, `${d.patterns} reusable patterns distilled from ${d.real} memories (${(d.cover * 100).toFixed(1)}% embedded)`);
|
|
462
465
|
if (d.patterns === 0) return row(STATE.OFF, `${d.total} memories stored and ${(d.cover * 100).toFixed(1)}% embedded, but 0 have been distilled into patterns — the store records and forgets`);
|
|
463
466
|
// "BARELY RUN" IS NOT "NOT RUNNING". This returned STATE.OFF while its own sentence says the
|
|
@@ -700,7 +703,9 @@ export const CAPABILITIES = [
|
|
|
700
703
|
if (!Array.isArray(lessons) || lessons.length === 0) return row(STATE.ABSENT, 'the lesson store exists but holds no lessons yet');
|
|
701
704
|
|
|
702
705
|
const S = helpers.lessonStore.STATUS || {};
|
|
703
|
-
|
|
706
|
+
// In force = what the gate (lessonsFor) can deliver: ratified AND not switched off. A demoted
|
|
707
|
+
// ratified lesson was counted here as able to "affect what your AI does" (RNBC QA 2026-10-01).
|
|
708
|
+
const inForce = lessons.filter((l) => (l?.status === S.RATIFIED || l?.status === S.ACTIVE) && !l?.demoted).length;
|
|
704
709
|
// AWAITING YOU means RATIFIABLE BY YOU (issue #125). This counted every CANDIDATE, including
|
|
705
710
|
// the seeded imported-owner lessons — which `ratify()` refuses by design and `pending()`
|
|
706
711
|
// excludes, so `lesson-ratify --list` correctly reported "0 awaiting your decision" while this
|
|
@@ -874,6 +879,10 @@ export const CAPABILITIES = [
|
|
|
874
879
|
scope: SCOPE.MACHINE,
|
|
875
880
|
// Loading a launchd job is machine mutation with no single verified command; global Rule 10.
|
|
876
881
|
turnOn: null,
|
|
882
|
+
// …but there IS a verified control: the console's Settings switch, which saveConfig() hands to
|
|
883
|
+
// nightly-controller.applyNightlyChoice() with a recorded undo. RNBC QA 2026-10-01: the row said
|
|
884
|
+
// "No verified one-line command exists" two cards above that very switch.
|
|
885
|
+
setting: 'nightly',
|
|
877
886
|
detect() {
|
|
878
887
|
const status = nightlyStatus();
|
|
879
888
|
if (status.state === 'on') return row(STATE.ON, status.evidence);
|
|
@@ -911,6 +920,7 @@ export function auditAll({ project = process.cwd() } = {}) {
|
|
|
911
920
|
whatItBuysYou: c.whatItBuysYou,
|
|
912
921
|
scope: c.scope,
|
|
913
922
|
turnOn: c.turnOn,
|
|
923
|
+
...(c.setting ? { setting: c.setting } : {}),
|
|
914
924
|
state,
|
|
915
925
|
evidence,
|
|
916
926
|
// A digest binds proactive routing to this audit invocation's observed bytes. Synthetic test
|