ruvnet-brain 4.3.35 → 4.3.36

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,82 @@
1
+ // ADR-0091 D5 — per-store failure isolation for corpus reconciliation.
2
+ //
3
+ // One store failing to refresh must not abort the whole generation. This module owns the three
4
+ // pure decisions that make that safe, so the reconciler, the rehearsal and the tests share them:
5
+ //
6
+ // 1. WHICH failures are retried. Only transient ones (clone, fetch, runner I/O), exactly once, in a
7
+ // fresh worker directory. A corpus-qa failure is never retried: the round-trip sample is
8
+ // deterministic (FNV-1a, corpus-qa.mjs), so a retry re-embeds the whole store to reproduce it.
9
+ // The class comes from WHERE the worker failed and from forge-refresh's exit status, never
10
+ // from log text.
11
+ // 2. HOW MANY stores may be carried or missing before the failure is systemic and the generation
12
+ // must fail loudly: carried + missing <= max(3, 5% of eligible).
13
+ // 3. WHETHER a degraded generation may be published at all. Installed clients judge every bundle
14
+ // with their INSTALLED validator, and every validator shipped before this change rejects any
15
+ // non-CURRENT eligible row. ADR-0091 D10 therefore gates degraded publication behind a
16
+ // transition release plus a 14-day soak; until D10 records that transition, a degraded
17
+ // generation is built and sealed but never published.
18
+
19
+ /** kb/forge-refresh.mjs exits with this status when corpus-qa rejected its candidate. */
20
+ export const CORPUS_QA_FAILED_EXIT = 3;
21
+
22
+ export const FAILURE_CLASS = Object.freeze({
23
+ TRANSIENT: 'transient',
24
+ QA: 'qa',
25
+ BUILD: 'build',
26
+ INTEGRITY: 'integrity',
27
+ });
28
+
29
+ /** A worker failure carrying its structured class. `detail` is for logs only, never for shipped rows. */
30
+ export class StoreWorkerError extends Error {
31
+ constructor({ store, stage, failureClass, detail = '' }) {
32
+ super(`[corpus-reconcile] ${store}: ${stage} failed (${failureClass})${detail ? ` (${detail})` : ''}`);
33
+ this.name = 'StoreWorkerError';
34
+ this.store = store;
35
+ this.stage = stage;
36
+ this.failureClass = failureClass;
37
+ }
38
+ }
39
+
40
+ export function isRetryable(error) {
41
+ return error instanceof StoreWorkerError && error.failureClass === FAILURE_CLASS.TRANSIENT;
42
+ }
43
+
44
+ /**
45
+ * The shipped, machine-readable reason. Stage and class only: stderr can quote runner paths, and a
46
+ * coverage row travels to every user.
47
+ */
48
+ export function failureReason(error) {
49
+ if (error instanceof StoreWorkerError) return `${error.failureClass}: ${error.stage} failed`;
50
+ return `${FAILURE_CLASS.INTEGRITY}: worker failed`;
51
+ }
52
+
53
+ /** Fixed rule: the failure is systemic above max(3, 5% of eligible). */
54
+ export function degradedBound(eligibleCount) {
55
+ if (!Number.isSafeInteger(eligibleCount) || eligibleCount < 0) throw new Error('eligible count must be a non-negative integer');
56
+ return Math.max(3, Math.floor(eligibleCount * 0.05));
57
+ }
58
+
59
+ /**
60
+ * ADR-0091 D10 owns this value. It stays null until the transition release N (the first release
61
+ * shipping the tolerant validator while its own coverage is all-CURRENT) has become releases/latest;
62
+ * D10 then records { version, latestSince } here. Null means no transition: never publish degraded.
63
+ */
64
+ export const VALIDATOR_TRANSITION = null;
65
+ export const TRANSITION_SOAK_DAYS = 14;
66
+
67
+ export function degradedPublication({ transition = VALIDATOR_TRANSITION, now = new Date() } = {}) {
68
+ if (!transition) {
69
+ return { allowed: false,
70
+ reason: 'no tolerant-validator transition release is recorded (ADR-0091 D10); installed clients would reject a non-CURRENT row' };
71
+ }
72
+ const since = Date.parse(transition.latestSince);
73
+ if (typeof transition.version !== 'string' || !transition.version || !Number.isFinite(since)) {
74
+ return { allowed: false, reason: 'the recorded validator transition is malformed' };
75
+ }
76
+ const soakEnds = since + TRANSITION_SOAK_DAYS * 24 * 60 * 60 * 1000;
77
+ if (now.getTime() < soakEnds) {
78
+ return { allowed: false,
79
+ reason: `validator transition ${transition.version} soaks until ${new Date(soakEnds).toISOString()} (ADR-0091 D10)` };
80
+ }
81
+ return { allowed: true, reason: `validator transition ${transition.version} has soaked ${TRANSITION_SOAK_DAYS} days` };
82
+ }
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+ // scripts/corpus-watchdog.mjs — is the customer corpus ACTUALLY being refreshed every night?
3
+ //
4
+ // THE FAILURE THIS EXISTS TO END (2026-09-28/29, measured on live run history). The nightly
5
+ // dispatcher `corpus-nightly-dispatch.yml` stood down in ~13 seconds and finished GREEN, night after
6
+ // night, because repository variable CORPUS_NIGHTLY was unset. A green run is not a refreshed corpus:
7
+ // nothing was dispatched, nothing was published, and ntfy-alerts only pages on red. Before that, nine
8
+ // corpus-mode protected-release failures in a row never paged at all (a GITHUB_TOKEN dispatch cannot
9
+ // fire workflow_run). Silence was read as health — the exact failure config/scheduled-jobs.json names:
10
+ // ABSENCE OF EVIDENCE IS FAILURE.
11
+ //
12
+ // So this watchdog does not ask "did a workflow go green?". It asks for EVIDENCE OF AN OUTCOME:
13
+ // RED (a) no night ended `published` or `no-change` in the last 48h (catches silent stand-downs)
14
+ // (b) the newest corpus run of the last 24h failed (or was cancelled / timed out)
15
+ // (c) the newest vX.Y.Z code release has no VERIFIED public-verification aggregate 24h after
16
+ // publish, or carries one that does not verify (the nightly cannot arm without it)
17
+ // (d) any store has been deferred (carried STALE or MISSING) for more than 7 days
18
+ // WARNING a superseded or degraded night, a stand-down tonight, an unknown outcome, a long run
19
+ // GREEN none of the above
20
+ //
21
+ // Exit code is the contract (same as scripts/github-health-watch.mjs): 0 = GREEN or WARNING,
22
+ // 1 = RED or the evidence could not be gathered. corpus-watchdog.yml runs on a genuine `schedule:`,
23
+ // so its red conclusion fires ntfy-alerts.yml's workflow_run listener — no secret needed here.
24
+ //
25
+ // READ-ONLY. Every gh call below is a list/view/download. It never dispatches, edits, or publishes.
26
+ // Every `--json` field used is one gh 2.101.0 lists for that subcommand (the redesign that created
27
+ // this file exists because release.mjs asked `gh release view` for `isLatest`, which only
28
+ // `gh release list` has); tests/unit/corpus-watchdog.test.mjs pins the lists.
29
+ //
30
+ // node scripts/corpus-watchdog.mjs [--repo owner/name] [--json] [--now ISO]
31
+
32
+ import crypto from 'node:crypto';
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+ import { spawnSync } from 'node:child_process';
37
+ import { fileURLToPath, pathToFileURL } from 'node:url';
38
+ import { CODE_TAG_PATTERN, isCorpusReleaseTag } from './release-channel-kind.mjs';
39
+ import { AGGREGATE_ASSET, SIGNING_PUBLIC_KEY_FILE } from './approved-runtime.mjs';
40
+ import { COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET, verifyCoverageSidecar } from './corpus-coverage-sidecar.mjs';
41
+
42
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
43
+ export const RED = 'RED';
44
+ export const WARNING = 'WARNING';
45
+ export const GREEN = 'GREEN';
46
+ export const INFO = 'INFO';
47
+ const HOUR = 3_600_000;
48
+ export const FRESHNESS_WINDOW_MS = 48 * HOUR;
49
+ export const TONIGHT_WINDOW_MS = 24 * HOUR;
50
+ export const AGGREGATE_GRACE_MS = 24 * HOUR;
51
+ export const DEFERRAL_LIMIT_MS = 7 * 24 * HOUR;
52
+ export const LONG_RUN_MS = 8 * HOUR;
53
+ /** protected-release.yml's run-name for a corpus run: `protected-release corpus <dispatch id>`. */
54
+ export const CORPUS_RUN_TITLE = /^protected-release corpus (\S+)$/;
55
+ /** The dispatch id corpus-nightly-dispatch.yml passes: `corpus-<its run id>-<its attempt>`. */
56
+ const DISPATCH_ID = /^corpus-(\d+)-(\d+)$/;
57
+ /** Terminal outcomes a corpus-release-outcome.json may declare in an `outcome` field (design D2). */
58
+ const DECLARED_OUTCOMES = new Set(['published', 'no-change', 'superseded', 'degraded', 'failed']);
59
+ const GOOD_NIGHT = new Set(['published', 'no-change']);
60
+
61
+ const ms = (iso) => {
62
+ const t = Date.parse(String(iso ?? ''));
63
+ return Number.isFinite(t) ? t : null;
64
+ };
65
+ const hours = (delta) => `${(delta / HOUR).toFixed(1)}h`;
66
+
67
+ /**
68
+ * What ONE protected-release corpus run proved. Preference order: an explicit `outcome` in the run's
69
+ * corpus-release-outcome.json, then that record's per-job results, then the run's own job list.
70
+ * A non-success run conclusion is `failed` unless the record says otherwise (a typed superseded exit
71
+ * is a warning, not red). A success that shows neither a publish nor a no-change round is `unknown`
72
+ * — never counted as a good night.
73
+ */
74
+ export function classifyCorpusRun(run) {
75
+ if (run?.status !== 'completed') return 'in-progress';
76
+ const declared = String(run.outcomeRecord?.outcome ?? '').replace('no_change', 'no-change');
77
+ if (DECLARED_OUTCOMES.has(declared)) return declared;
78
+ if (run.conclusion !== 'success') return 'failed';
79
+ const recorded = run.outcomeRecord?.jobs;
80
+ const byName = new Map((run.jobs || []).map((job) => [job.name, job.conclusion]));
81
+ const noChange = recorded?.no_change ?? byName.get('corpus-no-change-round');
82
+ const publish = recorded?.publish ?? byName.get('protected-corpus-publisher');
83
+ if (noChange === 'success') return 'no-change';
84
+ if (publish === 'success') return 'published';
85
+ return 'unknown';
86
+ }
87
+
88
+ /**
89
+ * How long each store in the NEWEST generation has been deferred: walk generations newest-first while
90
+ * the store stays carried/missing; it has been deferred since the oldest generation in that unbroken
91
+ * run. A generation whose coverage is unknown (published before the D6.2 sidecar, or not fetched)
92
+ * ends the walk with `lowerBound: true` — the true deferral is at least that long.
93
+ * `generations`: [{ tag, publishedAt, degraded: { carried, missing } | null }], newest first.
94
+ */
95
+ export function deferralSince(generations) {
96
+ const [newest, ...older] = generations || [];
97
+ if (!newest?.degraded) return [];
98
+ const stores = [...new Set([...(newest.degraded.carried || []), ...(newest.degraded.missing || [])])].sort();
99
+ return stores.map((store) => {
100
+ let since = newest.publishedAt;
101
+ let lowerBound = true; // until a generation proves the store was NOT deferred, history ran out first
102
+ for (const generation of older) {
103
+ if (!generation.degraded) break;
104
+ if (![...(generation.degraded.carried || []), ...(generation.degraded.missing || [])].includes(store)) { lowerBound = false; break; }
105
+ since = generation.publishedAt;
106
+ }
107
+ return { store, since, lowerBound };
108
+ });
109
+ }
110
+
111
+ /**
112
+ * The pure verdict. `input`:
113
+ * corpusReleases [{ tag, publishedAt }] non-draft corpus-sha256-* releases
114
+ * corpusRuns [{ id, title, status, conclusion, createdAt, updatedAt, outcome }] protected-release corpus runs
115
+ * dispatcherRuns [{ id, attempt, status, conclusion, createdAt }] corpus-nightly-dispatch runs
116
+ * codeRelease { tag, publishedAt, aggregate: { state: verified|missing|invalid, reason } } | null
117
+ * deferral { observable, reason, newestTag, degraded: {carried, missing}, stores: [{store, since, lowerBound}] }
118
+ */
119
+ export function judgeCorpusHealth(input, now) {
120
+ const at = now instanceof Date ? now.getTime() : Number(now);
121
+ if (!Number.isFinite(at)) throw new Error('judgeCorpusHealth needs a valid `now`');
122
+ const findings = [];
123
+ const add = (level, check, detail) => findings.push({ level, check, detail });
124
+ const age = (iso) => { const t = ms(iso); return t === null || t > at ? null : at - t; };
125
+ const runs = (input?.corpusRuns || []).map((run) => ({ ...run, outcome: run.outcome ?? classifyCorpusRun(run) }));
126
+ const dispatchers = input?.dispatcherRuns || [];
127
+
128
+ // Dispatcher runs that finished without creating a corpus run: stand-downs (the silent failure).
129
+ const dispatchedIds = new Set(runs.map((run) => CORPUS_RUN_TITLE.exec(String(run.title || ''))?.[1]).filter(Boolean));
130
+ const standDowns = dispatchers.filter((d) => d.status === 'completed' && d.conclusion === 'success'
131
+ && !dispatchedIds.has(`corpus-${d.id}-${d.attempt ?? 1}`));
132
+
133
+ // (a) a good night inside the freshness window. Inclusive: exactly 48h old still counts.
134
+ const evidence = [
135
+ ...(input?.corpusReleases || []).map((release) => ({ kind: 'published', at: release.publishedAt, ref: release.tag })),
136
+ ...runs.filter((run) => GOOD_NIGHT.has(run.outcome)).map((run) => ({ kind: run.outcome, at: run.updatedAt, ref: `run ${run.id}` })),
137
+ ].filter((item) => age(item.at) !== null).sort((a, b) => ms(b.at) - ms(a.at));
138
+ const lastGoodNight = evidence[0] || null;
139
+ if (!lastGoodNight || age(lastGoodNight.at) > FRESHNESS_WINDOW_MS) {
140
+ const recentStandDowns = standDowns.filter((d) => (age(d.createdAt) ?? Infinity) <= FRESHNESS_WINDOW_MS).length;
141
+ add(RED, 'freshness', `no night ended published or no-change in the last 48h `
142
+ + `(last: ${lastGoodNight ? `${lastGoodNight.kind} ${lastGoodNight.ref} ${hours(age(lastGoodNight.at))} ago` : 'never observed'}; `
143
+ + `${recentStandDowns} dispatcher stand-down(s) in the window — a green dispatcher that dispatched nothing is not a refreshed corpus)`);
144
+ } else {
145
+ add(GREEN, 'freshness', `${lastGoodNight.kind} ${lastGoodNight.ref} ${hours(age(lastGoodNight.at))} ago`);
146
+ }
147
+
148
+ // (b) tonight: the newest corpus run created in the last 24h.
149
+ const tonight = runs.filter((run) => (age(run.createdAt) ?? Infinity) <= TONIGHT_WINDOW_MS)
150
+ .sort((a, b) => ms(b.createdAt) - ms(a.createdAt))[0];
151
+ const lastDispatcher = dispatchers.filter((d) => (age(d.createdAt) ?? Infinity) <= TONIGHT_WINDOW_MS)
152
+ .sort((a, b) => ms(b.createdAt) - ms(a.createdAt))[0];
153
+ if (tonight) {
154
+ const ref = `corpus run ${tonight.id} (${tonight.title})`;
155
+ if (tonight.outcome === 'failed') add(RED, 'tonight', `${ref} failed (conclusion ${tonight.conclusion})`);
156
+ else if (tonight.outcome === 'superseded') add(WARNING, 'tonight', `${ref} was superseded by a newer code release before publish`);
157
+ else if (tonight.outcome === 'degraded') add(WARNING, 'tonight', `${ref} published a degraded generation`);
158
+ else if (tonight.outcome === 'unknown') add(WARNING, 'tonight', `${ref} succeeded but shows neither a publish nor a no-change round`);
159
+ else if (tonight.outcome === 'in-progress') {
160
+ add((age(tonight.createdAt) ?? 0) > LONG_RUN_MS ? WARNING : INFO, 'tonight', `${ref} still running after ${hours(age(tonight.createdAt) ?? 0)}`);
161
+ } else add(GREEN, 'tonight', `${ref} ended ${tonight.outcome}`);
162
+ } else if (!lastDispatcher) {
163
+ add(WARNING, 'tonight', 'no corpus-nightly-dispatch run in the last 24h (schedule dropped or disabled?)');
164
+ } else if (lastDispatcher.status === 'completed' && lastDispatcher.conclusion !== 'success') {
165
+ add(RED, 'tonight', `corpus-nightly-dispatch run ${lastDispatcher.id} concluded ${lastDispatcher.conclusion}`);
166
+ } else if (standDowns.includes(lastDispatcher)) {
167
+ add(WARNING, 'tonight', `corpus-nightly-dispatch run ${lastDispatcher.id} stood down: nothing was dispatched `
168
+ + '(CORPUS_NIGHTLY off, or no install-verified runtime yet)');
169
+ } else {
170
+ add(GREEN, 'tonight', `corpus-nightly-dispatch run ${lastDispatcher.id} is ${lastDispatcher.status}`);
171
+ }
172
+
173
+ // (c) the newest code release must reach a verified aggregate within 24h of publish.
174
+ const code = input?.codeRelease;
175
+ if (!code) add(RED, 'code-aggregate', 'no published vX.Y.Z code release was found');
176
+ else if (code.aggregate?.state === 'verified') add(GREEN, 'code-aggregate', `${code.tag} carries a verified PASS aggregate`);
177
+ else if (code.aggregate?.state === 'invalid') add(RED, 'code-aggregate', `${code.tag} carries an aggregate that does not verify `
178
+ + `under this checkout's verifier (the one approved-runtime --resolve runs, so the nightly will refuse it too): ${code.aggregate.reason}`);
179
+ else {
180
+ const since = age(code.publishedAt);
181
+ if (since === null || since >= AGGREGATE_GRACE_MS) {
182
+ add(RED, 'code-aggregate', `${code.tag} was published ${since === null ? 'at an unknown time' : `${hours(since)} ago`} `
183
+ + 'and still has no public-verification aggregate — the nightly cannot arm');
184
+ } else add(INFO, 'code-aggregate', `${code.tag} is ${hours(since)} old; aggregate pending (grace 24h)`);
185
+ }
186
+
187
+ // (d) deferred stores. Only observable from a generation that carries the D6.2 coverage sidecar.
188
+ const deferral = input?.deferral;
189
+ if (!deferral?.observable) {
190
+ add(INFO, 'deferral', `not observable: ${deferral?.reason || 'no coverage evidence gathered'}`);
191
+ } else {
192
+ const overdue = (deferral.stores || []).filter((row) => (age(row.since) ?? 0) > DEFERRAL_LIMIT_MS);
193
+ if (overdue.length) {
194
+ add(RED, 'deferral', `${overdue.length} store(s) deferred more than 7 days: ${overdue.slice(0, 10)
195
+ .map((row) => `${row.store} (${row.lowerBound ? '≥' : ''}${hours(age(row.since))})`).join(', ')}`);
196
+ } else add(GREEN, 'deferral', `${(deferral.stores || []).length} deferred store(s), none older than 7 days`);
197
+ const degraded = [...(deferral.degraded?.carried || []), ...(deferral.degraded?.missing || [])];
198
+ if (degraded.length) add(WARNING, 'degraded', `${deferral.newestTag} is degraded: ${deferral.degraded.carried.length} carried, ${deferral.degraded.missing.length} missing`);
199
+ }
200
+
201
+ const verdict = findings.some((f) => f.level === RED) ? RED : findings.some((f) => f.level === WARNING) ? WARNING : GREEN;
202
+ return { verdict, findings, lastGoodNight };
203
+ }
204
+
205
+ // ── Gathering (read-only gh). Everything above is pure; everything below talks to GitHub. ──────────
206
+
207
+ function defaultGh(args) {
208
+ const result = spawnSync(process.env.RUVNET_GH_COMMAND || 'gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 5 * 60_000 });
209
+ if (result.error || result.status !== 0) {
210
+ throw new Error(`gh ${args.slice(0, 3).join(' ')} failed: ${String(result.error?.message || result.stderr || `exit ${result.status}`).trim().slice(0, 300)}`);
211
+ }
212
+ return result.stdout;
213
+ }
214
+
215
+ const semver = (tag) => CODE_TAG_PATTERN.test(tag) ? tag.slice(1).split('.').map(Number) : null;
216
+ /** Same selection as scripts/approved-runtime.mjs --resolve: newest by semver, no drafts, no prereleases. */
217
+ function newestCodeRelease(rows) {
218
+ return rows.filter((row) => !row.isDraft && !row.isPrerelease && semver(row.tagName))
219
+ .sort((a, b) => { const x = semver(a.tagName); const y = semver(b.tagName); return y[0] - x[0] || y[1] - x[1] || y[2] - x[2]; })[0] || null;
220
+ }
221
+
222
+ function assetNames(gh, repo, tag) {
223
+ return new Set((JSON.parse(gh(['release', 'view', tag, '--repo', repo, '--json', 'assets'])).assets || []).map((asset) => asset.name));
224
+ }
225
+ function download(gh, repo, tag, name, dir) {
226
+ gh(['release', 'download', tag, '--repo', repo, '--pattern', name, '--dir', dir, '--clobber']);
227
+ return fs.readFileSync(path.join(dir, name));
228
+ }
229
+
230
+ async function aggregateState({ gh, repo, tag, root, scratch }) {
231
+ if (!assetNames(gh, repo, tag).has(AGGREGATE_ASSET)) return { state: 'missing' };
232
+ try {
233
+ const aggregate = JSON.parse(download(gh, repo, tag, AGGREGATE_ASSET, scratch).toString('utf8'));
234
+ const { verifyPublicVerificationAggregate } = await import('./public-verification-aggregate.mjs');
235
+ verifyPublicVerificationAggregate(aggregate, crypto.createPublicKey(fs.readFileSync(path.join(root, SIGNING_PUBLIC_KEY_FILE), 'utf8')));
236
+ if (aggregate.identity?.tag !== tag) throw new Error(`aggregate describes ${aggregate.identity?.tag}, not ${tag}`);
237
+ return { state: 'verified' };
238
+ } catch (error) {
239
+ return { state: 'invalid', reason: error.message.slice(0, 200) };
240
+ }
241
+ }
242
+
243
+ function generationCoverage({ gh, repo, tag, scratch }) {
244
+ const names = assetNames(gh, repo, tag);
245
+ if (!names.has(COVERAGE_ASSET) || !names.has(COVERAGE_RECEIPT_ASSET)) return null;
246
+ const dir = fs.mkdtempSync(path.join(scratch, 'coverage-'));
247
+ const { degraded } = verifyCoverageSidecar({
248
+ sidecar: JSON.parse(download(gh, repo, tag, COVERAGE_RECEIPT_ASSET, dir).toString('utf8')),
249
+ coverageBytes: download(gh, repo, tag, COVERAGE_ASSET, dir),
250
+ generationTag: tag,
251
+ archiveSha256: tag.slice('corpus-sha256-'.length),
252
+ });
253
+ return degraded;
254
+ }
255
+
256
+ function corpusRunEvidence({ gh, repo, run, scratch }) {
257
+ if (run.status !== 'completed') return {};
258
+ const dir = fs.mkdtempSync(path.join(scratch, 'outcome-'));
259
+ try {
260
+ gh(['run', 'download', String(run.databaseId), '--repo', repo, '--name', `corpus-release-outcome-${run.databaseId}-${run.attempt ?? 1}`, '--dir', dir]);
261
+ return { outcomeRecord: JSON.parse(fs.readFileSync(path.join(dir, 'corpus-release-outcome.json'), 'utf8')) };
262
+ } catch {
263
+ // Runs that never reached corpus-terminal-outcome have no record; their job list still says what happened.
264
+ return { jobs: JSON.parse(gh(['run', 'view', String(run.databaseId), '--repo', repo, '--json', 'jobs'])).jobs || [] };
265
+ }
266
+ }
267
+
268
+ export async function gatherCorpusHealthInput({ repo, now, gh = defaultGh, root = ROOT, scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-watchdog-')) }) {
269
+ const at = now.getTime();
270
+ const recent = (iso) => (ms(iso) ?? 0) >= at - 3 * 24 * HOUR;
271
+ const dispatcherRuns = JSON.parse(gh(['run', 'list', '--repo', repo, '--workflow', 'corpus-nightly-dispatch.yml', '--limit', '30',
272
+ '--json', 'databaseId,attempt,event,status,conclusion,createdAt,updatedAt']))
273
+ .map((run) => ({ id: run.databaseId, attempt: run.attempt, event: run.event, status: run.status, conclusion: run.conclusion, createdAt: run.createdAt }));
274
+ const corpusRuns = JSON.parse(gh(['run', 'list', '--repo', repo, '--workflow', 'protected-release.yml', '--limit', '60',
275
+ '--json', 'databaseId,attempt,displayTitle,status,conclusion,createdAt,updatedAt']))
276
+ .filter((run) => CORPUS_RUN_TITLE.test(String(run.displayTitle || '')) && recent(run.createdAt))
277
+ .map((run) => ({ id: run.databaseId, title: run.displayTitle, status: run.status, conclusion: run.conclusion,
278
+ createdAt: run.createdAt, updatedAt: run.updatedAt, ...corpusRunEvidence({ gh, repo, run, scratch }) }));
279
+ const releases = JSON.parse(gh(['release', 'list', '--repo', repo, '--limit', '100', '--json', 'tagName,publishedAt,isDraft,isPrerelease']));
280
+ const corpusReleases = releases.filter((row) => !row.isDraft && isCorpusReleaseTag(row.tagName))
281
+ .map((row) => ({ tag: row.tagName, publishedAt: row.publishedAt }))
282
+ .sort((a, b) => (ms(b.publishedAt) ?? 0) - (ms(a.publishedAt) ?? 0));
283
+ const newestCode = newestCodeRelease(releases);
284
+ const codeRelease = newestCode && { tag: newestCode.tagName, publishedAt: newestCode.publishedAt,
285
+ aggregate: await aggregateState({ gh, repo, tag: newestCode.tagName, root, scratch }) };
286
+
287
+ // Deferral: newest generation's verified coverage, then older ones only while it can still matter.
288
+ let deferral = { observable: false, reason: 'no corpus release is published' };
289
+ if (corpusReleases.length) {
290
+ const generations = [];
291
+ for (const release of corpusReleases.slice(0, 15)) {
292
+ const degraded = generationCoverage({ gh, repo, tag: release.tag, scratch });
293
+ generations.push({ ...release, degraded });
294
+ const pending = generations[0].degraded && [...generations[0].degraded.carried, ...generations[0].degraded.missing];
295
+ if (!degraded || !pending?.length || (ms(release.publishedAt) ?? 0) < at - DEFERRAL_LIMIT_MS - 24 * HOUR) break;
296
+ }
297
+ deferral = generations[0].degraded
298
+ ? { observable: true, newestTag: generations[0].tag, degraded: generations[0].degraded, stores: deferralSince(generations) }
299
+ // Generations published before ADR-0091 D6.2 carry no CORPUS-COVERAGE.json / coverage-receipt.json,
300
+ // so which stores were carried, and since when, is simply not recorded anywhere a reader can verify.
301
+ : { observable: false, reason: `${generations[0].tag} predates the D6.2 coverage sidecar (${COVERAGE_ASSET} + ${COVERAGE_RECEIPT_ASSET})` };
302
+ }
303
+ return { corpusReleases, corpusRuns, dispatcherRuns, codeRelease, deferral };
304
+ }
305
+
306
+ export function renderReport(result, now) {
307
+ const lines = [`corpus-watchdog ${result.verdict} at ${now.toISOString()}`];
308
+ for (const f of result.findings) lines.push(` [${f.level}] ${f.check}: ${f.detail}`);
309
+ return `${lines.join('\n')}\n`;
310
+ }
311
+
312
+ async function main(argv = process.argv.slice(2)) {
313
+ const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
314
+ const repo = opt('--repo') || process.env.GITHUB_REPOSITORY || 'stuinfla/ruvnet-brain';
315
+ const now = opt('--now') ? new Date(opt('--now')) : new Date();
316
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !Number.isFinite(now.getTime())) {
317
+ process.stderr.write('usage: corpus-watchdog.mjs [--repo owner/name] [--json] [--now ISO]\n');
318
+ return 2;
319
+ }
320
+ let result;
321
+ try {
322
+ result = judgeCorpusHealth(await gatherCorpusHealthInput({ repo, now }), now);
323
+ } catch (error) {
324
+ // Could not observe = could not prove health. Absence of evidence is failure.
325
+ process.stderr.write(`corpus-watchdog RED: evidence could not be gathered: ${error.message}\n`);
326
+ return 1;
327
+ }
328
+ process.stdout.write(argv.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : renderReport(result, now));
329
+ return result.verdict === RED ? 1 : 0;
330
+ }
331
+
332
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
333
+ main().then((code) => { process.exitCode = code; });
334
+ }
@@ -0,0 +1,72 @@
1
+ // scripts/fixture-denominator.mjs — ADR-0091 D6.4 (and the one retirement function D7.7 requires).
2
+ //
3
+ // The retrieval fixture is FROZEN (182 human questions, one per repository, digest-bound). Live
4
+ // coverage is not: 194+ eligible repositories and growing. The release canary used to demand that
5
+ // the two sets be EQUAL, which a living corpus can never satisfy. The reconciled decision:
6
+ //
7
+ // available = eligible repository rows that ship a store (CURRENT, or STALE with a verified carry)
8
+ // retired = fixture repositories with NO row at all in a complete coverage observation (D7.2)
9
+ // questioned = fixture ∩ available -- every one is asked its frozen question
10
+ // blocking = fixture − available − retired -- the release refuses (e.g. MISSING, INELIGIBLE)
11
+ // unfixturedEligible = available − fixture -- RECORDED, never blocking; the fixture stays frozen
12
+ //
13
+ // Retirement only ever REMOVES a question from the denominator; it never adds a passing answer. The
14
+ // signal is "absent from a sealed, complete enumeration" -- it cannot tell deleted from privatized
15
+ // from renamed, which is acceptable for exactly that reason (ADR-0091 D7.2).
16
+
17
+ import { eligibleRepositoryStanding } from '../plugin/scripts/coverage-integrity.mjs';
18
+
19
+ const lower = (value) => String(value || '').toLowerCase();
20
+ // localeCompare, exactly as scripts/retrieval-canary.mjs orders and set-checks these lists. Default
21
+ // code-unit sort disagrees on real store names ('chatgpt-…' vs 'chatgpt_…'), which the canary's
22
+ // checkedSet rejects as 'eligible denominator set is invalid'.
23
+ const ordered = (values) => [...new Set(values)].sort((a, b) => a.localeCompare(b));
24
+
25
+ /** True only for a repository enumeration the coverage itself proves complete and terminal. */
26
+ export function repositoryEnumerationComplete(coverage) {
27
+ const repositories = (coverage?.rows || []).filter((row) => row?.kind === 'repository');
28
+ const enumeration = coverage?.enumerationReceipt;
29
+ return enumeration?.terminal === true && enumeration?.duplicateKeys === 0
30
+ && enumeration?.repositories?.expected === repositories.length;
31
+ }
32
+
33
+ /** D7.2: fixture stores with no repository row at all, under a complete enumeration; else none. */
34
+ export function retiredFixtureStores({ coverage, fixtureStores }) {
35
+ if (!repositoryEnumerationComplete(coverage)) return [];
36
+ const present = new Set();
37
+ for (const row of (coverage.rows || []).filter((entry) => entry?.kind === 'repository')) {
38
+ present.add(lower(row.name));
39
+ if (row.artifact?.store) present.add(lower(row.artifact.store));
40
+ }
41
+ return ordered(fixtureStores.map(lower).filter((store) => !present.has(store)));
42
+ }
43
+
44
+ export function fixtureDenominator({ coverage, fixtureStores }) {
45
+ const fixture = ordered((fixtureStores || []).map(lower));
46
+ if (!fixture.length || fixture.some((store) => !store)) throw new Error('fixture store set is empty or malformed');
47
+ const eligible = (coverage?.rows || []).filter((row) => row?.kind === 'repository' && row.disposition === 'eligible');
48
+ const available = new Map();
49
+ for (const row of eligible) {
50
+ if (eligibleRepositoryStanding(row) !== 'shipped') continue;
51
+ const store = lower(row.artifact?.store);
52
+ if (!store) throw new Error(`eligible repository row ${row.key} names no store`);
53
+ if (available.has(store)) throw new Error(`eligible repository store ${store} is duplicated`);
54
+ available.set(store, row);
55
+ }
56
+ const retired = new Set(retiredFixtureStores({ coverage, fixtureStores: fixture }));
57
+ const questioned = fixture.filter((store) => available.has(store));
58
+ const blocking = fixture.filter((store) => !available.has(store) && !retired.has(store)).map((store) => {
59
+ const row = (coverage?.rows || []).find((entry) => entry?.kind === 'repository'
60
+ && (lower(entry.artifact?.store) === store || lower(entry.name) === store));
61
+ return { store, status: row?.status ?? null, disposition: row?.disposition ?? null };
62
+ });
63
+ const fixtureSet = new Set(fixture);
64
+ return {
65
+ fixture,
66
+ questioned,
67
+ questionedRows: questioned.map((store) => available.get(store)),
68
+ retired: [...retired],
69
+ blocking,
70
+ unfixturedEligible: ordered([...available.keys()].filter((store) => !fixtureSet.has(store))),
71
+ };
72
+ }
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ // scripts/knowledge-input-digest.mjs — "has anything the corpus is built FROM changed since the seed?"
3
+ //
4
+ // WHY (2026-09-29 nightly redesign). The old no-change test compared the NEW archive's digest with
5
+ // the seed archive's digest. It could never match: a rebuilt archive carries fresh builtUtc stamps,
6
+ // a fresh selection receipt and fresh aggregate receipts even when not one input moved, so every
7
+ // quiet night paid the full ~3 hours and minted a new generation anyway. The decision now happens
8
+ // BEFORE anything is built, over the inputs themselves, computed the same way for the seed (from the
9
+ // sealed evidence it carries) and for tonight (from the sealed observation and this checkout):
10
+ //
11
+ // sourceObservationSha256 the sealed observation digest. Included on purpose (the conservative
12
+ // clause of the design): the aggregate builders consume more of the
13
+ // observation than head SHAs -- concepts.sources.json binds
14
+ // observationSha256 and the ruv-gists ledger row uses it as sourceCommit,
15
+ // and coverage rows ship each repository's description/homepage for routing.
16
+ // repositories every eligible repository store -> the commit tonight wants (upstream.sha)
17
+ // versus the commit the seed actually contains (artifact.sourceCommit),
18
+ // so a store the seed carried at old bytes can never read as unchanged.
19
+ // gists every gist -> wanted version versus ingested version, same rule.
20
+ // publicInputs the byte identities of the fenced public prose (primers, topics, L2,
21
+ // capability cards, aliases) and the topic-ownership map.
22
+ // embedding the model:dimensions the runtime embeds with / the seed was embedded with.
23
+ //
24
+ // Every uncertainty resolves toward BUILDING: a seed without the evidence (the pre-contract bootstrap)
25
+ // yields null, and null is never "unchanged".
26
+
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { canonicalJson, digest as sha256Of } from './coverage-integrity.mjs';
32
+ import { materializePublicInputs, SELECTION_FILE } from './public-inputs.mjs';
33
+ import { parseBuildFingerprint } from './corpus-next-seed.mjs';
34
+
35
+ export const KNOWLEDGE_INPUT_KIND = 'ruvnet-brain-knowledge-input';
36
+ const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
37
+ const HEX64 = /^[0-9a-f]{64}$/;
38
+ const COMMIT = /^[0-9a-f]{7,64}$/;
39
+
40
+ function fail(message) {
41
+ throw new Error(`[knowledge-input-digest] ${message}`);
42
+ }
43
+
44
+ const lower = (value) => (value === null || value === undefined ? null : String(value).toLowerCase());
45
+
46
+ /** Canonical form: every list sorted, every commit lowercased, nothing else admitted. */
47
+ export function normalize(components) {
48
+ const c = components || {};
49
+ if (!HEX64.test(String(c.sourceObservationSha256 || ''))) fail('sourceObservationSha256 must be 64 lowercase hex');
50
+ if (![c.repositories, c.gists, c.publicInputs?.files, c.embedding].every(Array.isArray)) {
51
+ fail('repositories, gists, publicInputs.files and embedding must be arrays');
52
+ }
53
+ const repositories = c.repositories.map((row) => ({ store: lower(row?.store), sha: lower(row?.sha) }))
54
+ .sort((a, b) => String(a.store).localeCompare(String(b.store)));
55
+ if (repositories.some((row) => !row.store)) fail('a repository component has no store');
56
+ if (new Set(repositories.map((row) => row.store)).size !== repositories.length) fail('duplicate repository store');
57
+ const gists = c.gists.map((row) => ({ id: String(row?.id || ''), sha: lower(row?.sha) }))
58
+ .sort((a, b) => a.id.localeCompare(b.id));
59
+ if (gists.some((row) => !row.id)) fail('a gist component has no id');
60
+ const files = c.publicInputs.files.map((row) => ({ path: String(row?.path || ''), sha256: lower(row?.sha256), bytes: row?.bytes }))
61
+ .sort((a, b) => a.path.localeCompare(b.path));
62
+ if (files.some((row) => !row.path || !HEX64.test(String(row.sha256)) || !Number.isSafeInteger(row.bytes))) {
63
+ fail('a public input file row is malformed');
64
+ }
65
+ const ownership = Object.fromEntries(Object.entries(c.publicInputs.ownership || {})
66
+ .map(([slug, repo]) => [String(slug), String(repo)]).sort(([a], [b]) => a.localeCompare(b)));
67
+ const embedding = [...new Set(c.embedding.map(String))].sort();
68
+ if (!embedding.length) fail('no embedding model recorded');
69
+ return {
70
+ schemaVersion: 1, kind: KNOWLEDGE_INPUT_KIND, sourceObservationSha256: c.sourceObservationSha256,
71
+ repositories, gists, publicInputs: { files, ownership }, embedding,
72
+ };
73
+ }
74
+
75
+ /** One sha256 over the canonical components. Ordering of the inputs never matters. */
76
+ export function digest(components) {
77
+ return sha256Of(canonicalJson(normalize(components)));
78
+ }
79
+
80
+ const eligibleRepositories = (coverage) => (coverage?.rows || [])
81
+ .filter((row) => row?.kind === 'repository' && row?.disposition === 'eligible');
82
+ const gistRows = (coverage) => (coverage?.rows || []).filter((row) => row?.kind === 'gist');
83
+ const gistId = (row) => String(row?.key || '').replace(/^gist:/, '');
84
+
85
+ function readJsonIfPresent(file) {
86
+ if (!fs.existsSync(file)) return null;
87
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
88
+ }
89
+
90
+ /**
91
+ * The seed's knowledge inputs, read ONLY from evidence the sealed seed carries (its extracted assets):
92
+ * CORPUS-COVERAGE.json, PUBLIC-INPUT-SELECTION.json and RVF-GENERATIONS.json. null when any is absent
93
+ * or unreadable -- the caller must then build.
94
+ */
95
+ export function fromSeed(assetsDir) {
96
+ const assets = path.resolve(assetsDir || '');
97
+ const coverage = readJsonIfPresent(path.join(assets, 'CORPUS-COVERAGE.json'));
98
+ const selection = readJsonIfPresent(path.join(assets, SELECTION_FILE));
99
+ const ledger = readJsonIfPresent(path.join(assets, 'RVF-GENERATIONS.json'));
100
+ if (coverage?.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)
101
+ || !HEX64.test(String(coverage.sourceObservationSha256 || ''))) return null;
102
+ if (!Array.isArray(selection?.files) || !ledger?.stores || typeof ledger.stores !== 'object') return null;
103
+ const embedding = Object.values(ledger.stores)
104
+ .filter((store) => store?.model && Number.isSafeInteger(store?.dimensions))
105
+ .map((store) => `${store.model}:${store.dimensions}`);
106
+ if (!embedding.length) return null;
107
+ return {
108
+ sourceObservationSha256: coverage.sourceObservationSha256,
109
+ // What the seed CONTAINS: a carried (STALE) or MISSING store reads as its old/absent commit.
110
+ repositories: eligibleRepositories(coverage).map((row) => ({ store: row.artifact?.store, sha: row.artifact?.sourceCommit ?? null })),
111
+ gists: gistRows(coverage).map((row) => ({ id: gistId(row), sha: row.artifact?.sourceCommit ?? null })),
112
+ publicInputs: { files: selection.files, ownership: selection.ownership || {} },
113
+ embedding,
114
+ };
115
+ }
116
+
117
+ /** The runtime's embedding profile, read from THAT runtime's own forge fingerprint. */
118
+ export async function runtimeEmbedding(root = DEFAULT_ROOT) {
119
+ const forge = await import(pathToFileURL(path.join(path.resolve(root), 'kb', 'forge-corpus.mjs')).href);
120
+ const { model, dimensions } = parseBuildFingerprint(forge.FORGE_BUILD_FINGERPRINT);
121
+ return [`${model}:${dimensions}`];
122
+ }
123
+
124
+ /**
125
+ * Tonight's knowledge inputs: the coverage measured from the sealed observation (what every source
126
+ * WANTS to be), plus the public prose this checkout would select, materialized into a throwaway
127
+ * directory by the one canonical selector -- never re-implemented here.
128
+ */
129
+ export async function fromCoverage(coverage, root = DEFAULT_ROOT, {
130
+ materialize = materializePublicInputs, embedding = null } = {}) {
131
+ if (coverage?.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)) {
132
+ fail('tonight requires a measured ruvnet-brain-corpus-coverage object');
133
+ }
134
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'knowledge-input-'));
135
+ let publicInputs;
136
+ try {
137
+ const selected = materialize({ builderRoot: path.resolve(root), outDir: path.join(scratch, 'public') });
138
+ publicInputs = { files: selected.selectionReceipt.files, ownership: selected.selectionReceipt.ownership || {} };
139
+ } finally {
140
+ fs.rmSync(scratch, { recursive: true, force: true });
141
+ }
142
+ return {
143
+ sourceObservationSha256: coverage.sourceObservationSha256,
144
+ repositories: eligibleRepositories(coverage).map((row) => {
145
+ if (!COMMIT.test(lower(row.upstream?.sha) || '')) fail(`${row.artifact?.store || row.key} has no upstream sha`);
146
+ return { store: row.artifact?.store, sha: row.upstream.sha };
147
+ }),
148
+ gists: gistRows(coverage).map((row) => ({ id: gistId(row), sha: row.upstream?.sha ?? null })),
149
+ publicInputs,
150
+ embedding: embedding || await runtimeEmbedding(root),
151
+ };
152
+ }
153
+
154
+ /** { unchanged, seedSha256, tonightSha256 }. A seed without evidence is never unchanged. */
155
+ export function compareKnowledgeInputs({ seed, tonight }) {
156
+ const tonightSha256 = digest(tonight);
157
+ if (!seed) return { unchanged: false, seedSha256: null, tonightSha256, reason: 'the seed carries no knowledge-input evidence' };
158
+ const seedSha256 = digest(seed);
159
+ return {
160
+ unchanged: seedSha256 === tonightSha256, seedSha256, tonightSha256,
161
+ reason: seedSha256 === tonightSha256 ? 'every knowledge input equals the seed' : 'at least one knowledge input differs from the seed',
162
+ };
163
+ }