ruvnet-brain 4.0.90-dev → 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.
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ingest-new-repos.mjs — load what rUv shipped, without being asked.
4
+ *
5
+ * THE GAP THIS CLOSES, and it is embarrassing rather than subtle. The owner asked on 2026-08-20
6
+ * why `ruvnet/ultrasonic` was not in the brain: *"aren't you loading everything Ruv creates every
7
+ * day?????"* The honest answer was no, and nothing ever had. `nightly-wrapper.sh` refreshed
8
+ * lessons, health, the release watchdog and the replay proof — and contained ZERO references to
9
+ * ingestion. New rUv repos only ever entered the brain when a human typed `ingest-repo.mjs` by
10
+ * hand, so the corpus drifted behind the org silently and the only symptom was a question the
11
+ * brain could not answer.
12
+ *
13
+ * `brain-stamp.mjs` already COMPUTES built-vs-pending every night. Nothing acted on it. Measuring
14
+ * a gap and never closing it is how 137 repos stayed uningested while the scorecard reported the
15
+ * number cheerfully.
16
+ *
17
+ * WHY IT IS BOUNDED. Ingestion embeds a whole repository; doing 137 in one night would run for
18
+ * hours and starve everything after it in the nightly. `--max` (default 3) keeps the nightly
19
+ * bounded and lets the corpus converge over days instead of blocking on one enormous run. That is
20
+ * a deliberate trade, and the run SAYS how many remain rather than implying it finished.
21
+ *
22
+ * WHAT IT WILL NOT DO. It does not write capability cards. An ingested store with no card is DARK —
23
+ * valid bytes that no by-description query can reach — and `ingest-repo.mjs` already says so on
24
+ * every run. Inventing a card from a repo name would put a confident description in the routing
25
+ * layer that nobody grounded in the source, which is worse than an honest gap: it would route real
26
+ * questions to a corpus that cannot answer them. So this reports the dark stores it created and
27
+ * leaves the card to someone who reads the repo.
28
+ *
29
+ * node scripts/ingest-new-repos.mjs # report only
30
+ * node scripts/ingest-new-repos.mjs --apply # ingest up to --max new repos
31
+ * node scripts/ingest-new-repos.mjs --apply --max 5
32
+ */
33
+ import { execFileSync, spawnSync } from 'node:child_process';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { storeRoot, storesAt, darkStores } from '../kb/store-root.mjs';
37
+
38
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
39
+ const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
40
+ const APPLY = process.argv.includes('--apply');
41
+ const arg = (n, d) => {
42
+ const i = process.argv.indexOf(n);
43
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d;
44
+ };
45
+ const MAX = Number(arg('--max', '3'));
46
+
47
+ /** Every repo the org actually has right now. Live, never a remembered list. */
48
+ function liveOrgRepos() {
49
+ try {
50
+ const out = execFileSync('gh', [
51
+ 'repo', 'list', OWNER, '--limit', '500', '--no-archived', '--json', 'name,isFork,pushedAt,diskUsage',
52
+ ], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 });
53
+ return JSON.parse(out)
54
+ .filter((r) => !r.isFork)
55
+ // AN EMPTY REPO IS NOT A FAILURE TO RETRY FOREVER (measured 2026-08-20).
56
+ //
57
+ // The first bulk run reported 7 failures; six of them are size=0KB — genuinely empty
58
+ // repositories (socket, Auto-GPT, CodeGPT, ruvGPT2, rUvGPT, AIConverse). `git fetch` succeeds
59
+ // and there is simply nothing to embed, so ingestion fails correctly and then the repo stays
60
+ // "missing", so the NEXT night retries it, forever. A permanent nightly failure that is
61
+ // actually correct behaviour trains the reader to ignore the failure line — which is how a
62
+ // real failure would then hide inside it.
63
+ .filter((r) => Number(r.diskUsage ?? 1) > 0)
64
+ // Newest first: if the budget only allows a few tonight, spend it on what rUv shipped most
65
+ // recently, which is what a question is most likely to be about.
66
+ .sort((a, b) => String(b.pushedAt).localeCompare(String(a.pushedAt)))
67
+ .map((r) => r.name);
68
+ } catch (error) {
69
+ return { error: error?.message || String(error) };
70
+ }
71
+ }
72
+
73
+ export function pendingRepos(orgRepos, root = storeRoot()) {
74
+ const have = new Set(storesAt(root).map((s) => String(s).toLowerCase()));
75
+ return orgRepos.filter((name) => !have.has(String(name).toLowerCase()));
76
+ }
77
+
78
+ const isMain = (() => {
79
+ try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
80
+ catch { return false; }
81
+ })();
82
+
83
+ if (isMain) {
84
+ const repos = liveOrgRepos();
85
+ if (!Array.isArray(repos)) {
86
+ // A LIST WE COULD NOT FETCH IS NOT AN EMPTY LIST. Reporting "0 new repos" here would read as
87
+ // "the brain is current" when the truth is "I could not look" — the exact conflation this
88
+ // repository keeps paying for.
89
+ console.error(`[ingest-new] could NOT list ${OWNER}'s repos: ${repos.error}`);
90
+ console.error('[ingest-new] this is NOT "nothing new" — it is "I could not look". Exiting 1.');
91
+ process.exit(1);
92
+ }
93
+
94
+ const root = storeRoot();
95
+ const missing = pendingRepos(repos, root);
96
+ console.log(`[ingest-new] ${OWNER}: ${repos.length} live repos, ${storesAt(root).length} ingested, ${missing.length} missing`);
97
+ if (!missing.length) { console.log('[ingest-new] corpus is level with the org.'); process.exit(0); }
98
+
99
+ console.log(`[ingest-new] next up (newest first): ${missing.slice(0, MAX).join(', ')}`);
100
+ if (!APPLY) {
101
+ console.log(`[ingest-new] report only. Re-run with --apply to ingest up to ${MAX}.`);
102
+ process.exit(0);
103
+ }
104
+
105
+ let done = 0; let failed = 0;
106
+ for (const name of missing.slice(0, MAX)) {
107
+ console.log(`\n[ingest-new] ingesting ${name}`);
108
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'ingest-repo.mjs'), '--name', name],
109
+ { cwd: ROOT, stdio: 'inherit', env: process.env, timeout: 45 * 60_000 });
110
+ if (r.status === 0) done += 1;
111
+ else { failed += 1; console.log(`[ingest-new] ${name} FAILED — left missing so the next run retries it`); }
112
+ }
113
+
114
+ const stillMissing = missing.length - done;
115
+ const dark = darkStores(root);
116
+ console.log(`\n[ingest-new] ingested ${done}, failed ${failed}, ${stillMissing} still missing.`);
117
+ if (dark.length) {
118
+ console.log(`[ingest-new] ${dark.length} store(s) are DARK — valid bytes no by-description query can reach.`);
119
+ console.log('[ingest-new] a card is a claim about what a repo is FOR; write it from the source, not the name.');
120
+ }
121
+ process.exit(failed ? 1 : 0);
122
+ }
@@ -7,6 +7,29 @@
7
7
  # class a blind retry can fix) -> if it fails AGAIN, loud phone alert + a durable marker file that
8
8
  # ground-ruvnet.sh surfaces at the top of the very next session, unprompted. Success or a legitimate
9
9
  # "nothing was due tonight" no-op: log only, phone stays silent.
10
+
11
+ # WHICH NODE RUNS THIS DECIDES WHETHER TONIGHT'S WRITES SURVIVE (measured 2026-08-20).
12
+ #
13
+ # This file hardcoded "$NODE_BIN" in eight places. That is node v22.13.1 (ABI 127), and the
14
+ # better-sqlite3 binding agentdb needs is built for ABI 137 — so under it agentdb SILENTLY falls
15
+ # back to non-persistent sql.js and every write is lost. Silently, because better-sqlite3 is an
16
+ # OPTIONAL dependency (ruvnet/ruflo#2219): the failure never errors and the CLI still prints a
17
+ # success table. `lesson-bridge.mjs --apply` and `learning-replay.mjs` both write, so the nightly
18
+ # was discarding its own results. Measured, same command, same DB, only the interpreter differing:
19
+ # "$NODE_BIN" v22.13.1 -> 2 sql.js fallback msgs, DURABLE=NO
20
+ # /opt/homebrew/bin/node v24.18.0 -> 0 fallback msgs, DURABLE=YES
21
+ # Resolve a node whose ABI matches the binding, and fail loudly rather than silently losing data.
22
+ NODE_BIN=""
23
+ for _cand in /opt/homebrew/opt/node@24/bin/node /opt/homebrew/bin/node "$(command -v node 2>/dev/null)"; do
24
+ [ -x "$_cand" ] || continue
25
+ if [ "$("$_cand" -p 'process.versions.modules' 2>/dev/null)" = "137" ]; then NODE_BIN="$_cand"; break; fi
26
+ done
27
+ if [ -z "$NODE_BIN" ]; then
28
+ echo "[nightly] FATAL: no node with ABI 137 found — agentdb writes would silently not persist." >&2
29
+ echo "[nightly] checked: /opt/homebrew/opt/node@24/bin/node /opt/homebrew/bin/node \$(command -v node)" >&2
30
+ exit 1
31
+ fi
32
+
10
33
  set -u
11
34
  # kb/models-cache (the fallback) starts cold every time -> every nightly re-downloads the ONNX
12
35
  # embedder from HuggingFace. Point at the already-warm cache instead (verified present).
@@ -55,9 +78,9 @@ run_once() {
55
78
  { echo "===== nightly-wrapper rebuild attempt $1 — $(date -u +%FT%TZ) ====="
56
79
  if [ "$SMOKE" = "1" ]; then
57
80
  echo "[SMOKE] dry-run: exercising the full chain, NOT building"
58
- /usr/local/bin/node scripts/self-update.mjs --fresh-window 60
81
+ "$NODE_BIN" scripts/self-update.mjs --fresh-window 60
59
82
  else
60
- /usr/local/bin/node scripts/self-update.mjs --apply --fresh-window 60
83
+ "$NODE_BIN" scripts/self-update.mjs --apply --fresh-window 60
61
84
  fi
62
85
  } >> "$LOG" 2>&1
63
86
  rc=$?
@@ -93,7 +116,7 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memor
93
116
  # it REFUSES to apply when it reads zero rows while bridged lessons are installed — so a transient
94
117
  # read failure can never strip the store. Best-effort, never blocks the rebuild.
95
118
  echo "===== lesson-bridge — $(date -u +%FT%TZ) =====" >> "$LOG"
96
- /usr/local/bin/node plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
119
+ "$NODE_BIN" plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
97
120
  || echo "===== lesson-bridge: SKIPPED (see line above) =====" >> "$LOG"
98
121
 
99
122
  # AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
@@ -141,15 +164,27 @@ sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
141
164
  # REPORTS ONLY. It cannot push, merge, close or publish (asserted by test). Exit 1 means a human
142
165
  # needs to look, and the reasons print in full rather than as a count.
143
166
  echo "===== GITHUB-HEALTH watch — $(date -u +%FT%TZ) =====" >> "$LOG"
144
- /usr/local/bin/node scripts/github-health-watch.mjs >> "$LOG" 2>&1 \
167
+ # LOAD WHAT rUv SHIPPED, WITHOUT BEING ASKED (added 2026-08-20).
168
+ #
169
+ # The owner: "aren't you loading everything Ruv creates every day?????" The answer was no — this
170
+ # file had ZERO ingestion, so new repos only entered the brain when a human typed the command.
171
+ # Measured the day this landed: 181 live repos, 69 ingested, 125 missing. `brain-stamp.mjs` had
172
+ # been COMPUTING that gap nightly and nothing acted on it.
173
+ #
174
+ # Bounded to 3/night on purpose: ingestion embeds a whole repository, and 125 in one run would
175
+ # starve everything after it here. The corpus converges over days and the run says how many
176
+ # remain rather than implying it finished. A failed repo is left missing so the next night retries.
177
+ "$NODE_BIN" scripts/ingest-new-repos.mjs --apply --max 3 >> "$LOG" 2>&1 || true
178
+
179
+ "$NODE_BIN" scripts/github-health-watch.mjs >> "$LOG" 2>&1 \
145
180
  || echo "[github-health] findings above need attention" >> "$LOG"
146
181
 
147
182
  echo "===== RELEASE-CONVERGENCE watchdog — $(date -u +%FT%TZ) =====" >> "$LOG"
148
- /usr/local/bin/node scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
183
+ "$NODE_BIN" scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
149
184
  || echo "[release-watchdog] exited non-zero — see above; nightly continues" >> "$LOG"
150
185
 
151
186
  echo "===== LEARNING-REPLAY counterfactual trap — $(date -u +%FT%TZ) =====" >> "$LOG"
152
- /usr/local/bin/node scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
187
+ "$NODE_BIN" scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
153
188
  LR_RC=$?
154
189
  case "$LR_RC" in
155
190
  0) echo "===== LEARNING-REPLAY: PASS =====" >> "$LOG" ;;
@@ -165,7 +200,7 @@ esac
165
200
  BRAIN_KB="$HOME/.cache/ruvnet-brain/kb"
166
201
  if [ -f "$BRAIN_KB/forge-ask-all.mjs" ]; then
167
202
  echo "===== brain-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
168
- if (cd "$BRAIN_KB" && /usr/local/bin/node forge-ask-all.mjs --dir . --q "HNSW vector index" --k 1) >> "$LOG" 2>&1; then
203
+ if (cd "$BRAIN_KB" && "$NODE_BIN" forge-ask-all.mjs --dir . --q "HNSW vector index" --k 1) >> "$LOG" 2>&1; then
169
204
  echo "===== brain-health canary: OK =====" >> "$LOG"
170
205
  else
171
206
  echo "===== brain-health canary: DOWN — escalating =====" >> "$LOG"
@@ -181,7 +216,7 @@ fi
181
216
  # The canary itself handles urgent pushes on alive->DEAD transitions (and recovery notices), so a
182
217
  # known-dead key doesn't re-alarm every night; here we only log.
183
218
  echo "===== key-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
184
- zsh -lc 'cd /Users/stuartkerr/Code/ruvnet-brain && /usr/local/bin/node scripts/key-canary.mjs --notify' >> "$LOG" 2>&1 \
219
+ zsh -lc 'cd /Users/stuartkerr/Code/ruvnet-brain && "$NODE_BIN" scripts/key-canary.mjs --notify' >> "$LOG" 2>&1 \
185
220
  && echo "===== key-health canary: all present keys alive =====" >> "$LOG" \
186
221
  || echo "===== key-health canary: at least one key DEAD (push sent on new deaths) =====" >> "$LOG"
187
222
 
@@ -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().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
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) {