ruvnet-brain 4.3.8 → 4.3.10
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 +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +11 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +472 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-two-run-proof.mjs +415 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +53 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +108 -8
- package/scripts/publication-receipt.mjs +173 -66
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-publication-handoff.mjs +101 -0
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/release.mjs +10 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- package/scripts/wired-check.mjs +31 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Immutable scheduler target. Platform adapters execute this exact file with Node; this runner
|
|
3
|
+
// alone owns the dynamic package-resolution command used to converge corpus and host surfaces.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const runnerPath = path.resolve(fileURLToPath(import.meta.url));
|
|
12
|
+
const registrationPath = String((process.argv[2] === '--registration' ? process.argv[3] : null) || process.env.RUVNET_NIGHTLY_REGISTRATION
|
|
13
|
+
|| path.join(path.dirname(runnerPath), 'registration.json'));
|
|
14
|
+
let registration;
|
|
15
|
+
try {
|
|
16
|
+
if (!path.isAbsolute(registrationPath)) throw new Error('registration path is missing or not absolute');
|
|
17
|
+
registration = JSON.parse(fs.readFileSync(registrationPath, 'utf8'));
|
|
18
|
+
const runnerSha256 = crypto.createHash('sha256').update(fs.readFileSync(runnerPath)).digest('hex');
|
|
19
|
+
if (registration.schemaVersion !== 2 || registration.kind !== 'ruvnet-brain-nightly-scheduler'
|
|
20
|
+
|| (registration.identity !== 'com.ruvnet.brain-update'
|
|
21
|
+
&& !/^com\.ruvnet\.brain-update\.proof-[A-Za-z0-9._-]+$/.test(registration.identity || ''))
|
|
22
|
+
|| path.resolve(registration.runnerPath || '') !== runnerPath
|
|
23
|
+
|| registration.runnerSha256 !== runnerSha256
|
|
24
|
+
|| path.resolve(registration.nodePath || '') !== path.resolve(process.execPath)
|
|
25
|
+
|| !Array.isArray(registration.argv) || registration.argv.length !== 0
|
|
26
|
+
|| !registration.packageTarget || typeof registration.packageTarget.spec !== 'string') {
|
|
27
|
+
throw new Error('registration does not bind this exact runner and Node executable');
|
|
28
|
+
}
|
|
29
|
+
if (registration.packageTarget.sha256 !== null) {
|
|
30
|
+
if (!path.isAbsolute(registration.packageTarget.spec)
|
|
31
|
+
|| !/^[a-f0-9]{64}$/.test(registration.packageTarget.sha256)
|
|
32
|
+
|| crypto.createHash('sha256').update(fs.readFileSync(registration.packageTarget.spec)).digest('hex')
|
|
33
|
+
!== registration.packageTarget.sha256) throw new Error('registered package target digest mismatch');
|
|
34
|
+
} else if (registration.packageTarget.spec !== 'ruvnet-brain@latest') {
|
|
35
|
+
throw new Error('unhashed package target is not the production latest channel');
|
|
36
|
+
}
|
|
37
|
+
const proof = /^com\.ruvnet\.brain-update\.proof-[A-Za-z0-9._-]+$/.test(registration.identity);
|
|
38
|
+
if (proof) {
|
|
39
|
+
const bundleStat = fs.lstatSync(registration.bundleTarget?.spec || '');
|
|
40
|
+
if (!registration.bundleTarget || !path.isAbsolute(registration.bundleTarget.spec)
|
|
41
|
+
|| !registration.bundleTarget.spec.endsWith('.zip')
|
|
42
|
+
|| !bundleStat.isFile() || bundleStat.isSymbolicLink()
|
|
43
|
+
|| !/^[a-f0-9]{64}$/.test(registration.bundleTarget.sha256)
|
|
44
|
+
|| crypto.createHash('sha256').update(fs.readFileSync(registration.bundleTarget.spec)).digest('hex')
|
|
45
|
+
!== registration.bundleTarget.sha256) throw new Error('registered bundle target digest mismatch');
|
|
46
|
+
} else if (registration.bundleTarget !== null && registration.bundleTarget !== undefined) {
|
|
47
|
+
throw new Error('production nightly registration cannot pin a local bundle');
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(`RuvNet Brain nightly refresh registration is invalid: ${error.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const allowedEnvironment = ['PATH', 'HOME', 'USERPROFILE', 'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB', 'npm_config_cache', 'NO_COLOR', 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP'];
|
|
55
|
+
if (registration.environment !== undefined && (!registration.environment || Array.isArray(registration.environment)
|
|
56
|
+
|| Object.entries(registration.environment).some(([key, value]) => !allowedEnvironment.includes(key) || typeof value !== 'string'))) {
|
|
57
|
+
console.error('Invalid registered nightly environment'); process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
Object.assign(process.env, registration.environment || {});
|
|
60
|
+
|
|
61
|
+
const npxName = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
62
|
+
const adjacent = path.join(path.dirname(process.execPath), npxName);
|
|
63
|
+
const npx = fs.existsSync(adjacent) ? adjacent : npxName;
|
|
64
|
+
const argv = ['--yes', registration.packageTarget.spec, '--update', '--no-nightly-prompt'];
|
|
65
|
+
let executable = npx;
|
|
66
|
+
let launchArgs = argv;
|
|
67
|
+
if (process.platform === 'win32') {
|
|
68
|
+
// This content-addressed runner is copied alone: keep its dependency closure standalone.
|
|
69
|
+
// Like rufloInvocation, execute the managed package's declared JS entry, never a .cmd shell.
|
|
70
|
+
try {
|
|
71
|
+
const shim = fs.existsSync(adjacent) ? adjacent : String(process.env.PATH || '')
|
|
72
|
+
.split(path.delimiter).filter(Boolean).map(dir => path.join(dir, npxName))
|
|
73
|
+
.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
|
74
|
+
if (!shim) throw new Error('managed npm npx shim is missing');
|
|
75
|
+
const packageRoot = fs.realpathSync(path.join(path.dirname(fs.realpathSync(shim)), 'node_modules', 'npm'));
|
|
76
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
77
|
+
const declared = manifest.bin?.npx;
|
|
78
|
+
if (manifest.name !== 'npm' || typeof declared !== 'string' || !declared
|
|
79
|
+
|| path.isAbsolute(declared) || path.win32.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) {
|
|
80
|
+
throw new Error('managed npm package has no safe declared npx entry');
|
|
81
|
+
}
|
|
82
|
+
const entry = fs.realpathSync(path.resolve(packageRoot, declared));
|
|
83
|
+
const relative = path.relative(packageRoot, entry);
|
|
84
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !fs.statSync(entry).isFile()) {
|
|
85
|
+
throw new Error('managed npm npx entry escapes its package');
|
|
86
|
+
}
|
|
87
|
+
executable = process.execPath;
|
|
88
|
+
launchArgs = [entry, ...argv];
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`RuvNet Brain nightly refresh could not resolve managed npm: ${error.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const allowed = ['PATH', 'HOME', 'USERPROFILE', 'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB',
|
|
95
|
+
'npm_config_cache', 'NO_COLOR', 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP'];
|
|
96
|
+
const childEnv = Object.fromEntries(allowed.filter((key) => process.env[key] !== undefined)
|
|
97
|
+
.map((key) => [key, process.env[key]]));
|
|
98
|
+
const result = spawnSync(executable, launchArgs, {
|
|
99
|
+
stdio: 'inherit',
|
|
100
|
+
shell: false,
|
|
101
|
+
env: { ...childEnv, RUVNET_NIGHTLY: '1',
|
|
102
|
+
RUVNET_NIGHTLY_REGISTRATION: registrationPath,
|
|
103
|
+
RUVNET_NIGHTLY_IDENTITY: registration.identity,
|
|
104
|
+
RUVNET_NIGHTLY_NODE_PATH: registration.nodePath,
|
|
105
|
+
RUVNET_NIGHTLY_RUNNER_PATH: registration.runnerPath,
|
|
106
|
+
RUVNET_NIGHTLY_RUNNER_SHA256: registration.runnerSha256,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (result.error) {
|
|
111
|
+
console.error(`RuvNet Brain nightly refresh could not start: ${result.error.message}`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
} else {
|
|
114
|
+
process.exitCode = result.status === null ? 1 : result.status;
|
|
115
|
+
}
|
package/kb/brain-profile.mjs
CHANGED
|
@@ -42,34 +42,92 @@ function familyEntries(dir, store) {
|
|
|
42
42
|
entry === `${store}-primer.md` || entry === store || entry.startsWith(`${store}.`));
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// SOURCE is the existing update-ownership policy; PRIVATE-STORES is the existing
|
|
46
|
+
// case-insensitive publication fence. Neither discovery nor a filename prefix grants ownership.
|
|
47
|
+
function profileOwnership(dir) {
|
|
48
|
+
const read = (name) => {
|
|
49
|
+
const file = path.join(dir, name);
|
|
50
|
+
if (!fs.lstatSync(file).isFile()) throw new Error(`profile ownership requires regular ${name}`);
|
|
51
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
|
+
};
|
|
53
|
+
const source = read('SOURCE.json');
|
|
54
|
+
const fence = read('PRIVATE-STORES.json');
|
|
55
|
+
if (!Array.isArray(fence.privateStores) || fence.privateStores.some((s) => typeof s !== 'string' || !s)) {
|
|
56
|
+
throw new Error('invalid PRIVATE-STORES ownership policy');
|
|
57
|
+
}
|
|
58
|
+
const entries = Array.isArray(source.stores)
|
|
59
|
+
? source.stores.map((s) => [s?.kbName, s])
|
|
60
|
+
: source.stores && typeof source.stores === 'object'
|
|
61
|
+
? Object.entries(source.stores) : null;
|
|
62
|
+
if (!entries) throw new Error('invalid SOURCE ownership policy');
|
|
63
|
+
const privateNames = new Set(fence.privateStores.map((s) => s.toLowerCase()));
|
|
64
|
+
const managed = new Set();
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const [name, value] of entries) {
|
|
67
|
+
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/i.test(name)
|
|
68
|
+
|| !value || typeof value !== 'object' || Array.isArray(value)
|
|
69
|
+
|| (value.kbName != null && value.kbName !== name)
|
|
70
|
+
|| (value.updateManaged != null && typeof value.updateManaged !== 'boolean')
|
|
71
|
+
|| seen.has(name.toLowerCase())) throw new Error('invalid SOURCE store ownership');
|
|
72
|
+
seen.add(name.toLowerCase());
|
|
73
|
+
if (value.updateManaged !== false && !privateNames.has(name.toLowerCase())) managed.add(name);
|
|
74
|
+
}
|
|
75
|
+
return { managed, read };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Only published artifact spellings, never arbitrary store.* siblings or directories.
|
|
79
|
+
function managedEntries(dir, store) {
|
|
80
|
+
const names = new Set([`${store}-primer.md`, ...[
|
|
81
|
+
'.rvf', '.big.rvf', '.idmap.json', '.rvf.idmap.json', '.rvf.embed.json',
|
|
82
|
+
'.big.rvf.idmap.json', '.big.rvf.embed.json', '.passages.jsonl',
|
|
83
|
+
'.big.passages.jsonl', '.meta.json', '.big.meta.json', '.symbols.json',
|
|
84
|
+
].map((suffix) => `${store}${suffix}`)]);
|
|
85
|
+
return fs.readdirSync(dir).filter((entry) => names.has(entry));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireRegular(dir, entries) {
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (!fs.lstatSync(path.join(dir, entry)).isFile()) {
|
|
91
|
+
throw new Error(`profile requires regular artifact, not directory or symbolic link: ${entry}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cardParts(text) {
|
|
97
|
+
return text.split(/(?=^## )/m);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cardName(part) {
|
|
101
|
+
return part.match(/^## ([^\n]+)\s*$/m)?.[1]?.trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function filterCapabilityCards(dir, managed, { validateOnly = false } = {}) {
|
|
46
105
|
const file = path.join(dir, 'capability-cards.md');
|
|
47
106
|
if (!fs.existsSync(file)) return;
|
|
48
107
|
const backup = path.join(dir, 'capability-cards.complete.md');
|
|
49
108
|
const current = fs.readFileSync(file, 'utf8');
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (firstCard < 0 || start < 0) throw new Error('could not isolate the RuVector capability card');
|
|
56
|
-
const rest = full.slice(start);
|
|
57
|
-
const next = rest.slice(1).search(/^## /m);
|
|
58
|
-
const card = next < 0 ? rest : rest.slice(0, next + 1);
|
|
59
|
-
fs.writeFileSync(file, `${full.slice(0, firstCard)}${card.trimEnd()}\n`);
|
|
109
|
+
if (!/^## ruvector\s*$/m.test(current)) throw new Error('could not isolate the RuVector capability card');
|
|
110
|
+
const keep = (part) => !managed.has(cardName(part)) || cardName(part) === PROFILE_RUVECTOR;
|
|
111
|
+
if (validateOnly) return;
|
|
112
|
+
if (!fs.existsSync(backup) || cardParts(current).some((part) => !keep(part))) fs.copyFileSync(file, backup);
|
|
113
|
+
fs.writeFileSync(file, cardParts(current).filter(keep).join(''));
|
|
60
114
|
}
|
|
61
115
|
|
|
62
116
|
function restoreCapabilityCards(dir) {
|
|
63
117
|
const backup = path.join(dir, 'capability-cards.complete.md');
|
|
64
|
-
if (fs.existsSync(backup))
|
|
118
|
+
if (fs.existsSync(backup)) {
|
|
119
|
+
const { managed } = profileOwnership(dir);
|
|
120
|
+
requireRegular(dir, ['capability-cards.complete.md', 'capability-cards.md']);
|
|
121
|
+
mergeCapabilityCards(dir, fs.readFileSync(backup, 'utf8'), managed);
|
|
122
|
+
}
|
|
65
123
|
}
|
|
66
124
|
|
|
67
|
-
function
|
|
68
|
-
const file = path.join(dir, '
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
fs.writeFileSync(file,
|
|
125
|
+
function mergeCapabilityCards(dir, incoming, managed) {
|
|
126
|
+
const file = path.join(dir, 'capability-cards.md');
|
|
127
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
128
|
+
const preserved = cardParts(current).filter((part) => cardName(part) && !managed.has(cardName(part)));
|
|
129
|
+
const next = cardParts(incoming).filter((part) => !cardName(part) || managed.has(cardName(part)));
|
|
130
|
+
fs.writeFileSync(file, [...next, ...preserved].join(''));
|
|
73
131
|
}
|
|
74
132
|
|
|
75
133
|
export function applyBrainProfile(dir, profile) {
|
|
@@ -80,21 +138,33 @@ export function applyBrainProfile(dir, profile) {
|
|
|
80
138
|
}
|
|
81
139
|
|
|
82
140
|
const allowed = new Set([PROFILE_RUVECTOR]);
|
|
141
|
+
const ownership = profileOwnership(dir);
|
|
142
|
+
const ledger = ownership.read('RVF-GENERATIONS.json');
|
|
143
|
+
if (!ledger.stores || typeof ledger.stores !== 'object' || Array.isArray(ledger.stores)) {
|
|
144
|
+
throw new Error('invalid RVF-GENERATIONS ownership metadata');
|
|
145
|
+
}
|
|
146
|
+
const plan = discoverStoreFamilies(dir)
|
|
147
|
+
.filter((store) => !allowed.has(store) && ownership.managed.has(store))
|
|
148
|
+
.map((store) => ({ store, entries: managedEntries(dir, store) }));
|
|
149
|
+
for (const { entries } of plan) requireRegular(dir, entries);
|
|
150
|
+
requireRegular(dir, ['capability-cards.md', 'capability-cards.complete.md']
|
|
151
|
+
.filter((name) => fs.readdirSync(dir).includes(name)));
|
|
152
|
+
filterCapabilityCards(dir, ownership.managed, { validateOnly: true });
|
|
83
153
|
const removed = [];
|
|
84
154
|
const removedStores = [];
|
|
85
155
|
let bytesFreed = 0;
|
|
86
|
-
for (const store of
|
|
87
|
-
if (allowed.has(store)) continue;
|
|
156
|
+
for (const { store, entries } of plan) {
|
|
88
157
|
removedStores.push(store);
|
|
89
|
-
for (const entry of
|
|
158
|
+
for (const entry of entries) {
|
|
90
159
|
const target = path.join(dir, entry);
|
|
91
160
|
try { bytesFreed += fs.statSync(target).size; } catch { /* report only measured bytes */ }
|
|
92
|
-
fs.
|
|
161
|
+
fs.unlinkSync(target);
|
|
93
162
|
removed.push(entry);
|
|
94
163
|
}
|
|
95
164
|
}
|
|
96
|
-
filterCapabilityCards(dir);
|
|
97
|
-
|
|
165
|
+
filterCapabilityCards(dir, ownership.managed);
|
|
166
|
+
for (const store of removedStores) delete ledger.stores[store];
|
|
167
|
+
fs.writeFileSync(path.join(dir, 'RVF-GENERATIONS.json'), `${JSON.stringify(ledger, null, 2)}\n`);
|
|
98
168
|
return { profile, removed, removedStores, bytesFreed, stores: discoverStoreFamilies(dir) };
|
|
99
169
|
}
|
|
100
170
|
|
|
@@ -102,23 +172,39 @@ export function restoreCompleteProfile(targetDir, sourceDir) {
|
|
|
102
172
|
if (!discoverStoreFamilies(sourceDir).includes(PROFILE_RUVECTOR)) {
|
|
103
173
|
throw new Error(`complete bundle source is unavailable at ${sourceDir}`);
|
|
104
174
|
}
|
|
105
|
-
|
|
106
|
-
const
|
|
175
|
+
const sourceOwnership = profileOwnership(sourceDir);
|
|
176
|
+
const targetOwnership = profileOwnership(targetDir);
|
|
177
|
+
const sourceLedger = sourceOwnership.read('RVF-GENERATIONS.json');
|
|
178
|
+
const targetLedger = targetOwnership.read('RVF-GENERATIONS.json');
|
|
179
|
+
for (const ledger of [sourceLedger, targetLedger]) {
|
|
180
|
+
if (!ledger.stores || typeof ledger.stores !== 'object' || Array.isArray(ledger.stores)) {
|
|
181
|
+
throw new Error('invalid RVF-GENERATIONS ownership metadata');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const stores = discoverStoreFamilies(sourceDir).filter((store) => sourceOwnership.managed.has(store));
|
|
107
185
|
for (const store of stores) {
|
|
108
|
-
|
|
186
|
+
if (!targetOwnership.managed.has(store)) throw new Error(`private or unknown restoration ownership: ${store}`);
|
|
187
|
+
requireRegular(sourceDir, managedEntries(sourceDir, store));
|
|
188
|
+
requireRegular(targetDir, managedEntries(targetDir, store));
|
|
189
|
+
}
|
|
190
|
+
requireRegular(sourceDir, ['capability-cards.md']);
|
|
191
|
+
requireRegular(targetDir, ['capability-cards.md', 'capability-cards.complete.md']
|
|
192
|
+
.filter((name) => fs.readdirSync(targetDir).includes(name)));
|
|
193
|
+
for (const store of stores) {
|
|
194
|
+
for (const entry of managedEntries(sourceDir, store)) {
|
|
109
195
|
fs.cpSync(path.join(sourceDir, entry), path.join(targetDir, entry), {
|
|
110
196
|
recursive: true,
|
|
111
197
|
force: true,
|
|
112
198
|
});
|
|
113
199
|
}
|
|
200
|
+
if (sourceLedger.stores[store]) targetLedger.stores[store] = sourceLedger.stores[store];
|
|
114
201
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(targetDir, entry));
|
|
118
|
-
}
|
|
202
|
+
targetLedger.stores = Object.fromEntries(Object.entries(targetLedger.stores).sort(([a], [b]) => a.localeCompare(b)));
|
|
203
|
+
fs.writeFileSync(path.join(targetDir, 'RVF-GENERATIONS.json'), `${JSON.stringify(targetLedger, null, 2)}\n`);
|
|
119
204
|
const cards = path.join(sourceDir, 'capability-cards.md');
|
|
120
205
|
if (fs.existsSync(cards)) {
|
|
121
|
-
|
|
206
|
+
mergeCapabilityCards(targetDir, fs.readFileSync(cards, 'utf8'), new Set(stores));
|
|
207
|
+
fs.copyFileSync(path.join(targetDir, 'capability-cards.md'), path.join(targetDir, 'capability-cards.complete.md'));
|
|
122
208
|
}
|
|
123
209
|
return { profile: PROFILE_COMPLETE, stores: discoverStoreFamilies(targetDir) };
|
|
124
210
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
|
|
6
|
+
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
7
|
+
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
8
|
+
const PHASE_FILE = /^\d{3}-[A-Z_]+\.json$/;
|
|
9
|
+
const QUARANTINE = /^\.prune-[A-Za-z0-9._-]+-[a-f0-9]{16}$/;
|
|
10
|
+
|
|
11
|
+
export const LIFECYCLE_EVIDENCE_RETENTION_POLICY = Object.freeze({
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
policyId: 'lifecycle-evidence-v1',
|
|
14
|
+
maxRefreshReceipts: 32,
|
|
15
|
+
maxTransactionDirectories: 16,
|
|
16
|
+
maxEvidenceBytes: 16 * 1024 * 1024,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function rootsFor({ brainHome, kbDir }) {
|
|
20
|
+
const home = path.resolve(brainHome || path.dirname(path.resolve(kbDir || '')));
|
|
21
|
+
const live = path.resolve(kbDir || path.join(home, 'kb'));
|
|
22
|
+
return { refresh: path.join(home, 'refresh-runs'),
|
|
23
|
+
transactions: path.join(path.dirname(live), `.${path.basename(live)}.update-transactions`) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function regularJson(file) {
|
|
27
|
+
const stat = fs.lstatSync(file);
|
|
28
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a trusted regular file');
|
|
29
|
+
return { value: JSON.parse(fs.readFileSync(file, 'utf8')), bytes: stat.size };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function treeBytes(root) {
|
|
33
|
+
const stat = fs.lstatSync(root);
|
|
34
|
+
if (stat.isSymbolicLink()) throw new Error('symbolic link is not trusted evidence');
|
|
35
|
+
if (stat.isFile()) return stat.size;
|
|
36
|
+
if (!stat.isDirectory()) throw new Error('special file is not trusted evidence');
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
for (const name of fs.readdirSync(root)) bytes += treeBytes(path.join(root, name));
|
|
39
|
+
return bytes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stamp(value, label) {
|
|
43
|
+
const parsed = Date.parse(value || '');
|
|
44
|
+
if (!Number.isFinite(parsed)) throw new Error(`${label} has no valid semantic timestamp`);
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scanRefresh(root, unsafe) {
|
|
49
|
+
if (!fs.existsSync(root)) return [];
|
|
50
|
+
const rows = [];
|
|
51
|
+
for (const name of fs.readdirSync(root).sort()) {
|
|
52
|
+
const file = path.join(root, name);
|
|
53
|
+
if (QUARANTINE.test(name)) {
|
|
54
|
+
unsafe.push({ path: file, reason: 'recognized pruning quarantine remains' });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
if (!name.endsWith('.json')) throw new Error('unexpected refresh evidence entry');
|
|
59
|
+
const { value: receipt, bytes } = regularJson(file);
|
|
60
|
+
const runId = name.slice(0, -5);
|
|
61
|
+
if (!SAFE_ID.test(runId) || receipt?.schemaVersion !== 3 || receipt?.kind !== 'ruvnet-brain-refresh-run'
|
|
62
|
+
|| receipt.runId !== runId || !['RUNNING', 'SETTLING', ...TERMINAL_REFRESH].includes(receipt.status)) {
|
|
63
|
+
throw new Error('malformed refresh receipt identity or state');
|
|
64
|
+
}
|
|
65
|
+
rows.push({ kind: 'refresh', id: runId, path: file, bytes,
|
|
66
|
+
timestamp: stamp(receipt.finishedAt || receipt.startedAt, `refresh ${runId}`), receipt });
|
|
67
|
+
} catch (error) { unsafe.push({ path: file, reason: error.message }); }
|
|
68
|
+
}
|
|
69
|
+
return rows;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scanTransactions(root, unsafe) {
|
|
73
|
+
if (!fs.existsSync(root)) return [];
|
|
74
|
+
const rows = [];
|
|
75
|
+
for (const name of fs.readdirSync(root).sort()) {
|
|
76
|
+
const dir = path.join(root, name);
|
|
77
|
+
if (QUARANTINE.test(name)) {
|
|
78
|
+
unsafe.push({ path: dir, reason: 'recognized pruning quarantine remains' });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const stat = fs.lstatSync(dir);
|
|
83
|
+
if (!SAFE_ID.test(name) || !stat.isDirectory() || stat.isSymbolicLink()) {
|
|
84
|
+
throw new Error('malformed transaction evidence entry');
|
|
85
|
+
}
|
|
86
|
+
const names = fs.readdirSync(dir).sort();
|
|
87
|
+
if (!names.length || names.some((file) => !PHASE_FILE.test(file))) throw new Error('transaction phase inventory is malformed');
|
|
88
|
+
const phases = names.map((file) => regularJson(path.join(dir, file)).value);
|
|
89
|
+
const latest = phases.at(-1);
|
|
90
|
+
if (latest?.kind !== 'ruvnet-brain-storage-transaction-phase' || latest.transactionId !== name
|
|
91
|
+
|| latest.sequence !== names.length || typeof latest.state !== 'string') {
|
|
92
|
+
throw new Error('transaction phase identity is malformed');
|
|
93
|
+
}
|
|
94
|
+
rows.push({ kind: 'transaction', id: name, path: dir, bytes: treeBytes(dir),
|
|
95
|
+
timestamp: stamp(latest.recordedAt, `transaction ${name}`), latest });
|
|
96
|
+
} catch (error) { unsafe.push({ path: dir, reason: error.message }); }
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function transactionReferences(value, output = []) {
|
|
102
|
+
if (!value || typeof value !== 'object') return output;
|
|
103
|
+
if (Object.hasOwn(value, 'transactionReceipts') && value.transactionReceipts !== null) {
|
|
104
|
+
output.push(value.transactionReceipts);
|
|
105
|
+
}
|
|
106
|
+
for (const nested of Array.isArray(value) ? value : Object.values(value)) transactionReferences(nested, output);
|
|
107
|
+
return output;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function newest(rows, predicate) {
|
|
111
|
+
return rows.filter(predicate).sort((a, b) => b.timestamp - a.timestamp || b.id.localeCompare(a.id))[0] || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function trustedEvidenceRoot(root, unsafe) {
|
|
115
|
+
try {
|
|
116
|
+
for (const entry of [path.dirname(root), root]) {
|
|
117
|
+
const stat = fs.lstatSync(entry);
|
|
118
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('evidence root is not a trusted directory (symbolic or special entry)');
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error.code !== 'ENOENT') unsafe.push({ path: root, reason: error.message });
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function snapshot(options) {
|
|
128
|
+
const roots = rootsFor(options);
|
|
129
|
+
const unsafe = [];
|
|
130
|
+
const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe) : [];
|
|
131
|
+
const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe) : [];
|
|
132
|
+
const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
|
|
133
|
+
const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => path.resolve(entry)));
|
|
134
|
+
const protectedRefresh = new Map();
|
|
135
|
+
const protectRefresh = (row, reason) => { if (row) protectedRefresh.set(row.path, reason); };
|
|
136
|
+
for (const row of refresh) {
|
|
137
|
+
if (['RUNNING', 'SETTLING'].includes(row.receipt.status)) protectRefresh(row, `active ${row.receipt.status}`);
|
|
138
|
+
if (preserveRefresh.has(row.id)) protectRefresh(row, 'explicitly preserved refresh run');
|
|
139
|
+
}
|
|
140
|
+
protectRefresh(newest(refresh, (row) => row.receipt.action === 'nightly'), 'newest nightly receipt');
|
|
141
|
+
protectRefresh(newest(refresh, (row) => row.receipt.action === 'update'), 'newest update receipt');
|
|
142
|
+
protectRefresh(newest(refresh, (row) => row.receipt.status === 'FAILED'), 'newest failed receipt');
|
|
143
|
+
protectRefresh(newest(refresh, (row) => row.receipt.status === 'ABANDONED'), 'newest abandoned receipt');
|
|
144
|
+
|
|
145
|
+
const retainedTransactionPaths = new Set();
|
|
146
|
+
for (const row of refresh) {
|
|
147
|
+
if (!protectedRefresh.has(row.path)) continue;
|
|
148
|
+
for (const reference of transactionReferences(row.receipt)) {
|
|
149
|
+
const resolved = path.resolve(String(reference || ''));
|
|
150
|
+
const relative = path.relative(roots.transactions, resolved);
|
|
151
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
|
|
152
|
+
unsafe.push({ path: row.path, reason: `forged external transaction reference: ${String(reference)}` });
|
|
153
|
+
} else retainedTransactionPaths.add(resolved);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const protectedTransactions = new Map();
|
|
157
|
+
for (const row of transactions) {
|
|
158
|
+
if (!TERMINAL_TRANSACTION.has(row.latest.state)) protectedTransactions.set(row.path, `nonterminal ${row.latest.state}`);
|
|
159
|
+
if (preserveTransactions.has(row.path)) protectedTransactions.set(row.path, 'explicitly preserved transaction');
|
|
160
|
+
if (retainedTransactionPaths.has(row.path)) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
|
|
161
|
+
}
|
|
162
|
+
const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
|
|
163
|
+
+ unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
|
|
164
|
+
try { return sum + treeBytes(entry); } catch { return sum; }
|
|
165
|
+
}, 0);
|
|
166
|
+
return { roots, unsafe, refresh, transactions, bytes, protectedRefresh, protectedTransactions };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function counts(state) {
|
|
170
|
+
return { refreshReceipts: state.refresh.length, transactionDirectories: state.transactions.length, bytes: state.bytes };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isWithin(state, policy) {
|
|
174
|
+
return state.unsafe.length === 0 && state.refresh.length <= policy.maxRefreshReceipts
|
|
175
|
+
&& state.transactions.length <= policy.maxTransactionDirectories && state.bytes <= policy.maxEvidenceBytes;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function assessLifecycleEvidence(options = {}) {
|
|
179
|
+
const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
|
|
180
|
+
const state = snapshot(options);
|
|
181
|
+
return { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention', policy,
|
|
182
|
+
observedAt: (options.now || (() => new Date().toISOString()))(), before: counts(state),
|
|
183
|
+
protected: {
|
|
184
|
+
refresh: [...state.protectedRefresh].map(([entryPath, reason]) => ({ path: entryPath, reason })),
|
|
185
|
+
transactions: [...state.protectedTransactions].map(([entryPath, reason]) => ({ path: entryPath, reason })),
|
|
186
|
+
},
|
|
187
|
+
removable: {
|
|
188
|
+
refresh: state.refresh.filter((row) => TERMINAL_REFRESH.has(row.receipt.status) && !state.protectedRefresh.has(row.path))
|
|
189
|
+
.sort((a, b) => a.timestamp - b.timestamp).map(({ path: entryPath }) => entryPath),
|
|
190
|
+
transactions: state.transactions.filter((row) => TERMINAL_TRANSACTION.has(row.latest.state)
|
|
191
|
+
&& !state.protectedTransactions.has(row.path)).sort((a, b) => a.timestamp - b.timestamp)
|
|
192
|
+
.map(({ path: entryPath }) => entryPath),
|
|
193
|
+
},
|
|
194
|
+
removed: { refresh: [], transactions: [] }, after: counts(state), unsafe: state.unsafe,
|
|
195
|
+
withinBudget: isWithin(state, policy) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
|
|
199
|
+
const name = path.basename(entry);
|
|
200
|
+
const resolved = path.resolve(entry);
|
|
201
|
+
if (path.dirname(resolved) !== path.resolve(root) || !SAFE_ID.test(name.replace(/\.json$/, ''))) {
|
|
202
|
+
throw new Error(`refusing unsafe lifecycle evidence removal: ${entry}`);
|
|
203
|
+
}
|
|
204
|
+
const quarantine = path.join(root, `.prune-${name.replace(/\.json$/, '')}-${crypto.randomBytes(8).toString('hex')}`);
|
|
205
|
+
fs.renameSync(resolved, quarantine);
|
|
206
|
+
afterQuarantine?.({ entry: resolved, quarantine });
|
|
207
|
+
removeEntry(quarantine);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function pruneLifecycleEvidence(options = {}) {
|
|
211
|
+
const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
|
|
212
|
+
const removeEntry = options.removeEntry || ((entry) => fs.rmSync(entry, { recursive: true, force: true }));
|
|
213
|
+
// A prior invocation's quarantine name is not proof of ownership of its current
|
|
214
|
+
// bytes. Preserve it as unsafe, measured evidence; only this invocation may remove
|
|
215
|
+
// the exact entry it just quarantined. Recovery must not guess away private data.
|
|
216
|
+
const initial = assessLifecycleEvidence({ ...options, policy });
|
|
217
|
+
const removed = { refresh: [], transactions: [] };
|
|
218
|
+
for (;;) {
|
|
219
|
+
const state = snapshot(options);
|
|
220
|
+
if (isWithin(state, policy)) break;
|
|
221
|
+
if (state.unsafe.length) break;
|
|
222
|
+
const refreshCandidates = state.refresh.filter((row) => TERMINAL_REFRESH.has(row.receipt.status)
|
|
223
|
+
&& !state.protectedRefresh.has(row.path)).sort((a, b) => a.timestamp - b.timestamp);
|
|
224
|
+
const transactionCandidates = state.transactions.filter((row) => TERMINAL_TRANSACTION.has(row.latest.state)
|
|
225
|
+
&& !state.protectedTransactions.has(row.path)).sort((a, b) => a.timestamp - b.timestamp);
|
|
226
|
+
let candidate = state.refresh.length > policy.maxRefreshReceipts ? refreshCandidates[0]
|
|
227
|
+
: state.transactions.length > policy.maxTransactionDirectories ? transactionCandidates[0]
|
|
228
|
+
: [...refreshCandidates, ...transactionCandidates].sort((a, b) => a.timestamp - b.timestamp)[0];
|
|
229
|
+
if (!candidate) break;
|
|
230
|
+
const root = candidate.kind === 'refresh' ? state.roots.refresh : state.roots.transactions;
|
|
231
|
+
quarantineAndRemove(candidate.path, root, removeEntry, options.afterQuarantine);
|
|
232
|
+
removed[candidate.kind === 'refresh' ? 'refresh' : 'transactions'].push(candidate.path);
|
|
233
|
+
}
|
|
234
|
+
const final = assessLifecycleEvidence({ ...options, policy });
|
|
235
|
+
return { ...final, before: initial.before, removed,
|
|
236
|
+
after: final.after, withinBudget: final.withinBudget };
|
|
237
|
+
}
|