ruvnet-brain 4.0.36 → 4.2.2-dev
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 +28 -5
- package/bin/install.mjs +405 -23
- package/data/model-catalog.json +104 -15
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +72 -9
- package/plugin/commands/configure.md +67 -21
- package/plugin/commands/rvcb.md +72 -9
- package/plugin/hooks/codex-hooks.json +40 -33
- package/plugin/hooks/hook-contracts.json +14 -24
- package/plugin/hooks/hooks.json +7 -42
- package/plugin/mcp/server.mjs +23 -6
- package/plugin/scripts/adr-currency-gate.mjs +150 -0
- package/plugin/scripts/capability-registry.mjs +10 -1
- package/plugin/scripts/codex-hook-adapter.mjs +121 -19
- package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
- package/plugin/scripts/continuation-gate.mjs +148 -8
- package/plugin/scripts/decision-gate.mjs +428 -0
- package/plugin/scripts/decision-outcomes.mjs +231 -0
- package/plugin/scripts/degradation-watch.mjs +271 -0
- package/plugin/scripts/ground-ruvnet.sh +51 -11
- package/plugin/scripts/hijack-ruvnet.sh +11 -3
- package/plugin/scripts/hook-registry.mjs +48 -3
- package/plugin/scripts/hook-shim.mjs +58 -3
- package/plugin/scripts/identifier-preflight.mjs +134 -0
- package/plugin/scripts/learn-capture.sh +50 -1
- package/plugin/scripts/learn-flush.mjs +5 -5
- package/plugin/scripts/lesson-bridge.mjs +343 -0
- package/plugin/scripts/lesson-hooks.sh +26 -0
- package/plugin/scripts/lesson-promote.mjs +50 -0
- package/plugin/scripts/lesson-store.mjs +6 -1
- package/plugin/scripts/mcp-readiness.mjs +107 -0
- package/plugin/scripts/protect-brain-state.sh +9 -0
- package/plugin/scripts/runtime-preferences.mjs +40 -0
- package/plugin/scripts/session-snapshot-hook.mjs +15 -6
- package/plugin/scripts/spend-guard.mjs +125 -0
- package/plugin/scripts/unprompted-runtime.mjs +12 -2
- package/plugin/scripts/update-apply.mjs +7 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
- package/scripts/brain-score.mjs +261 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +38 -4
- package/scripts/card-from-source.mjs +114 -0
- package/scripts/console-engine.mjs +1 -1
- package/scripts/corpus-candidate.mjs +294 -0
- package/scripts/corpus-reconcile.mjs +273 -0
- package/scripts/corpus-seed-publish.mjs +110 -0
- package/scripts/health-repair.mjs +11 -2
- package/scripts/ingest-new-repos.mjs +122 -0
- package/scripts/ingest-repo.mjs +66 -6
- package/scripts/learning-replay-cli.mjs +8 -3
- package/scripts/learning-replay-fixture.mjs +25 -6
- package/scripts/learning-replay-proof.mjs +38 -0
- package/scripts/nightly-wrapper.sh +55 -7
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/rebuild-gists-from-receipts.mjs +246 -0
- package/scripts/release-transaction.mjs +30 -2
- package/scripts/release.mjs +147 -0
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/rvf-generation.mjs +44 -0
- package/scripts/selfcheck.mjs +9 -1
- package/scripts/stabilization-receipt.mjs +11 -1
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-commands.mjs +117 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deterministically reconstruct gist passages from tracked, exact-version source receipts.
|
|
3
|
+
// This intentionally does not call the GitHub API, build vectors, or publish artifacts.
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const NAME = 'ruv-gists';
|
|
12
|
+
const HEX_GIST = /^[a-f0-9]{20,64}$/;
|
|
13
|
+
const HEX40 = /^[a-f0-9]{40}$/;
|
|
14
|
+
const HEX64 = /^[a-f0-9]{64}$/;
|
|
15
|
+
|
|
16
|
+
function fail(message) {
|
|
17
|
+
throw new Error(`[rebuild-gists-from-receipts] ${message}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
21
|
+
const jsonLine = (value) => JSON.stringify(value).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
22
|
+
|
|
23
|
+
function validDate(value) {
|
|
24
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validateFilename(filename, label) {
|
|
28
|
+
if (typeof filename !== 'string' || !filename || filename.includes('\0')
|
|
29
|
+
|| filename.split(/[\\/]/).some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
30
|
+
fail(`${label} has an unsafe or missing filename`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateSourceReceipts(source) {
|
|
35
|
+
if (![1, 2].includes(source?.schemaVersion)) fail('source receipt schemaVersion must be 1 or 2');
|
|
36
|
+
if (!/^[A-Za-z0-9-]{1,39}$/.test(String(source.owner || ''))) fail('source receipt owner is malformed');
|
|
37
|
+
if (!validDate(source.generated)) fail('source receipt generated timestamp is malformed');
|
|
38
|
+
if (!source.gists || typeof source.gists !== 'object' || Array.isArray(source.gists)) {
|
|
39
|
+
fail('source receipt gists must be an object');
|
|
40
|
+
}
|
|
41
|
+
if (Object.keys(source.gists).length === 0) fail('source receipt has no gist receipts');
|
|
42
|
+
|
|
43
|
+
for (const [gistId, receipt] of Object.entries(source.gists)) {
|
|
44
|
+
const label = `gist ${gistId}`;
|
|
45
|
+
if (!HEX_GIST.test(gistId)) fail(`${label} id is malformed`);
|
|
46
|
+
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) fail(`${label} receipt is malformed`);
|
|
47
|
+
if (receipt.complete !== true) fail(`${label} receipt is incomplete`);
|
|
48
|
+
if (!HEX40.test(String(receipt.versionSha || ''))) fail(`${label} versionSha is malformed`);
|
|
49
|
+
if (!validDate(receipt.updatedAt) || !validDate(receipt.ingestedAt)) fail(`${label} timestamps are malformed`);
|
|
50
|
+
if (!Array.isArray(receipt.files) || receipt.files.length === 0) fail(`${label} has no complete file inventory`);
|
|
51
|
+
const filenames = new Set();
|
|
52
|
+
for (const file of receipt.files) {
|
|
53
|
+
validateFilename(file?.filename, label);
|
|
54
|
+
if (filenames.has(file.filename)) fail(`${label} has duplicate filename ${file.filename}`);
|
|
55
|
+
filenames.add(file.filename);
|
|
56
|
+
if (file.included === true) {
|
|
57
|
+
if (!HEX64.test(String(file.sha256 || '')) || !Number.isSafeInteger(file.bytes) || file.bytes < 0) {
|
|
58
|
+
fail(`${label}/${file.filename} has malformed included-file identity`);
|
|
59
|
+
}
|
|
60
|
+
} else if (file.included !== false || typeof file.reason !== 'string' || !file.reason
|
|
61
|
+
|| !(file.size === null || (Number.isSafeInteger(file.size) && file.size >= 0))) {
|
|
62
|
+
fail(`${label}/${file.filename} has malformed exclusion evidence`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const expectedDigest = sha256(JSON.stringify(receipt.files));
|
|
66
|
+
if (!HEX64.test(String(receipt.contentDigest || '')) || receipt.contentDigest !== expectedDigest) {
|
|
67
|
+
fail(`${label} contentDigest does not match its file inventory`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return source;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function rawUrlFor({ owner, gistId, versionSha, filename }) {
|
|
74
|
+
if (!/^[A-Za-z0-9-]{1,39}$/.test(String(owner || '')) || !HEX_GIST.test(String(gistId || ''))
|
|
75
|
+
|| !HEX40.test(String(versionSha || ''))) fail('cannot construct raw URL from malformed source identity');
|
|
76
|
+
validateFilename(filename, `gist ${gistId}`);
|
|
77
|
+
const encodedFile = filename.split('/').map(encodeURIComponent).join('/');
|
|
78
|
+
return `https://gist.githubusercontent.com/${owner}/${gistId}/raw/${versionSha}/${encodedFile}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function paragraphChunks(text, size = 3200) {
|
|
82
|
+
if (typeof text !== 'string') fail('passage content must be text');
|
|
83
|
+
if (!Number.isSafeInteger(size) || size < 1) fail('chunk size must be a positive integer');
|
|
84
|
+
const out = [];
|
|
85
|
+
let buffer = '';
|
|
86
|
+
for (const paragraph of text.split(/\n\n+/)) {
|
|
87
|
+
if (buffer && buffer.length + paragraph.length + 2 > size) {
|
|
88
|
+
out.push(buffer);
|
|
89
|
+
buffer = '';
|
|
90
|
+
}
|
|
91
|
+
buffer = buffer ? `${buffer}\n\n${paragraph}` : paragraph;
|
|
92
|
+
}
|
|
93
|
+
if (buffer.trim()) out.push(buffer);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function provenanceBanner({ owner, gistId, filename, updatedAt }) {
|
|
98
|
+
return `SOURCE: GitHub gist by @${owner} — "${filename.replace(/\s+/g, ' ').trim().slice(0, 160)}"\n`
|
|
99
|
+
+ `GIST STATUS: rUv's own notes / release announcement — may describe PROPOSED or UNRELEASED work.\n`
|
|
100
|
+
+ `Treat as intent, not as confirmed shipped behavior: verify against repo source before asserting.\n`
|
|
101
|
+
+ `updated: ${updatedAt.slice(0, 10)} · https://gist.github.com/${owner}/${gistId}\n\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function mapBounded(items, concurrency, operation) {
|
|
105
|
+
const results = new Array(items.length);
|
|
106
|
+
let next = 0;
|
|
107
|
+
async function worker() {
|
|
108
|
+
while (next < items.length) {
|
|
109
|
+
const index = next++;
|
|
110
|
+
results[index] = await operation(items[index]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function reconstructGists(source, { fetchFn = globalThis.fetch, concurrency = 6 } = {}) {
|
|
118
|
+
validateSourceReceipts(source);
|
|
119
|
+
if (typeof fetchFn !== 'function') fail('fetch implementation is unavailable');
|
|
120
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 32) {
|
|
121
|
+
fail('concurrency must be an integer from 1 through 32');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const jobs = [];
|
|
125
|
+
for (const [gistId, receipt] of Object.entries(source.gists)) {
|
|
126
|
+
for (const file of receipt.files) {
|
|
127
|
+
if (file.included) jobs.push({ gistId, receipt, file });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const bodies = await mapBounded(jobs, concurrency, async ({ gistId, receipt, file }) => {
|
|
131
|
+
const url = rawUrlFor({ owner: source.owner, gistId, versionSha: receipt.versionSha, filename: file.filename });
|
|
132
|
+
const response = await fetchFn(url, {
|
|
133
|
+
headers: { accept: 'text/plain', 'user-agent': 'ruvnet-brain-gist-receipt-rebuild' },
|
|
134
|
+
});
|
|
135
|
+
if (!response?.ok) fail(`${gistId}/${file.filename} raw fetch failed: HTTP ${response?.status ?? 'unknown'}`);
|
|
136
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
137
|
+
if (bytes.length !== file.bytes) fail(`${gistId}/${file.filename} byte count ${bytes.length} differs from receipt ${file.bytes}`);
|
|
138
|
+
const digest = sha256(bytes);
|
|
139
|
+
if (digest !== file.sha256) fail(`${gistId}/${file.filename} sha256 ${digest} differs from receipt ${file.sha256}`);
|
|
140
|
+
let body;
|
|
141
|
+
try {
|
|
142
|
+
body = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
143
|
+
} catch {
|
|
144
|
+
fail(`${gistId}/${file.filename} is not valid UTF-8 text`);
|
|
145
|
+
}
|
|
146
|
+
return body;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const passages = [];
|
|
150
|
+
const entries = {};
|
|
151
|
+
let bodyIndex = 0;
|
|
152
|
+
let id = 0;
|
|
153
|
+
for (const [gistId, receipt] of Object.entries(source.gists)) {
|
|
154
|
+
for (const file of receipt.files) {
|
|
155
|
+
if (!file.included) continue;
|
|
156
|
+
const body = bodies[bodyIndex++];
|
|
157
|
+
const chunks = paragraphChunks(body);
|
|
158
|
+
const title = file.filename.replace(/\s+/g, ' ').trim().slice(0, 180) || file.filename;
|
|
159
|
+
const banner = provenanceBanner({ owner: source.owner, gistId, filename: file.filename, updatedAt: receipt.updatedAt });
|
|
160
|
+
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
161
|
+
const passageId = String(id++);
|
|
162
|
+
const passagePath = `${gistId.slice(0, 8)}/${file.filename}${chunks.length > 1 ? `#${chunkIndex}` : ''}`;
|
|
163
|
+
const text = banner + chunk;
|
|
164
|
+
passages.push({ id: passageId, text, path: passagePath, title });
|
|
165
|
+
entries[passageId] = { path: passagePath, kind: 'doc', title, chunk: chunkIndex, preview: text.slice(0, 200) };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const passageBody = passages.map(jsonLine).join('\n') + '\n';
|
|
170
|
+
const meta = {
|
|
171
|
+
model: NAME,
|
|
172
|
+
dimensions: 0,
|
|
173
|
+
metric: 'cosine',
|
|
174
|
+
name: NAME,
|
|
175
|
+
generated: source.generated,
|
|
176
|
+
repo: `gists/${source.owner}`,
|
|
177
|
+
note: "rUv's public gists — announcements and thinking, PROPOSED unless confirmed in repo source.",
|
|
178
|
+
entries,
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
passages,
|
|
182
|
+
passageBody,
|
|
183
|
+
meta,
|
|
184
|
+
sources: { ...source, schemaVersion: 2, passagesSha256: sha256(passageBody), gists: source.gists },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function writeReconstruction(result, { outDir }) {
|
|
189
|
+
const output = path.resolve(outDir || '');
|
|
190
|
+
if (!result?.passageBody || !result.meta || !result.sources) fail('complete reconstruction result is required');
|
|
191
|
+
fs.mkdirSync(output, { recursive: true });
|
|
192
|
+
const files = {
|
|
193
|
+
passagesFile: path.join(output, `${NAME}.passages.jsonl`),
|
|
194
|
+
metaFile: path.join(output, `${NAME}.meta.json`),
|
|
195
|
+
sourcesFile: path.join(output, `${NAME}.sources.json`),
|
|
196
|
+
};
|
|
197
|
+
const contents = new Map([
|
|
198
|
+
[files.passagesFile, result.passageBody],
|
|
199
|
+
[files.metaFile, `${JSON.stringify(result.meta, null, 2)}\n`],
|
|
200
|
+
[files.sourcesFile, `${JSON.stringify(result.sources, null, 2)}\n`],
|
|
201
|
+
]);
|
|
202
|
+
const staged = [];
|
|
203
|
+
try {
|
|
204
|
+
for (const [file, content] of contents) {
|
|
205
|
+
const temporary = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
206
|
+
fs.writeFileSync(temporary, content, { flag: 'wx' });
|
|
207
|
+
staged.push([temporary, file]);
|
|
208
|
+
}
|
|
209
|
+
for (const [temporary, file] of staged) fs.renameSync(temporary, file);
|
|
210
|
+
} finally {
|
|
211
|
+
for (const [temporary] of staged) fs.rmSync(temporary, { force: true });
|
|
212
|
+
}
|
|
213
|
+
return files;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function option(argv, name, fallback) {
|
|
217
|
+
const index = argv.indexOf(name);
|
|
218
|
+
return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('--') ? argv[index + 1] : fallback;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
222
|
+
const allowed = new Set(['--sources', '--out-dir', '--concurrency']);
|
|
223
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
224
|
+
if (!allowed.has(argv[index]) || !argv[index + 1] || argv[index + 1].startsWith('--')) fail(`unknown or incomplete option ${argv[index] || '(missing)'}`);
|
|
225
|
+
}
|
|
226
|
+
const sourcesFile = path.resolve(option(argv, '--sources', path.join(ROOT, 'kb', `${NAME}.sources.json`)));
|
|
227
|
+
const outDir = path.resolve(option(argv, '--out-dir', path.dirname(sourcesFile)));
|
|
228
|
+
const concurrency = Number(option(argv, '--concurrency', '6'));
|
|
229
|
+
let source;
|
|
230
|
+
try {
|
|
231
|
+
source = JSON.parse(fs.readFileSync(sourcesFile, 'utf8'));
|
|
232
|
+
} catch (error) {
|
|
233
|
+
fail(`cannot read source receipts ${sourcesFile}: ${error.message}`);
|
|
234
|
+
}
|
|
235
|
+
const result = await reconstructGists(source, { concurrency });
|
|
236
|
+
const written = writeReconstruction(result, { outDir });
|
|
237
|
+
process.stdout.write(`${JSON.stringify({ ok: true, passages: result.passages.length, passagesSha256: result.sources.passagesSha256, ...written }, null, 2)}\n`);
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
|
|
242
|
+
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
243
|
+
console.error(error.message);
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -55,10 +55,36 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
|
|
|
55
55
|
aborted: [],
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* THE DIGEST SERIALISER MUST AGREE WITH THE ONE THAT WRITES THE FILE.
|
|
60
|
+
*
|
|
61
|
+
* It did not, and it silently corrupted the terminal receipt of EVERY successful release. Measured
|
|
62
|
+
* 2026-08-20 on v4.0.36 and v4.0.90-dev — both `channels-converged` receipts, both digest=BAD, both
|
|
63
|
+
* with the same missing key:
|
|
64
|
+
*
|
|
65
|
+
* canonicalJson({a: undefined}) -> {"a":undefined} key KEPT (and not even valid JSON)
|
|
66
|
+
* JSON.stringify({a: undefined}) -> {} key DROPPED
|
|
67
|
+
*
|
|
68
|
+
* The converge observation carries optional fields — `hosts.verifier.error`, `claudeOnly`,
|
|
69
|
+
* `codexOnly`, `dual` — and on a clean run `verified.error` is `undefined`. So the digest was
|
|
70
|
+
* computed over `…"error":undefined…` while the bytes written omitted `error` entirely: a digest
|
|
71
|
+
* over a shape that can never be read back. `runReleaseTransaction` appends a receipt, reads it
|
|
72
|
+
* back and verifies it, so the publish then died with `release receipt digest mismatch` AFTER npm
|
|
73
|
+
* and GitHub had both been promoted — the release shipped and the rail reported failure.
|
|
74
|
+
*
|
|
75
|
+
* Proven by reconstruction: restoring `observation.hosts.verifier.error = undefined` on the stored
|
|
76
|
+
* receipt makes `digestReceipt` reproduce the stored digest exactly.
|
|
77
|
+
*
|
|
78
|
+
* Arrays have the same asymmetry (`JSON.stringify([undefined])` is `[null]`), so both are matched
|
|
79
|
+
* here. This does not change the digest of any receipt that never held an undefined value, which is
|
|
80
|
+
* every receipt that currently verifies.
|
|
81
|
+
*/
|
|
58
82
|
export const canonicalJson = (value) => {
|
|
59
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
83
|
+
if (Array.isArray(value)) return `[${value.map((item) => (item === undefined ? 'null' : canonicalJson(item))).join(',')}]`;
|
|
60
84
|
if (value && typeof value === 'object') {
|
|
61
|
-
return `{${Object.keys(value).sort()
|
|
85
|
+
return `{${Object.keys(value).sort()
|
|
86
|
+
.filter((key) => value[key] !== undefined)
|
|
87
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
62
88
|
}
|
|
63
89
|
return JSON.stringify(value);
|
|
64
90
|
};
|
|
@@ -86,6 +112,8 @@ export const transactionIdFor = (identity) => crypto.createHash('sha256').update
|
|
|
86
112
|
bundleSha256: identity.bundleSha256,
|
|
87
113
|
bundleSignatureSha256: identity.bundleSignatureSha256 || null,
|
|
88
114
|
bundleDigestSha256: identity.bundleDigestSha256 || null,
|
|
115
|
+
...(identity.corpusSeedSha256 ? { corpusSeedSha256: identity.corpusSeedSha256 } : {}),
|
|
116
|
+
...(identity.generationLedgerSha256 ? { generationLedgerSha256: identity.generationLedgerSha256 } : {}),
|
|
89
117
|
})).digest('hex');
|
|
90
118
|
|
|
91
119
|
export function signReceipt(receipt, privateKey) {
|
package/scripts/release.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { verifyPayload } from './release-payload.mjs';
|
|
|
37
37
|
|
|
38
38
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
39
39
|
const PUBLISH = process.argv.includes('--publish');
|
|
40
|
+
const CORPUS_SEED = process.argv.includes('--corpus-seed');
|
|
40
41
|
let protectedCandidate = null;
|
|
41
42
|
let sealedPackageArtifact = null;
|
|
42
43
|
let publicationReceiptPath = null;
|
|
@@ -56,6 +57,147 @@ function runOrDie(label, cmd, args, opts = {}) {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
const cliArg = (argv, name) => {
|
|
61
|
+
const index = argv.indexOf(name);
|
|
62
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
63
|
+
};
|
|
64
|
+
const isHex = (value, length) => new RegExp(`^[a-f0-9]{${length}}$`).test(String(value || ''));
|
|
65
|
+
const sha256File = (file) => {
|
|
66
|
+
const hash = crypto.createHash('sha256');
|
|
67
|
+
const fd = fs.openSync(file, 'r');
|
|
68
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
69
|
+
try {
|
|
70
|
+
let bytes;
|
|
71
|
+
while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
|
|
72
|
+
} finally {
|
|
73
|
+
fs.closeSync(fd);
|
|
74
|
+
}
|
|
75
|
+
return hash.digest('hex');
|
|
76
|
+
};
|
|
77
|
+
const exactFileIdentity = (value) => value && typeof value.file === 'string'
|
|
78
|
+
&& isHex(value.sha256, 64) && Number.isSafeInteger(value.bytes) && value.bytes >= 0;
|
|
79
|
+
|
|
80
|
+
function corpusFailure(message) {
|
|
81
|
+
throw new Error(`[corpus-seed] ${message}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function runProtectedCorpusSeed({
|
|
85
|
+
argv = process.argv.slice(2),
|
|
86
|
+
env = process.env,
|
|
87
|
+
root = ROOT,
|
|
88
|
+
run = (command, args, options) => spawnSync(command, args, { encoding: 'utf8', ...options }),
|
|
89
|
+
} = {}) {
|
|
90
|
+
if (env.GITHUB_ACTIONS !== 'true' || env.GITHUB_WORKFLOW !== 'protected-release'
|
|
91
|
+
|| env.GITHUB_REPOSITORY !== 'stuinfla/ruvnet-brain') {
|
|
92
|
+
corpusFailure('publication is allowed only inside the protected-release GitHub workflow for stuinfla/ruvnet-brain');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tag = cliArg(argv, '--corpus-tag');
|
|
96
|
+
const bundleFile = cliArg(argv, '--corpus-bundle');
|
|
97
|
+
const receiptFile = cliArg(argv, '--corpus-receipt');
|
|
98
|
+
const target = cliArg(argv, '--target');
|
|
99
|
+
const repo = cliArg(argv, '--repo') || env.GITHUB_REPOSITORY;
|
|
100
|
+
const digestMatch = String(tag || '').match(/^corpus-sha256-([a-f0-9]{64})$/);
|
|
101
|
+
if (!digestMatch) corpusFailure('corpus tag must be corpus-sha256- followed by 64 lowercase hex characters');
|
|
102
|
+
if (repo !== env.GITHUB_REPOSITORY || repo !== 'stuinfla/ruvnet-brain') corpusFailure('repository does not match the protected workflow');
|
|
103
|
+
|
|
104
|
+
for (const [label, file] of [['bundle', bundleFile], ['receipt', receiptFile]]) {
|
|
105
|
+
if (!file || !path.isAbsolute(file)) corpusFailure(`${label} must be an absolute regular file`);
|
|
106
|
+
try {
|
|
107
|
+
const stat = fs.lstatSync(file);
|
|
108
|
+
if (!stat.isFile() || stat.isSymbolicLink()) corpusFailure(`${label} must be an absolute regular file`);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (String(error.message).startsWith('[corpus-seed]')) throw error;
|
|
111
|
+
corpusFailure(`${label} must be an absolute regular file (${error.message})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const headResult = run('git', ['rev-parse', 'HEAD'], {
|
|
116
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
117
|
+
});
|
|
118
|
+
if (headResult.error || headResult.status !== 0) corpusFailure('cannot resolve release HEAD');
|
|
119
|
+
const head = String(headResult.stdout || '').trim();
|
|
120
|
+
|
|
121
|
+
let receipt;
|
|
122
|
+
try {
|
|
123
|
+
receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8'));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
corpusFailure(`corpus receipt is unreadable/corrupt (${error.message})`);
|
|
126
|
+
}
|
|
127
|
+
if (!isHex(target, 40) || target !== head || target !== env.GITHUB_SHA || target !== receipt.builderSourceSha) {
|
|
128
|
+
corpusFailure('target must exactly equal HEAD, GITHUB_SHA, and the corpus receipt builderSourceSha');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const boundaryIdentities = [receipt.privateFence, receipt.eligibilityPolicy, receipt.generationLedger];
|
|
132
|
+
const storeBindingsValid = Number.isSafeInteger(receipt.storeCount) && receipt.storeCount > 0
|
|
133
|
+
&& Array.isArray(receipt.stores) && receipt.stores.length === receipt.storeCount
|
|
134
|
+
&& receipt.stores.every((store) => typeof store?.name === 'string' && store.name.length > 0
|
|
135
|
+
&& /^[a-f0-9]{7,64}$/.test(String(store.sourceCommit || ''))
|
|
136
|
+
&& typeof store.builtUtc === 'string' && Number.isFinite(Date.parse(store.builtUtc))
|
|
137
|
+
&& typeof store.model === 'string' && store.model.length > 0
|
|
138
|
+
&& Number.isSafeInteger(store.dimensions) && store.dimensions > 0
|
|
139
|
+
&& Array.isArray(store.files) && store.files.length > 0 && store.files.every(exactFileIdentity));
|
|
140
|
+
const emptyFailureArrays = ['duplicateRvfDigests', 'unreceiptedRvfFiles', 'missingSidecars']
|
|
141
|
+
.every((key) => Array.isArray(receipt[key]) && receipt[key].length === 0);
|
|
142
|
+
const privateExclusionsValid = Array.isArray(receipt.excludedPrivateStores)
|
|
143
|
+
&& receipt.excludedPrivateStores.every((name) => typeof name === 'string' && name.length > 0);
|
|
144
|
+
const generatorFile = path.join(root, 'scripts/corpus-candidate.mjs');
|
|
145
|
+
const generatorValid = fs.existsSync(generatorFile)
|
|
146
|
+
&& receipt.generator?.corpusCandidateSha256 === sha256File(generatorFile);
|
|
147
|
+
if (receipt.schemaVersion !== 1 || receipt.kind !== 'ruvnet-brain-corpus-candidate'
|
|
148
|
+
|| !receipt.createdAt || !receipt.coverageGeneration || !storeBindingsValid || !emptyFailureArrays
|
|
149
|
+
|| !privateExclusionsValid || !boundaryIdentities.every(exactFileIdentity) || !exactFileIdentity(receipt.archive)
|
|
150
|
+
|| !generatorValid
|
|
151
|
+
|| receipt.archive.file !== path.basename(bundleFile)) {
|
|
152
|
+
corpusFailure('corpus receipt bindings are incomplete or invalid');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const archiveSha256 = sha256File(bundleFile);
|
|
156
|
+
if (archiveSha256 !== receipt.archive.sha256 || fs.statSync(bundleFile).size !== receipt.archive.bytes) {
|
|
157
|
+
corpusFailure('archive bytes do not match the corpus receipt');
|
|
158
|
+
}
|
|
159
|
+
if (digestMatch[1] !== archiveSha256) corpusFailure('corpus tag digest does not match the receipt and archive');
|
|
160
|
+
|
|
161
|
+
const viewArgs = ['release', 'view', tag, '--json', 'tagName', '--repo', repo];
|
|
162
|
+
const view = run('gh', viewArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
163
|
+
if (!view.error && view.status === 0) corpusFailure(`release ${tag} already exists; refusing to overwrite immutable corpus seed`);
|
|
164
|
+
const viewError = String(view.error?.message || view.stderr || view.stdout || '');
|
|
165
|
+
if (!/(release not found|no release found)/i.test(viewError)) corpusFailure(`cannot prove ${tag} is absent (${viewError.trim() || `gh exited ${view.status}`})`);
|
|
166
|
+
|
|
167
|
+
const notes = [
|
|
168
|
+
'Content-addressed RuvNet Brain corpus seed.',
|
|
169
|
+
`Archive SHA-256: ${archiveSha256}`,
|
|
170
|
+
`Receipt SHA-256: ${sha256File(receiptFile)}`,
|
|
171
|
+
`Stores: ${receipt.storeCount}`,
|
|
172
|
+
`Coverage generation: ${receipt.coverageGeneration}`,
|
|
173
|
+
'This published prerelease is immutable and must never be replaced.',
|
|
174
|
+
].join('\n');
|
|
175
|
+
const createArgs = [
|
|
176
|
+
'release', 'create', tag,
|
|
177
|
+
'--prerelease', '--latest=false',
|
|
178
|
+
'--target', target,
|
|
179
|
+
'--repo', repo,
|
|
180
|
+
'--title', `Immutable corpus seed ${archiveSha256.slice(0, 16)}`,
|
|
181
|
+
'--notes', notes,
|
|
182
|
+
bundleFile, receiptFile,
|
|
183
|
+
];
|
|
184
|
+
const create = run('gh', createArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
185
|
+
if (create.error || create.status !== 0) {
|
|
186
|
+
corpusFailure(`protected corpus publication failed (${String(create.error?.message || create.stderr || create.stdout || '').trim()})`);
|
|
187
|
+
}
|
|
188
|
+
return { tag, target, repository: repo, archiveSha256, receiptSha256: sha256File(receiptFile) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (CORPUS_SEED) {
|
|
192
|
+
try {
|
|
193
|
+
const result = runProtectedCorpusSeed();
|
|
194
|
+
console.log(JSON.stringify({ ok: true, mode: 'corpus-seed', ...result }, null, 2));
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(error.message);
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
|
|
59
201
|
console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`);
|
|
60
202
|
|
|
61
203
|
// The local CLI remains useful as a read-only preflight, but publication authority lives only in
|
|
@@ -195,6 +337,8 @@ if (PUBLISH) {
|
|
|
195
337
|
bundleSignaturePath: path.join(payloadRoot, 'ruvnet-brain.zip.sig'),
|
|
196
338
|
bundleDigestPath: path.join(payloadRoot, 'ruvnet-brain.zip.sha256'),
|
|
197
339
|
packagePath: byRole.get('npm'),
|
|
340
|
+
corpusSeedPath: byRole.get('corpus-seed'),
|
|
341
|
+
generationLedgerPath: byRole.get('generation-ledger'),
|
|
198
342
|
};
|
|
199
343
|
for (const asset of Object.values(assets)) {
|
|
200
344
|
if (!fs.existsSync(asset)) {
|
|
@@ -224,6 +368,8 @@ if (PUBLISH) {
|
|
|
224
368
|
bundleSha256,
|
|
225
369
|
bundleSignatureSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleSignaturePath)).digest('hex'),
|
|
226
370
|
bundleDigestSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleDigestPath)).digest('hex'),
|
|
371
|
+
corpusSeedSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.corpusSeedPath)).digest('hex'),
|
|
372
|
+
generationLedgerSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.generationLedgerPath)).digest('hex'),
|
|
227
373
|
};
|
|
228
374
|
const privatePem = process.env.RUVNET_SIGNING_KEY;
|
|
229
375
|
if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
|
|
@@ -254,3 +400,4 @@ if (PUBLISH) {
|
|
|
254
400
|
} else {
|
|
255
401
|
console.log(`\n${c.g(c.b('✓✓✓ PREFLIGHT PASS — NOT PUBLISHED'))} — the committed candidate passed every check-only gate.\n`);
|
|
256
402
|
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// repo-count-detector.mjs — THE ONE detector for corpus-size claims in public surfaces.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from tests/unit/repo-count.test.mjs on 2026-08-10 so the WRITER (sync-census.mjs)
|
|
4
|
+
// and the GATE (repo-count.test.mjs) consult the same code. They had drifted by construction:
|
|
5
|
+
// the gate found every phrasing, while the writer enumerated a handful by hand and missed
|
|
6
|
+
// "71 of rUv's repos", "71 RuvNet building-block repos", "71 built today", and the
|
|
7
|
+
// HTML-entity variant. A checker and a fixer that disagree guarantee recurring red builds.
|
|
8
|
+
|
|
9
|
+
export const MIN_CORPUS_MAGNITUDE = 20;
|
|
10
|
+
|
|
11
|
+
// Deliberate, documented exceptions: the literal is correct AS WRITTEN because it describes a
|
|
12
|
+
// PAST state (a "before" baseline in a receipts table), not a current claim — not stale data.
|
|
13
|
+
// Anything else that disagrees with ALLOWED fails. Keyed `${file}::${matched text}` so a typo'd
|
|
14
|
+
// exemption can't silently swallow an unrelated real regression.
|
|
15
|
+
export const EXEMPT = new Set([
|
|
16
|
+
'README.md::24 repos', // v1 (pre-2.0) baseline column in the "what 2.0 proved" before/after table
|
|
17
|
+
'README.md::24→69 verified repos', // same v1-baseline row, "24→<current>" delta phrasing
|
|
18
|
+
// 2026-08-06: the corpus grew 69 → 71 (tonight's rebuild). The CURRENT claims were updated to 71
|
|
19
|
+
// in README.md and SKILL.md, but these two are HISTORICAL — they describe what release 2.0
|
|
20
|
+
// proved, inside a collapsed "Earlier — what 2.0 proved" section and its before/after table.
|
|
21
|
+
// Rewriting them to 71 would be the easy way to make this gate green and would falsify the record:
|
|
22
|
+
// 2.0 shipped with 69, not 71. A gate that pressures you into editing history is worse than the
|
|
23
|
+
// stale number it caught, so historical mentions are exempted by name and current ones are not.
|
|
24
|
+
'README.md::69 verified repos', // "what 2.0 proved" summary line — a claim about 2.0, not today
|
|
25
|
+
'README.md::69 repos', // the 2.0 column of the same v1→v2.0 before/after table
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** @param {string} src @returns {{text: string, n: number}[]} */
|
|
29
|
+
export function findRepoCountLiterals(src) {
|
|
30
|
+
const found = [];
|
|
31
|
+
// Covers "repo(s)" and the spelled-out "repositor(y|ies)" (SKILL.md line 8's own phrasing:
|
|
32
|
+
// "32 RuvNet (rUv / Reuven Cohen) repositories" — real prose puts several filler words/
|
|
33
|
+
// parentheticals between the number and the noun, so this can't be a fixed word count).
|
|
34
|
+
const REPO_WORD = '(?:repos?|repositor(?:y|ies))';
|
|
35
|
+
// Digit NOT immediately followed by "+": an open lower-bound like "20+ repos" is a true,
|
|
36
|
+
// deliberately-vague qualifier, not a precise count claim — exempt from matching at all rather
|
|
37
|
+
// than needing a per-mention exemption. Filler between the digit and the repo-word is capped at
|
|
38
|
+
// 50 chars and may not cross a sentence end / table-cell boundary (".", "|", newline), so this
|
|
39
|
+
// can't accidentally bridge two unrelated numbers separated by prose.
|
|
40
|
+
// `(?<!\d\.)` — never treat a SEMVER COMPONENT as a repo count. Real false positive: the primer's
|
|
41
|
+
// header line "…v3.4.21-dev · Built: 2026-07-20 · Covers: 69/192 repos" made the detector read the
|
|
42
|
+
// patch number `21` (the `-` after it is a word boundary) and then walk 40-odd filler chars to
|
|
43
|
+
// "repos", reporting a stale count on a line whose actual claim — 69/192 — was correct. The
|
|
44
|
+
// published number was right and the gate cried wolf, which is how a gate loses its authority.
|
|
45
|
+
// Two guards, both added after REAL false positives on the primer's own header line
|
|
46
|
+
// "…<version> · Built: <date> · Covers: 69/192 repos", whose actual claim (69/192) is correct:
|
|
47
|
+
// (?<!\d\.) — a SEMVER COMPONENT is never a repo count. The patch number was being read as
|
|
48
|
+
// one, because the `-` before a prerelease suffix is a word boundary.
|
|
49
|
+
// `·` in the filler exclusion — a middle dot separates INDEPENDENT facts. Without it the walk
|
|
50
|
+
// crossed from the build DATE into the count and reported `2026` as a repo count
|
|
51
|
+
// (found by this file's own detector tests, after the semver fix unmasked it).
|
|
52
|
+
const reSpaced = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)\\b(?:(?![.|·\\n])[\\s\\S]){0,50}?\\b${REPO_WORD}\\b`, 'gi');
|
|
53
|
+
const reHyphen = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)-repos?\\b`, 'gi');
|
|
54
|
+
for (const re of [reSpaced, reHyphen]) {
|
|
55
|
+
for (const m of src.matchAll(re)) {
|
|
56
|
+
const n = Number(m[1]);
|
|
57
|
+
if (n >= MIN_CORPUS_MAGNITUDE) found.push({ text: m[0], n });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* restore-local-ingests.mjs — replay every ingest a bundle apply silently removed.
|
|
4
|
+
*
|
|
5
|
+
* WHAT HAPPENED, overnight 2026-08-13 → 08-14. A scheduled bundle apply extracted an Aug-8 bundle
|
|
6
|
+
* over the live brain cache. The outcome for each piece of the previous evening's work was decided
|
|
7
|
+
* entirely by whether it had been committed:
|
|
8
|
+
*
|
|
9
|
+
* SURVIVED capability-cards.md — 5 hand-written cards, committed and pushed that night. The live
|
|
10
|
+
* brain was restored from the repo copy in one command.
|
|
11
|
+
* DIED helix, rvQR, RuCelium, wifi-veil — ingested and VERIFIED ROUTING hours earlier, gone
|
|
12
|
+
* without a trace. Their cards became orphans pointing at stores that no longer existed,
|
|
13
|
+
* which is worse than absence: the router offers an answer whose source is missing.
|
|
14
|
+
*
|
|
15
|
+
* The owner, correctly and for the second time: "you can't ever let work go overnight — you must
|
|
16
|
+
* always push latest changes or things get overwritten."
|
|
17
|
+
*
|
|
18
|
+
* A `.rvf` is a 32MB gitignored build artifact and CANNOT be committed. So the rule's second half
|
|
19
|
+
* applies: when the artifact cannot be pushed, commit the RECIPE. `ingest-repo.mjs` now records each
|
|
20
|
+
* ingest into `kb/local-ingests.json`, and this replays anything the cache is missing.
|
|
21
|
+
*
|
|
22
|
+
* The wipe was not on either adversarial audit's list, because both read code and this only shows
|
|
23
|
+
* up if you watch the machine overnight. A defect that needs a night to appear needs a ledger to be
|
|
24
|
+
* seen at all.
|
|
25
|
+
*
|
|
26
|
+
* MEASURED 2026-08-19, Dream Cycle memory-durability night: this script cannot tell "the store root
|
|
27
|
+
* was wiped" from "the store root never existed on this host" — a fresh checkout, a CI runner, or
|
|
28
|
+
* this very nightly agent's own ephemeral container all read exactly one recorded ingest away from
|
|
29
|
+
* every other, i.e. IDENTICALLY to a real overnight wipe, because `missingIngests()` only diffs the
|
|
30
|
+
* ledger against `storesAt(root)`, and `storesAt()` silently returns `[]` on ENOENT (kb/store-root.mjs).
|
|
31
|
+
* `restore-local-ingests.mjs` had zero test coverage before tonight, and the repo's own dream-cycle
|
|
32
|
+
* operating notes told this very automation to read ANY non-zero exit as "the nightly bundle wiped
|
|
33
|
+
* local work" — so a routine fresh-checkout run would have logged a false wipe every single night.
|
|
34
|
+
*
|
|
35
|
+
* The fix follows the pattern this repo already uses correctly in `nightly-watchdog.mjs`
|
|
36
|
+
* (MISSING / NEVER-RAN / STALE, three states never collapsed into one alarm) and the general
|
|
37
|
+
* pattern for reconciling a durable recipe against a rebuildable cache (npm's `.package-lock.json`
|
|
38
|
+
* witness co-located with `node_modules`, Dynamo-style tombstones): check whether the root was ever
|
|
39
|
+
* initialized BEFORE treating its contents as evidence of loss.
|
|
40
|
+
*/
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { execFileSync } from 'node:child_process';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { storeRoot, storesAt } from '../kb/store-root.mjs';
|
|
46
|
+
|
|
47
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
48
|
+
const LEDGER = path.join(ROOT, 'kb', 'local-ingests.json');
|
|
49
|
+
|
|
50
|
+
export const OK = 'OK';
|
|
51
|
+
export const NEVER_MATERIALIZED = 'NEVER-MATERIALIZED';
|
|
52
|
+
export const WIPED = 'WIPED';
|
|
53
|
+
|
|
54
|
+
/** Which recorded ingests are absent from the store root retrieval actually reads. */
|
|
55
|
+
export function missingIngests({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
|
|
56
|
+
let recorded = [];
|
|
57
|
+
try { recorded = JSON.parse(fs.readFileSync(ledgerFile, 'utf8')).ingests ?? []; } catch { return []; }
|
|
58
|
+
const present = new Set(storesAt(root));
|
|
59
|
+
return recorded.filter((e) => !present.has(e.store ?? e.name.toLowerCase()));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify a diff between the recipe ledger and the live store root into three states, never one:
|
|
64
|
+
* OK every recorded ingest is present.
|
|
65
|
+
* NEVER-MATERIALIZED root does not exist on this host at all — nothing was ever ingested here.
|
|
66
|
+
* Not evidence of loss: a fresh checkout, CI runner, or this agent's own
|
|
67
|
+
* ephemeral container all look like this on a routine first run.
|
|
68
|
+
* WIPED root exists (so this host DID ingest before) but recorded entries are gone —
|
|
69
|
+
* the actual signal `restore-local-ingests.mjs` exists to catch.
|
|
70
|
+
*/
|
|
71
|
+
export function classify({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
|
|
72
|
+
const missing = missingIngests({ ledgerFile, root });
|
|
73
|
+
if (!missing.length) return { state: OK, missing };
|
|
74
|
+
if (!fs.existsSync(root)) return { state: NEVER_MATERIALIZED, missing };
|
|
75
|
+
return { state: WIPED, missing };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isMain = (() => {
|
|
79
|
+
try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
80
|
+
catch { return false; }
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
if (isMain) {
|
|
84
|
+
const apply = process.argv.includes('--apply');
|
|
85
|
+
const { state, missing: gone } = classify();
|
|
86
|
+
if (state === OK) {
|
|
87
|
+
console.log(`[restore] every recorded local ingest is present in ${storeRoot()}`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
// NAMED, never counted. "4 stores missing" is the truncation that reads as completeness.
|
|
91
|
+
if (state === NEVER_MATERIALIZED) {
|
|
92
|
+
console.log(`\n[restore] ${storeRoot()} does not exist on this host — ${gone.length} recorded `
|
|
93
|
+
+ `ingest(s) were never materialized here. This is NOT evidence of a wipe (fresh checkout, CI `
|
|
94
|
+
+ `runner, or an ephemeral agent container all look like this):`);
|
|
95
|
+
} else {
|
|
96
|
+
console.log(`\n[restore] ${gone.length} recorded ingest(s) are NOT in ${storeRoot()}:`);
|
|
97
|
+
}
|
|
98
|
+
for (const e of gone) console.log(` ${e.name.padEnd(16)} ingested ${String(e.at).slice(0, 10)}`);
|
|
99
|
+
if (!apply) {
|
|
100
|
+
console.log('\n read-only. Re-run with --apply to re-ingest them.\n');
|
|
101
|
+
// NEVER-MATERIALIZED exits 2 (informational: nothing to restore FROM, just re-ingest) so a
|
|
102
|
+
// caller checking the exit code cannot mistake "never synced here" for "the nightly bundle
|
|
103
|
+
// wiped local work" — WIPED keeps exit 1, the real alarm.
|
|
104
|
+
process.exit(state === NEVER_MATERIALIZED ? 2 : 1);
|
|
105
|
+
}
|
|
106
|
+
let failed = 0;
|
|
107
|
+
for (const e of gone) {
|
|
108
|
+
console.log(`\n[restore] re-ingesting ${e.name}`);
|
|
109
|
+
try {
|
|
110
|
+
execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'ingest-repo.mjs'), '--name', e.name],
|
|
111
|
+
{ cwd: ROOT, stdio: 'inherit', env: process.env });
|
|
112
|
+
} catch { failed += 1; console.log(`[restore] ${e.name} FAILED — left recorded so the next run retries it`); }
|
|
113
|
+
}
|
|
114
|
+
console.log(`\n[restore] ${gone.length - failed}/${gone.length} restored.\n`);
|
|
115
|
+
process.exit(failed ? 1 : 0);
|
|
116
|
+
}
|