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.
- package/README.md +26 -3
- package/bin/install.mjs +142 -20
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/decision-outcomes.mjs +0 -0
- package/scripts/brain-score.mjs +17 -8
- package/scripts/build-bundle.mjs +13 -3
- package/scripts/card-from-source.mjs +114 -0
- package/scripts/corpus-candidate.mjs +294 -0
- package/scripts/corpus-reconcile.mjs +273 -0
- package/scripts/corpus-seed-publish.mjs +110 -0
- package/scripts/ingest-new-repos.mjs +122 -0
- package/scripts/nightly-wrapper.sh +43 -8
- package/scripts/rebuild-gists-from-receipts.mjs +246 -0
- package/scripts/release-transaction.mjs +30 -2
- package/scripts/release.mjs +147 -0
- package/scripts/rvf-generation.mjs +44 -0
|
@@ -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
|
-
|
|
81
|
+
"$NODE_BIN" scripts/self-update.mjs --fresh-window 60
|
|
59
82
|
else
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
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 &&
|
|
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()
|
|
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) {
|