ruvnet-brain 4.3.9 → 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.
Files changed (83) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +11 -2
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +472 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. 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
+ }
@@ -42,34 +42,92 @@ function familyEntries(dir, store) {
42
42
  entry === `${store}-primer.md` || entry === store || entry.startsWith(`${store}.`));
43
43
  }
44
44
 
45
- function filterCapabilityCards(dir) {
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
- const cardCount = (current.match(/^## /gm) || []).length;
51
- if (!fs.existsSync(backup) || cardCount > 1) fs.copyFileSync(file, backup);
52
- const full = fs.readFileSync(backup, 'utf8');
53
- const firstCard = full.search(/^## /m);
54
- const start = full.search(/^## ruvector\s*$/m);
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)) fs.copyFileSync(backup, path.join(dir, 'capability-cards.md'));
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 filterGenerationLedger(dir, allowed) {
68
- const file = path.join(dir, 'RVF-GENERATIONS.json');
69
- if (!fs.existsSync(file)) return;
70
- const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
71
- doc.stores = Object.fromEntries(Object.entries(doc.stores || {}).filter(([name]) => allowed.has(name)));
72
- fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
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 discoverStoreFamilies(dir)) {
87
- if (allowed.has(store)) continue;
156
+ for (const { store, entries } of plan) {
88
157
  removedStores.push(store);
89
- for (const entry of familyEntries(dir, store)) {
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.rmSync(target, { recursive: true, force: true });
161
+ fs.unlinkSync(target);
93
162
  removed.push(entry);
94
163
  }
95
164
  }
96
- filterCapabilityCards(dir);
97
- filterGenerationLedger(dir, allowed);
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
- fs.mkdirSync(targetDir, { recursive: true });
106
- const stores = discoverStoreFamilies(sourceDir);
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
- for (const entry of familyEntries(sourceDir, store)) {
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
- for (const entry of ['capability-cards.md', 'RVF-GENERATIONS.json']) {
116
- const source = path.join(sourceDir, entry);
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
- fs.copyFileSync(cards, path.join(targetDir, 'capability-cards.complete.md'));
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
+ }