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
|
@@ -24,7 +24,7 @@ import path from 'node:path';
|
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { findStores, diagnose } from './memory-doctor.mjs';
|
|
27
|
-
import { loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
|
|
27
|
+
import { learnerCwd, loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
|
|
28
28
|
|
|
29
29
|
const HOME = os.homedir();
|
|
30
30
|
// The SAME project root learn-flush.mjs computes, by the same rule — the two halves of the flush
|
|
@@ -195,7 +195,16 @@ function flushLearning() {
|
|
|
195
195
|
function trainLearning() {
|
|
196
196
|
if (!RUFLO) return { ok: false, log: 'ruflo is not on this machine — install it with `npm i -g ruflo@latest` to enable learning' };
|
|
197
197
|
try {
|
|
198
|
-
|
|
198
|
+
// ISSUE #136: train the SAME store the console card reads. With `cwd: HOME` the card read the
|
|
199
|
+
// project's learner and this trained the home one, so the button could never clear the card it
|
|
200
|
+
// was offered for — a remedy that cannot resolve its own finding is worse than no button.
|
|
201
|
+
// PROJECT is the same root learn-flush.mjs and learn-capture.sh resolve (#134).
|
|
202
|
+
// ISSUE #139 — now that ruflo v3.38.9 made `--train` REAL (ruvnet/ruflo#2940 was a no-op
|
|
203
|
+
// before), training the wrong store is no longer harmless: it moves that store's
|
|
204
|
+
// lastAdaptation to 0s and the card SILENTLY SELF-CLEARS while the learner the operator
|
|
205
|
+
// actually uses is untouched. #136 already moved this off `cwd: HOME`; it now shares the
|
|
206
|
+
// console's resolver so the remedy provably trains the store the card measured.
|
|
207
|
+
execFileSync(RUFLO, ['hooks', 'intelligence', '--train'], { cwd: learnerCwd({ cwd: PROJECT }), env: RUFLO_ENV, stdio: 'ignore', timeout: 600_000 });
|
|
199
208
|
} catch (e) { return { ok: false, log: `training cycle failed: ${e.message}` }; }
|
|
200
209
|
return { ok: true, log: 'ran one training cycle in the cross-project learner' };
|
|
201
210
|
}
|
|
@@ -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
|
+
}
|
package/scripts/ingest-repo.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import fs from 'node:fs';
|
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
|
|
23
|
+
import { storeRoot } from '../kb/store-root.mjs';
|
|
23
24
|
|
|
24
25
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
26
|
const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
|
|
@@ -33,7 +34,26 @@ if (!NAME) {
|
|
|
33
34
|
process.exit(2);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// THE WRITER NOW USES THE SAME RESOLVER AS THE READER. This was `path.join(ROOT, 'kb')` — the
|
|
38
|
+
// repo's build directory — while retrieval (kb/forge-mcp-all.mjs) reads storeRoot(), which defaults
|
|
39
|
+
// to ~/.cache/ruvnet-brain/kb. That split is why on 2026-08-12 three repos were ingested, each
|
|
40
|
+
// printed `roundtrip 3/3 PASS` and "searchable now", and NONE of them could be found by search: the
|
|
41
|
+
// ingest wrote one root and the live brain read another. This file's own header promises "the new
|
|
42
|
+
// repo is searchable immediately", and that promise was false by construction.
|
|
43
|
+
//
|
|
44
|
+
// Two adversarial audits both flagged that fixing the READER alone (kb/store-root.mjs, earlier
|
|
45
|
+
// today) made the split GUARANTEED rather than accidental for a dev checkout. This closes it at the
|
|
46
|
+
// writer, which is the half that was left.
|
|
47
|
+
//
|
|
48
|
+
// The repo's own bundle build still targets ./kb by setting KB_DIR — an explicit override of a
|
|
49
|
+
// stated default, not a second answer to the same question.
|
|
50
|
+
// TWO DIFFERENT THINGS, WHICH THE ORIGINAL `path.join(ROOT,'kb')` CONFLATED and my first fix
|
|
51
|
+
// inherited: where the BUILD SCRIPTS live (forge-refresh.mjs, always in this checkout) and where
|
|
52
|
+
// the STORES go (the root retrieval actually reads). Pointing both at storeRoot() broke immediately
|
|
53
|
+
// with `Cannot find module .../.cache/ruvnet-brain/kb/forge-refresh.mjs` — the tool is not the
|
|
54
|
+
// output. One name for two facts is the same defect as one fact in two names.
|
|
55
|
+
const KB_TOOLS = path.join(ROOT, 'kb'); // where forge-refresh.mjs lives
|
|
56
|
+
const KB = storeRoot(); // where stores go — the root the reader reads
|
|
37
57
|
const CLONES = path.join(ROOT, 'clones');
|
|
38
58
|
const kb = NAME.toLowerCase();
|
|
39
59
|
const dir = SOURCE ? path.resolve(SOURCE) : path.join(CLONES, NAME);
|
|
@@ -62,18 +82,58 @@ const url = process.env.RUVNET_CANONICAL_URL || 'https://raw.githubusercontent.c
|
|
|
62
82
|
// full-body source indexing (the 2026-07-10 depth-restore run zeroed ruvector this way).
|
|
63
83
|
const FULL = arg('--full', FULL_HINTS[kb] || '');
|
|
64
84
|
const KEEP = arg('--keep', KEEP_DIRS[kb] || '');
|
|
65
|
-
|
|
85
|
+
// `--out` is the STORE ROOT, not '.' — cwd is the tools directory now, and '.' would have written
|
|
86
|
+
// the store next to the build script, which is precisely the reader/writer split this change closes.
|
|
87
|
+
const buildArgs = ['forge-refresh.mjs', '--repo', dir, '--out', KB, '--name', kb, '--canonical-url', url];
|
|
66
88
|
if (FULL) buildArgs.push('--full', FULL);
|
|
67
89
|
if (KEEP) buildArgs.push('--keep', KEEP);
|
|
68
90
|
console.log(`[refresh bge-768] ${kb}${FULL ? ' (--full: ' + FULL.split(',').length + ' prefixes)' : ''}${KEEP ? ' (--keep: ' + KEEP + ')' : ''}`);
|
|
69
|
-
run('node', buildArgs, { cwd:
|
|
91
|
+
run('node', buildArgs, { cwd: KB_TOOLS, env });
|
|
70
92
|
console.log(`[symbols] ${kb}`);
|
|
71
93
|
try { run('node', ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env }); } catch { console.log(' (symbols skipped — sparse repo)'); }
|
|
72
94
|
|
|
73
95
|
const ok = fs.existsSync(path.join(KB, `${kb}.big.rvf`))
|
|
74
96
|
&& fs.existsSync(path.join(KB, `${kb}.passages.jsonl`))
|
|
75
97
|
&& fs.existsSync(path.join(KB, `${kb}.meta.json`));
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
// BUILT IS NOT REACHABLE, AND THIS LINE USED TO CLAIM OTHERWISE. It printed "searchable now" on
|
|
99
|
+
// the strength of three files existing. Measured 2026-08-13 on the very next run: helix ingested
|
|
100
|
+
// cleanly, every file present, and `search_ruvnet` answered "Searched 0 RuvNet repos" — the router
|
|
101
|
+
// declined because nothing in capability-cards.md describes the repo, so a by-description query
|
|
102
|
+
// cannot reach it. 26 of 66 stores are in exactly that state. The 2026-08-12 incident (three repos
|
|
103
|
+
// ingested, all reporting success, none findable) was read as a path bug; the path was only half of
|
|
104
|
+
// it. A store with no card is BUILT AND DARK, and saying "searchable" of it is the product lying.
|
|
105
|
+
const carded = (() => {
|
|
106
|
+
try {
|
|
107
|
+
return new RegExp(`^##\\s+${kb}\\s*$`, 'im')
|
|
108
|
+
.test(fs.readFileSync(path.join(KB, 'capability-cards.md'), 'utf8'));
|
|
109
|
+
} catch { return false; }
|
|
110
|
+
})();
|
|
111
|
+
// AN ARTIFACT WITH NO COMMITTED RECIPE IS NOT DURABLE WORK — it is a local side effect with a
|
|
112
|
+
// countdown on it. Proven overnight 2026-08-13 -> 08-14: a scheduled bundle apply extracted an
|
|
113
|
+
// Aug-8 bundle over the live cache. capability-cards.md SURVIVED because it was committed and the
|
|
114
|
+
// brain was restored from the repo in one command. helix, rvQR, RuCelium and wifi-veil — ingested
|
|
115
|
+
// and VERIFIED ROUTING hours earlier — were gone without a trace, because a .rvf is a 32MB
|
|
116
|
+
// gitignored build artifact that existed in exactly one directory on one machine.
|
|
117
|
+
//
|
|
118
|
+
// The stores themselves cannot be committed. The FACT that they were ingested can, so a wipe
|
|
119
|
+
// becomes detectable and replayable instead of silent and permanent. This record is the recipe.
|
|
120
|
+
if (ok) {
|
|
121
|
+
try {
|
|
122
|
+
const ledger = path.join(ROOT, 'kb', 'local-ingests.json');
|
|
123
|
+
const prior = fs.existsSync(ledger) ? JSON.parse(fs.readFileSync(ledger, 'utf8')) : { ingests: [] };
|
|
124
|
+
const rest = (prior.ingests || []).filter((e) => e.name !== NAME);
|
|
125
|
+
rest.push({ name: NAME, org: ORG, at: new Date().toISOString(), store: kb });
|
|
126
|
+
rest.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
fs.writeFileSync(ledger, `${JSON.stringify({ ingests: rest }, null, 2)}\n`);
|
|
128
|
+
console.log(`[record] kb/local-ingests.json — ${rest.length} local ingest(s) recorded. COMMIT THIS.`);
|
|
129
|
+
} catch (e) { console.log(`[record] could not record the ingest: ${e.message}`); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(!ok
|
|
133
|
+
? `\n[FAIL] ${NAME}: expected stores missing after build.`
|
|
134
|
+
: carded
|
|
135
|
+
? `\n[done] ${NAME} ingested AND routable → answers by-description queries now (no restart).`
|
|
136
|
+
: `\n[done] ${NAME} ingested, but DARK: no '## ${kb}' section in ${path.join(KB, 'capability-cards.md')}.\n`
|
|
137
|
+
+ ` The store is valid and a by-description query CANNOT reach it. Write a card describing\n`
|
|
138
|
+
+ ` what ${NAME} is for and when to reach for it, then re-check with a real search_ruvnet query.`);
|
|
79
139
|
process.exit(ok ? 0 : 1);
|
|
@@ -132,9 +132,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
132
132
|
const dirs = buildFixtures(base);
|
|
133
133
|
process.once('exit', () => cleanupFixtureDaemons(dirs));
|
|
134
134
|
const record = recordInProjectA(dirs, { trap });
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
// Every target fixture needs a real AgentDB file before Codex starts. The global SessionStart
|
|
136
|
+
// contract otherwise injects an opt-in question and requires the model to wait for an answer,
|
|
137
|
+
// which prevents both post-task arms from producing the command this trap is meant to compare.
|
|
138
|
+
// Only the memory-search trap needs the independent cache note; post-task needs an initialized,
|
|
139
|
+
// otherwise empty store so its control differs solely on the lesson under test.
|
|
140
|
+
const seed = seedProjectBMemory(dirs, {
|
|
141
|
+
includeFixtureRecord: trap === TRAP.MEMORY_SEARCH,
|
|
142
|
+
});
|
|
138
143
|
const refresh = nightlyRefresh(dirs);
|
|
139
144
|
console.log(` record: ${record.projectCount} sources; promoted=${record.promoted}; ok=${record.ok}`);
|
|
140
145
|
console.log(` seed: ${seed.skipped || `ok=${seed.ok}`}`);
|
|
@@ -23,11 +23,16 @@ import {
|
|
|
23
23
|
const CLAUDE_BIN = process.env.RUVNET_CLAUDE_BIN
|
|
24
24
|
|| path.join(os.homedir(), '.npm-global', 'bin', 'claude');
|
|
25
25
|
const CODEX_BIN = process.env.RUVNET_CODEX_BIN || 'codex';
|
|
26
|
-
const sh = (cmd, args, options = {}) =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const sh = (cmd, args, options = {}) => {
|
|
27
|
+
const [binary, argv] = /\.[cm]?js$/i.test(cmd)
|
|
28
|
+
? [process.execPath, [cmd, ...args]]
|
|
29
|
+
: [cmd, args];
|
|
30
|
+
return spawnSync(binary, argv, {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: 120_000,
|
|
33
|
+
...options,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
31
36
|
const remove = (target) => {
|
|
32
37
|
try { fs.rmSync(target, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
33
38
|
};
|
|
@@ -143,9 +148,23 @@ export function recordInProjectA(dirs, {
|
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
export function seedProjectBMemory(dirs, {
|
|
151
|
+
export function seedProjectBMemory(dirs, {
|
|
152
|
+
ruflo = RUFLO_BIN,
|
|
153
|
+
includeFixtureRecord = true,
|
|
154
|
+
} = {}) {
|
|
147
155
|
const db = path.join(dirs.projectB, '.swarm', 'memory.db');
|
|
148
156
|
const init = initMemoryDb(ruflo, db, dirs.projectB);
|
|
157
|
+
if (!includeFixtureRecord) {
|
|
158
|
+
return {
|
|
159
|
+
db,
|
|
160
|
+
key: null,
|
|
161
|
+
initExit: init.status,
|
|
162
|
+
storeExit: null,
|
|
163
|
+
exactReadExit: null,
|
|
164
|
+
ok: init.status === 0 && fs.existsSync(db),
|
|
165
|
+
skipped: 'AgentDB initialized; memory-search record not required',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
149
168
|
const store = sh(ruflo, [
|
|
150
169
|
'memory', 'store', '-k', PROJECT_B_MEMORY_KEY,
|
|
151
170
|
'--value', PROJECT_B_MEMORY_VALUE, '-n', 'default', '--path', db,
|
|
@@ -454,6 +454,44 @@ export function writeArtifact(file, aggregateResult, meta = {}) {
|
|
|
454
454
|
runs,
|
|
455
455
|
};
|
|
456
456
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
457
|
+
|
|
458
|
+
// AN ENVIRONMENT THAT CANNOT MEASURE MUST NOT OVERWRITE ONE THAT DID.
|
|
459
|
+
// Measured 2026-08-19: `learning-replay` had been red on main since 2026-08-11 because the
|
|
460
|
+
// artifact was re-recorded carrying `verdict: UNKNOWN` and "3/3 run(s) could not be measured;
|
|
461
|
+
// executor error: spawnSync codex ENOENT" — written by a nightly container with no `codex` on
|
|
462
|
+
// PATH. `codex` resolves fine on the owner's machine (0.148.0), so a real PASS was replaced by
|
|
463
|
+
// "I could not look", and every later CI run correctly refused the non-PASS verdict. The gate was
|
|
464
|
+
// right; its INPUT had been destroyed by a host that could never produce one. Same distinction
|
|
465
|
+
// `restore-local-ingests.mjs` and `degradation-watch` both needed: CANNOT-MEASURE is not
|
|
466
|
+
// MEASURED-AND-FAILED. A real FAIL still overwrites — that is a measurement, and it must land.
|
|
467
|
+
// "PARTIALLY MEASURED" IS NOT "COULD NOT MEASURE" either, and the first version of this guard got
|
|
468
|
+
// that wrong within the hour — the same conflation it exists to prevent, committed inside it. Its
|
|
469
|
+
// predicate was a substring match on "could not be measured", so a run reporting
|
|
470
|
+
// "1/3 run(s) could not be measured; 1/3 passed" — a genuine mixed result carrying a live
|
|
471
|
+
// regression signal — was swallowed, leaving a PASS from 2026-08-03 standing. It suppressed
|
|
472
|
+
// exactly the finding it should have let through. A host that cannot measure produces NO
|
|
473
|
+
// measurements: every run unknown. If even one run yielded a pass or a fail, the executor plainly
|
|
474
|
+
// ran and the result is real, however unwelcome.
|
|
475
|
+
const runsTotal = Number(artifact.n ?? 0);
|
|
476
|
+
const runsUnknown = Number(artifact.unknowns ?? 0);
|
|
477
|
+
const nothingMeasured = runsTotal > 0 && runsUnknown === runsTotal;
|
|
478
|
+
const unmeasurable = artifact.verdict === 'UNKNOWN'
|
|
479
|
+
&& nothingMeasured
|
|
480
|
+
&& /ENOENT|executor error/i.test(String(artifact.why ?? ''));
|
|
481
|
+
if (unmeasurable && fs.existsSync(file)) {
|
|
482
|
+
try {
|
|
483
|
+
const prior = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
484
|
+
if (prior?.verdict && prior.verdict !== 'UNKNOWN') {
|
|
485
|
+
process.stderr.write(
|
|
486
|
+
`[learning-replay] REFUSING to overwrite a ${prior.verdict} recorded ${prior.at} with an\n`
|
|
487
|
+
+ ` unmeasurable UNKNOWN — this host cannot run the executor (${artifact.why}).\n`
|
|
488
|
+
+ ' The prior measurement stands. Re-record on a host that has the executor.\n',
|
|
489
|
+
);
|
|
490
|
+
return prior;
|
|
491
|
+
}
|
|
492
|
+
} catch { /* unreadable prior is not a reason to keep a bad one — fall through and write */ }
|
|
493
|
+
}
|
|
494
|
+
|
|
457
495
|
fs.writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`);
|
|
458
496
|
return artifact;
|
|
459
497
|
}
|
|
@@ -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=$?
|
|
@@ -83,6 +106,19 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path .swar
|
|
|
83
106
|
# automatically (keeps last 14), zero risk to the live DB (reads only).
|
|
84
107
|
RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memory.db --keep 14 >> "$LOG" 2>&1 || true
|
|
85
108
|
|
|
109
|
+
# LESSON BRIDGE (ADR-066, 2026-08-10) — carry newly-tagged machine-wide lessons into the store the
|
|
110
|
+
# hooks actually read. Without this the bridge is a command someone has to remember, and a learning
|
|
111
|
+
# system that depends on being remembered is the thing ADR-066 was written to fix: knowledge sat in
|
|
112
|
+
# AgentDB for 18 days while the repo re-made the mistake it described.
|
|
113
|
+
#
|
|
114
|
+
# Safe to run unattended and to run when nothing changed: it is idempotent (bridged rows are replaced
|
|
115
|
+
# wholesale, native rows are never touched), it writes through the store's locked atomic writer, and
|
|
116
|
+
# it REFUSES to apply when it reads zero rows while bridged lessons are installed — so a transient
|
|
117
|
+
# read failure can never strip the store. Best-effort, never blocks the rebuild.
|
|
118
|
+
echo "===== lesson-bridge — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
119
|
+
"$NODE_BIN" plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
|
|
120
|
+
|| echo "===== lesson-bridge: SKIPPED (see line above) =====" >> "$LOG"
|
|
121
|
+
|
|
86
122
|
# AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
|
|
87
123
|
# 2026-07-09/10, the exact nights idx_bridge_key/idx_bridge_ns corruption recurred 3x on this very DB
|
|
88
124
|
# while in WAL mode. Best-effort, same shape as its brain-health/key-health siblings below: never
|
|
@@ -128,15 +164,27 @@ sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
|
|
|
128
164
|
# REPORTS ONLY. It cannot push, merge, close or publish (asserted by test). Exit 1 means a human
|
|
129
165
|
# needs to look, and the reasons print in full rather than as a count.
|
|
130
166
|
echo "===== GITHUB-HEALTH watch — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
131
|
-
|
|
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 \
|
|
132
180
|
|| echo "[github-health] findings above need attention" >> "$LOG"
|
|
133
181
|
|
|
134
182
|
echo "===== RELEASE-CONVERGENCE watchdog — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
135
|
-
|
|
183
|
+
"$NODE_BIN" scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
|
|
136
184
|
|| echo "[release-watchdog] exited non-zero — see above; nightly continues" >> "$LOG"
|
|
137
185
|
|
|
138
186
|
echo "===== LEARNING-REPLAY counterfactual trap — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
139
|
-
|
|
187
|
+
"$NODE_BIN" scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
|
|
140
188
|
LR_RC=$?
|
|
141
189
|
case "$LR_RC" in
|
|
142
190
|
0) echo "===== LEARNING-REPLAY: PASS =====" >> "$LOG" ;;
|
|
@@ -152,7 +200,7 @@ esac
|
|
|
152
200
|
BRAIN_KB="$HOME/.cache/ruvnet-brain/kb"
|
|
153
201
|
if [ -f "$BRAIN_KB/forge-ask-all.mjs" ]; then
|
|
154
202
|
echo "===== brain-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
155
|
-
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
|
|
156
204
|
echo "===== brain-health canary: OK =====" >> "$LOG"
|
|
157
205
|
else
|
|
158
206
|
echo "===== brain-health canary: DOWN — escalating =====" >> "$LOG"
|
|
@@ -168,7 +216,7 @@ fi
|
|
|
168
216
|
# The canary itself handles urgent pushes on alive->DEAD transitions (and recovery notices), so a
|
|
169
217
|
# known-dead key doesn't re-alarm every night; here we only log.
|
|
170
218
|
echo "===== key-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
171
|
-
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 \
|
|
172
220
|
&& echo "===== key-health canary: all present keys alive =====" >> "$LOG" \
|
|
173
221
|
|| echo "===== key-health canary: at least one key DEAD (push sent on new deaths) =====" >> "$LOG"
|
|
174
222
|
|
|
@@ -66,6 +66,7 @@ import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf,
|
|
|
66
66
|
import {
|
|
67
67
|
openRouterCredentialStatus,
|
|
68
68
|
saveOpenRouterCredential,
|
|
69
|
+
learnerCwd,
|
|
69
70
|
} from '../plugin/scripts/runtime-preferences.mjs';
|
|
70
71
|
import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
|
|
71
72
|
// One canonical answer to "which directory is this, and have I counted it already?" — shared with
|
|
@@ -2213,10 +2214,29 @@ function observeLearning() {
|
|
|
2213
2214
|
|
|
2214
2215
|
let lastTrainSeconds = null; let trajectories = 0;
|
|
2215
2216
|
try {
|
|
2217
|
+
// ISSUE #136 — THE LEARNER IS PROJECT-SCOPED, so this must ask about the SERVED project.
|
|
2218
|
+
//
|
|
2219
|
+
// `ruflo hooks intelligence --status` reports `Data Dir: <cwd>/.claude-flow/neural`. With
|
|
2220
|
+
// `cwd: SYSTEM_HOME` this measured `~/.claude-flow/neural` — a store nothing writes to on a
|
|
2221
|
+
// machine whose work happens inside project directories. Measured on one machine, one minute:
|
|
2222
|
+
// the home store held 1,216 trajectories last trained 6.9 DAYS ago while the served project held
|
|
2223
|
+
// 9,940 last trained 22 SECONDS ago. The card said "Your learner has gone quiet" about a learner
|
|
2224
|
+
// training every few seconds.
|
|
2225
|
+
//
|
|
2226
|
+
// This file already carries the verdict on this exact mistake at the refresh-child spawn: "cwd =
|
|
2227
|
+
// the SERVED project, NOT REPO … it was a real console-honesty bug". Same rule, same file,
|
|
2228
|
+
// different call site — #104's and #134's residual arriving a third time.
|
|
2229
|
+
// ISSUE #139 (@ObiWanKenobi) — `process.cwd()` was a HARDCODE in the opposite direction from
|
|
2230
|
+
// #136's `SYSTEM_HOME`. Neither asked which scope was in effect; the first was wrong by default
|
|
2231
|
+
// and the second is right only BECAUSE `project` is the default. Under
|
|
2232
|
+
// `RUVNET_LEARNING_SCOPE=user` the flush feeds ~/.claude-flow/neural while this read
|
|
2233
|
+
// <project>/.claude-flow/neural — the same false-positive card, inverted. The writer and this
|
|
2234
|
+
// reader now call ONE resolver (runtime-preferences.learnerCwd), so they agree by construction
|
|
2235
|
+
// instead of by coincidence.
|
|
2216
2236
|
const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
|
|
2217
2237
|
['hooks', 'intelligence', '--status'],
|
|
2218
2238
|
{
|
|
2219
|
-
cwd:
|
|
2239
|
+
cwd: learnerCwd(),
|
|
2220
2240
|
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
2221
2241
|
encoding: 'utf8',
|
|
2222
2242
|
timeout: 20_000,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* org-repo-count.mjs — how many repos rUv actually has, DERIVED, with the source recorded.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS REPLACES. `orgTotalApprox: 248` was a literal, written twice:
|
|
5
|
+
*
|
|
6
|
+
* scripts/build-bundle.mjs:402 coverage: { …, orgTotalApprox: 248, … }
|
|
7
|
+
* scripts/brain-stamp.mjs:77 coverage: { …, orgTotalApprox: 248, … }
|
|
8
|
+
*
|
|
9
|
+
* Measured 2026-08-12, two independent ways: `users/ruvnet.public_repos` = 200, and a paginated
|
|
10
|
+
* `users/ruvnet/repos` fetch = 200 distinct names. So the denominator every coverage percentage in
|
|
11
|
+
* this product is computed against was wrong by 48 — and wrong in BOTH copies, which is what a
|
|
12
|
+
* restated fact always does.
|
|
13
|
+
*
|
|
14
|
+
* Replacing 248 with 200 would repeat the mistake with a fresher number. The count changes whenever
|
|
15
|
+
* rUv pushes a new repo, so it is not a constant and must not be stored as one.
|
|
16
|
+
*
|
|
17
|
+
* HONESTY OVER AVAILABILITY. A build with no network must not silently invent a total. `source` is
|
|
18
|
+
* carried alongside the number so every consumer can see whether it is live:
|
|
19
|
+
*
|
|
20
|
+
* { count: 200, source: 'live', at: '2026-08-12T…' } ← queried just now
|
|
21
|
+
* { count: 200, source: 'recorded', at: '2026-08-12T…' } ← last live reading, reused offline
|
|
22
|
+
* { count: null, source: 'unknown', at: null } ← never say a number we cannot source
|
|
23
|
+
*
|
|
24
|
+
* A consumer that receives `unknown` must omit the claim rather than print a guess — the same rule
|
|
25
|
+
* sync-census follows when it refuses to write a non-positive census.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
33
|
+
/** The last LIVE reading, so an offline build reuses a real measurement instead of a literal. */
|
|
34
|
+
export const RECORD_PATH = process.env.RUVNET_ORG_COUNT_RECORD
|
|
35
|
+
|| path.join(ROOT, 'data', 'org-repo-count.json');
|
|
36
|
+
|
|
37
|
+
export const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
|
|
38
|
+
|
|
39
|
+
/** Ask GitHub. Returns a positive integer or null — never a guess, never a partial page. */
|
|
40
|
+
export function fetchLiveCount(owner = OWNER, { run = spawnSync } = {}) {
|
|
41
|
+
// `public_repos` is the account's own count: one request, no pagination to get wrong.
|
|
42
|
+
const r = run('gh', ['api', `users/${owner}`, '--jq', '.public_repos'], { encoding: 'utf8', timeout: 30_000 });
|
|
43
|
+
if (r.error || r.status !== 0) return null;
|
|
44
|
+
const n = Number(String(r.stdout || '').trim());
|
|
45
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const readRecord = (file) => {
|
|
49
|
+
try {
|
|
50
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
return Number.isInteger(j?.count) && j.count > 0 ? j : null;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The count, its provenance, and when it was taken.
|
|
57
|
+
* A successful live read updates the record, so the offline fallback is always a real past reading.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* READING A NUMBER MUST NOT MUTATE A TRACKED FILE.
|
|
61
|
+
*
|
|
62
|
+
* `persist` defaults to FALSE, and that default is the fix for a release that could not ship.
|
|
63
|
+
* Every call used to write `data/org-repo-count.json` — a TRACKED file — with a fresh `at`
|
|
64
|
+
* timestamp. Both production callers (`brain-stamp.mjs`, `build-bundle.mjs`) are BUILD scripts, so
|
|
65
|
+
* building the release candidate dirtied the working tree, and `release-qe`'s stabilization seal
|
|
66
|
+
* requires `dirty === false`. Measured 2026-08-19, once the seal was made to name its cause:
|
|
67
|
+
*
|
|
68
|
+
* stabilization seal failed: INVALID_LINEAGE
|
|
69
|
+
* working tree is dirty (1 path(s)):
|
|
70
|
+
* M data/org-repo-count.json
|
|
71
|
+
*
|
|
72
|
+
* That single line had blocked EVERY release since 2026-08-08 (issue #141: nothing published in
|
|
73
|
+
* eleven days while main advanced 46 commits), because the seal previously reported only the code
|
|
74
|
+
* `INVALID_LINEAGE` and named nothing.
|
|
75
|
+
*
|
|
76
|
+
* The committed record still matters — it is the honest offline fallback, so an air-gapped or
|
|
77
|
+
* rate-limited build reuses the last REAL reading instead of inventing one. It just may not be
|
|
78
|
+
* written as a side effect of being read. Refreshing it is now a deliberate act (`--record`), the
|
|
79
|
+
* same separation `refresh-model-catalog.mjs` already uses for the model snapshot.
|
|
80
|
+
*/
|
|
81
|
+
export function orgRepoCount({ owner = OWNER, file = RECORD_PATH, now = new Date(), fetch = fetchLiveCount, persist = false } = {}) {
|
|
82
|
+
const live = fetch(owner);
|
|
83
|
+
if (live) {
|
|
84
|
+
const at = now.toISOString();
|
|
85
|
+
try {
|
|
86
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
87
|
+
if (persist) fs.writeFileSync(file, `${JSON.stringify({ owner, count: live, at }, null, 2)}\n`);
|
|
88
|
+
} catch { /* recording is best-effort; the live number still stands */ }
|
|
89
|
+
return { count: live, source: 'live', at };
|
|
90
|
+
}
|
|
91
|
+
const rec = readRecord(file);
|
|
92
|
+
if (rec) return { count: rec.count, source: 'recorded', at: rec.at || null };
|
|
93
|
+
return { count: null, source: 'unknown', at: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Refreshing the committed record is a DELIBERATE ACT, never a side effect of a build.
|
|
98
|
+
*
|
|
99
|
+
* node scripts/org-repo-count.mjs --record
|
|
100
|
+
*
|
|
101
|
+
* Same separation `refresh-model-catalog.mjs` uses for the model snapshot: a build READS, a human
|
|
102
|
+
* (or a scheduled refresh that commits its own result) WRITES. That boundary is what keeps the
|
|
103
|
+
* working tree clean for `release-qe`'s stabilization seal.
|
|
104
|
+
*/
|
|
105
|
+
const isMain = (() => {
|
|
106
|
+
try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
107
|
+
catch { return false; }
|
|
108
|
+
})();
|
|
109
|
+
|
|
110
|
+
if (isMain) {
|
|
111
|
+
const record = process.argv.includes('--record');
|
|
112
|
+
const r = orgRepoCount({ persist: record });
|
|
113
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
114
|
+
if (record && r.source !== 'live') {
|
|
115
|
+
process.stderr.write('NOT recorded: the live read failed, and a remembered number may not be '
|
|
116
|
+
+ 'rewritten as if it were fresh.\n');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|