ruvnet-brain 4.3.20 → 4.3.25
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 +5 -5
- package/bin/install.mjs +383 -78
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/host-shell-boundary.mjs +43 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/scripts/update-apply.mjs +2 -32
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Build a corpus candidate from one immutable seed and exact upstream repository SHAs.
|
|
3
3
|
// This module deliberately has no publication capability. The protected-release workflow owns
|
|
4
|
-
// the only legal call to corpus-seed
|
|
4
|
+
// the only legal call to the canonical publisher, `scripts/release.mjs --corpus-seed`
|
|
5
|
+
// (release-authority.mjs's CANONICAL_PUBLISHERS) — see ADR-085.
|
|
5
6
|
|
|
6
7
|
import crypto from 'node:crypto';
|
|
7
8
|
import fs from 'node:fs';
|
|
@@ -9,11 +10,13 @@ import path from 'node:path';
|
|
|
9
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
11
|
import { fileURLToPath } from 'node:url';
|
|
11
12
|
import { extractZip } from '../kb/zip-extract.mjs';
|
|
13
|
+
import { normalizeUpdaterManifest } from './updater-manifest.mjs';
|
|
12
14
|
import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
|
|
13
|
-
import { buildCoverage, observeSourceUniverse,
|
|
14
|
-
import { reconcileGistReceipts } from './gist-receipts.mjs';
|
|
15
|
+
import { buildCoverage, observeSourceUniverse, renderMarkdown } from './source-coverage.mjs';
|
|
15
16
|
import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
|
|
16
17
|
import { rebuildCorpusAggregates } from './corpus-aggregates.mjs';
|
|
18
|
+
import { fileIdentity } from '../plugin/scripts/coverage-integrity.mjs';
|
|
19
|
+
import { storeRoot } from '../kb/store-root.mjs';
|
|
17
20
|
|
|
18
21
|
export { rebuildCorpusAggregates };
|
|
19
22
|
|
|
@@ -34,6 +37,38 @@ function fail(message) {
|
|
|
34
37
|
throw new Error(`[corpus-reconcile] ${message}`);
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
function abortError(signal) {
|
|
41
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
42
|
+
return Object.assign(new Error('reconciliation round aborted'), { name: 'AbortError' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function containsPath(parent, child) {
|
|
46
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child));
|
|
47
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Step 4, rule 7 (2026-09-13): reconciliation output must never land on -- or contain, or be
|
|
51
|
+
// contained by -- the checkout's own build workspace (<repo>/kb, never a second brain per
|
|
52
|
+
// kb/store-root.mjs), or the installed brain (~/.cache/ruvnet-brain/kb, or its env override --
|
|
53
|
+
// storeRoot()'s own answer). A caller that pointed reconciliation output at either would silently
|
|
54
|
+
// mutate a live tree mid-round instead of the disposable scratch area this loop assumes it owns.
|
|
55
|
+
export function forbiddenOutputRoots(root) {
|
|
56
|
+
return [
|
|
57
|
+
{ label: 'the checkout kb build workspace', dir: path.join(path.resolve(root), 'kb') },
|
|
58
|
+
{ label: 'the installed brain', dir: storeRoot() },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertPathNotOverlapping(label, targetDir, forbidden) {
|
|
63
|
+
const resolved = path.resolve(targetDir || '');
|
|
64
|
+
for (const entry of forbidden) {
|
|
65
|
+
const forbiddenDir = path.resolve(entry.dir);
|
|
66
|
+
if (containsPath(forbiddenDir, resolved) || containsPath(resolved, forbiddenDir)) {
|
|
67
|
+
fail(`${label} must not be, or contain, or be contained by, ${entry.label} (${resolved})`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
function sha256File(file) {
|
|
38
73
|
if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) fail(`seed archive missing (${file || 'no path supplied'})`);
|
|
39
74
|
const hash = crypto.createHash('sha256');
|
|
@@ -90,14 +125,31 @@ export function normalizeExtractedCorpus({ extractedDir, assetsDir }) {
|
|
|
90
125
|
const ledgers = filesNamed(extracted, 'RVF-GENERATIONS.json');
|
|
91
126
|
if (ledgers.length !== 1) fail(`seed archive must contain exactly one RVF-GENERATIONS.json; found ${ledgers.length}`);
|
|
92
127
|
const corpusRoot = path.dirname(ledgers[0]);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
128
|
+
// A published seed's own PRIVATE-STORES.json is AUTHENTICATED HISTORICAL EVIDENCE of what that
|
|
129
|
+
// prior round excluded — never the current builder's live policy. Keep it under a distinct name
|
|
130
|
+
// (SEED-PRIVATE-STORES.json) so it can never shadow, or be mistaken for, the canonical fence the
|
|
131
|
+
// exact builder checkout copies in below (main()), and is never overwritten.
|
|
132
|
+
const seedFence = path.join(corpusRoot, 'PRIVATE-STORES.json');
|
|
133
|
+
const hasSeedFence = fs.existsSync(seedFence);
|
|
96
134
|
fs.mkdirSync(assets, { recursive: true });
|
|
97
|
-
for (const entry of fs.readdirSync(corpusRoot))
|
|
135
|
+
for (const entry of fs.readdirSync(corpusRoot)) {
|
|
136
|
+
if (hasSeedFence && entry === 'PRIVATE-STORES.json') continue;
|
|
137
|
+
fs.renameSync(path.join(corpusRoot, entry), path.join(assets, entry));
|
|
138
|
+
}
|
|
139
|
+
if (hasSeedFence) fs.renameSync(seedFence, path.join(assets, 'SEED-PRIVATE-STORES.json'));
|
|
98
140
|
return assets;
|
|
99
141
|
}
|
|
100
142
|
|
|
143
|
+
// Historical evidence only: the identity of the PRIOR seed's own private-store fence, if the seed
|
|
144
|
+
// archive shipped one. Never used to gate anything against the current builder's live policy.
|
|
145
|
+
export function seedPrivateFenceEvidence(assetsDir) {
|
|
146
|
+
const file = path.join(path.resolve(assetsDir || ''), 'SEED-PRIVATE-STORES.json');
|
|
147
|
+
if (!fs.existsSync(file)) return null;
|
|
148
|
+
const stat = fs.lstatSync(file);
|
|
149
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('seed private-fence evidence is not a trusted regular file');
|
|
150
|
+
return fileIdentity(file);
|
|
151
|
+
}
|
|
152
|
+
|
|
101
153
|
function repositorySlug(url) {
|
|
102
154
|
const match = String(url || '').match(/^https:\/\/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?$/i);
|
|
103
155
|
return match ? `${match[1]}/${match[2]}` : null;
|
|
@@ -148,45 +200,97 @@ export function planReconciliation({ coverage, ledger, assetsDir = null }) {
|
|
|
148
200
|
return plan.sort((a, b) => a.store.localeCompare(b.store));
|
|
149
201
|
}
|
|
150
202
|
|
|
151
|
-
export
|
|
152
|
-
|
|
153
|
-
|
|
203
|
+
export const CONSISTENCY_MODEL = 'sealed-acquisition-manifest/1';
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Optional freshness telemetry. It NEVER throws and NEVER vetoes acceptance: a source moving after
|
|
207
|
+
* the manifest was sealed is ordinary, and says nothing about whether this generation is complete
|
|
208
|
+
* against its own pinned inputs. Absent or failed telemetry yields UNKNOWN, not failure.
|
|
209
|
+
*/
|
|
210
|
+
async function measureFreshness({ closingObservation, observation }) {
|
|
211
|
+
if (typeof closingObservation !== 'function') {
|
|
212
|
+
return { checkStatus: 'UNKNOWN', reason: 'no closing observation configured', closingObservationSha256: null };
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const closing = await closingObservation();
|
|
216
|
+
const moved = closing?.observationSha256 !== observation.observationSha256;
|
|
217
|
+
return {
|
|
218
|
+
checkStatus: moved ? 'NEWER_REVISION_OBSERVED' : 'NO_CHANGE_OBSERVED',
|
|
219
|
+
closingObservationSha256: closing?.observationSha256 ?? null,
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return { checkStatus: 'UNKNOWN', reason: `closing observation failed: ${error.message}`, closingObservationSha256: null };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Acquire ONE SEALED GENERATION against a frozen discovery manifest.
|
|
228
|
+
*
|
|
229
|
+
* WHY THIS REPLACED THE ROUND-STABILITY LOOP (measured 2026-09-14/15, Dual verdict "choose A").
|
|
230
|
+
* The previous loop only returned when a fresh observation of the ENTIRE live source universe hashed
|
|
231
|
+
* identically to the one it started with, and failed the whole build after 3 rounds otherwise. A round
|
|
232
|
+
* takes about an hour; the observation hash covers each repository's updatedAt, pushedAt, diskUsage and
|
|
233
|
+
* head oid; and the org pushes continuously (8 repositories in 24h; 13 of 185 moved since the committed
|
|
234
|
+
* coverage generation). So progress was unreliable under sustained churn -- a quiet hour could succeed,
|
|
235
|
+
* but nothing guaranteed one -- and a local run died exactly there after refreshing 90 stores. Every
|
|
236
|
+
* corpus-seed CI run in history has failed, none having reached even this far.
|
|
237
|
+
*
|
|
238
|
+
* The rule now: one bounded discovery pass freezes the identity set; every required source resolves to
|
|
239
|
+
* immutable pinned inputs; movement elsewhere can never invalidate an already-resolved entry or restart
|
|
240
|
+
* the generation. Acceptance is COMPLETENESS AGAINST THE SEALED MANIFEST -- every required source
|
|
241
|
+
* validated against the inputs it was pinned to -- not equality with a live universe that never holds
|
|
242
|
+
* still. A source that moves mid-run finishes at its pinned revision and is picked up by the NEXT
|
|
243
|
+
* generation; `latest` is never substituted, and an exhausted partial generation is never accepted.
|
|
244
|
+
*/
|
|
245
|
+
export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = null, observe, build,
|
|
246
|
+
readLedger: currentLedger, execute, prune, rebuild, closingObservation = null } = {}) {
|
|
247
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
|
|
154
248
|
|| [observe, build, currentLedger, execute, prune, rebuild].some((fn) => typeof fn !== 'function')) {
|
|
155
|
-
fail('bounded
|
|
249
|
+
fail('bounded acquisition configuration is invalid');
|
|
156
250
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
251
|
+
// ONE discovery pass. This observation is the sealed manifest every later step consumes; it is never
|
|
252
|
+
// re-taken, so upstream churn cannot restart or invalidate the generation.
|
|
253
|
+
const observation = await observe();
|
|
254
|
+
const attempts = [];
|
|
255
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
256
|
+
const coverage = await build(observation);
|
|
257
|
+
const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir });
|
|
258
|
+
const reconciliation = await execute(plan, attempt);
|
|
259
|
+
const pruning = await prune(coverage, attempt);
|
|
164
260
|
let aggregates;
|
|
165
261
|
try {
|
|
166
|
-
aggregates = await rebuild(
|
|
262
|
+
aggregates = await rebuild(coverage, observation, attempt);
|
|
167
263
|
} catch (error) {
|
|
168
264
|
if (error?.code !== 'GIST_OBSERVATION_MOVED') throw error;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
265
|
+
// A gist moved between its list entry and its detail fetch. The remedy is to retry against the
|
|
266
|
+
// SAME pinned inputs until one internally consistent revision is captured -- never to re-observe
|
|
267
|
+
// the universe, which is what made the old loop unable to finish.
|
|
268
|
+
attempts.push({ attempt, plan, ...reconciliation, ...pruning, rebuilt: [],
|
|
269
|
+
retried: { reason: 'gist revision moved during exact detail fetch', gistId: error.gistId || null } });
|
|
174
270
|
continue;
|
|
175
271
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
272
|
+
// Re-derive coverage from the SAME sealed observation after the aggregates were rebuilt. This is
|
|
273
|
+
// NOT re-observation -- the manifest is untouched -- it recomputes each row's artifact digests
|
|
274
|
+
// against the bytes this corpus now actually carries. Measured 2026-09-15: without it, a coverage
|
|
275
|
+
// row still pinned the PRE-rebuild ruv-gists digest and build-bundle refused the candidate with
|
|
276
|
+
// "coverage row gist:... was measured against different ruv-gists RVF bytes than this corpus
|
|
277
|
+
// carries", 56 minutes into an otherwise complete run.
|
|
278
|
+
const settled = await build(observation);
|
|
279
|
+
const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir });
|
|
280
|
+
const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
|
|
281
|
+
attempts.push({ attempt, plan, ...reconciliation, ...pruning, ...aggregates,
|
|
282
|
+
remainingArtifacts: remaining.length, unresolvedSources: unresolved.length });
|
|
283
|
+
if (!remaining.length && !unresolved.length) {
|
|
284
|
+
return {
|
|
285
|
+
observation, coverage: settled, attempts, consistencyModel: CONSISTENCY_MODEL,
|
|
286
|
+
freshness: await measureFreshness({ closingObservation, observation }),
|
|
287
|
+
};
|
|
186
288
|
}
|
|
187
|
-
observation = nextObservation;
|
|
188
289
|
}
|
|
189
|
-
|
|
290
|
+
const last = attempts[attempts.length - 1] || {};
|
|
291
|
+
fail(`sealed generation incomplete after ${maxAttempts} acquisition attempt(s): `
|
|
292
|
+
+ `${last.remainingArtifacts ?? 'unknown'} artifact(s) and ${last.unresolvedSources ?? 'unknown'} `
|
|
293
|
+
+ 'eligible source(s) remain unresolved against the sealed manifest');
|
|
190
294
|
}
|
|
191
295
|
|
|
192
296
|
function defaultRun(command, args, options = {}) {
|
|
@@ -235,6 +339,38 @@ function writeJsonAtomic(file, value) {
|
|
|
235
339
|
fs.renameSync(temporary, file);
|
|
236
340
|
}
|
|
237
341
|
|
|
342
|
+
// Step 4, rule 3 (2026-09-13): POSITIVE SELECTION for the generation ledger / SOURCE manifest --
|
|
343
|
+
// the old `prune` seam in the round-stability loop (now acquireSealedGeneration) was a hardcoded no-op (`() => ({ pruned: [] })`), so
|
|
344
|
+
// a repository removed from policy, made private, or deleted upstream simply lingered in
|
|
345
|
+
// RVF-GENERATIONS.json/SOURCE.json (and its .big.rvf family on disk) forever once ingested. This is
|
|
346
|
+
// the real prune: `eligibleStores` is the EXACT set this round's own coverage just measured as
|
|
347
|
+
// `kind: 'repository', disposition: 'eligible'` -- any OTHER repository store still present in the
|
|
348
|
+
// ledger no longer belongs, and its full artifact family plus its ledger/SOURCE rows are removed.
|
|
349
|
+
// `ruv-gists` and `concepts` are out of scope here: those are already fully regenerated from
|
|
350
|
+
// nothing every round (buildGistAggregate / materializePublicInputs+buildConceptAggregate), never
|
|
351
|
+
// overlaid, so nothing here ever needs to -- or may -- touch them.
|
|
352
|
+
export function pruneIneligibleStores({ assetsDir, eligibleStores }) {
|
|
353
|
+
const assets = path.resolve(assetsDir || '');
|
|
354
|
+
const ledgerFile = path.join(assets, 'RVF-GENERATIONS.json');
|
|
355
|
+
if (!fs.existsSync(ledgerFile)) return { pruned: [] };
|
|
356
|
+
const ledger = readJson(ledgerFile, 'RVF generation ledger');
|
|
357
|
+
const sourceFile = path.join(assets, 'SOURCE.json');
|
|
358
|
+
const source = fs.existsSync(sourceFile) ? readJson(sourceFile, 'SOURCE manifest') : { builder: 'rvf-kb-forge', stores: {} };
|
|
359
|
+
const eligible = new Set([...(eligibleStores || [])].map((store) => String(store).toLowerCase()));
|
|
360
|
+
const stale = Object.keys(ledger.stores || {})
|
|
361
|
+
.filter((store) => !['ruv-gists', 'concepts'].includes(store.toLowerCase()) && !eligible.has(store.toLowerCase()))
|
|
362
|
+
.sort();
|
|
363
|
+
if (!stale.length) return { pruned: [] };
|
|
364
|
+
for (const store of stale) {
|
|
365
|
+
delete ledger.stores[store];
|
|
366
|
+
if (source.stores) delete source.stores[store];
|
|
367
|
+
for (const suffix of STORE_ARTIFACT_SUFFIXES) fs.rmSync(path.join(assets, `${store}${suffix}`), { force: true });
|
|
368
|
+
}
|
|
369
|
+
writeJsonAtomic(ledgerFile, ledger);
|
|
370
|
+
writeJsonAtomic(sourceFile, source);
|
|
371
|
+
return { pruned: stale };
|
|
372
|
+
}
|
|
373
|
+
|
|
238
374
|
function seedWorkerAssets({ assets, output, store, ledger, source }) {
|
|
239
375
|
fs.mkdirSync(output, { recursive: true });
|
|
240
376
|
for (const name of storeArtifacts(store)) {
|
|
@@ -296,6 +432,7 @@ export async function executeReconciliation({
|
|
|
296
432
|
root = DEFAULT_ROOT,
|
|
297
433
|
run = defaultRunAsync,
|
|
298
434
|
concurrency = 5,
|
|
435
|
+
signal,
|
|
299
436
|
}) {
|
|
300
437
|
if (!Array.isArray(plan) || !Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 10) {
|
|
301
438
|
fail('reconciliation plan or worker concurrency is invalid');
|
|
@@ -315,7 +452,27 @@ export async function executeReconciliation({
|
|
|
315
452
|
const lowerStores = orderedPlan.map(({ store }) => store.toLowerCase());
|
|
316
453
|
if (new Set(lowerStores).size !== lowerStores.length) fail('reconciliation plan has duplicate or case-fold-colliding stores');
|
|
317
454
|
|
|
455
|
+
// Step 4, required proof 4 (2026-09-13): every worker in this pool shares ONE internal
|
|
456
|
+
// AbortController. Before this, `Promise.all` over the fixed-size worker pool below rejected as
|
|
457
|
+
// soon as ANY lane's `worker()` threw -- but the OTHER lanes kept running their own `while` loop
|
|
458
|
+
// completely unobserved: still cloning, still spawning forge-refresh, with nobody left awaiting
|
|
459
|
+
// them once the outer Promise.all had already settled. A later failure (or success) in one of
|
|
460
|
+
// those orphaned lanes could then surface as an unhandled rejection, or simply keep doing
|
|
461
|
+
// unnecessary work after the round was already lost. Now: the first failure aborts the shared
|
|
462
|
+
// signal, every lane observes it (both at its own loop-top and via the signal threaded into every
|
|
463
|
+
// child-process spawn below) and returns promptly, and `Promise.all` -- which no lane's promise
|
|
464
|
+
// ever rejects out of directly -- only resolves once every lane has actually stopped. Only then do
|
|
465
|
+
// we throw the FIRST real error (an aborted sibling's own error is discarded, never overwrites it).
|
|
466
|
+
// An externally supplied `signal` (a caller discarding this whole round) aborts the same
|
|
467
|
+
// controller, so both cancellation paths join through the one place.
|
|
468
|
+
const controller = new AbortController();
|
|
469
|
+
if (signal) {
|
|
470
|
+
if (signal.aborted) controller.abort(signal.reason);
|
|
471
|
+
else signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
|
|
472
|
+
}
|
|
473
|
+
|
|
318
474
|
const worker = async (item) => {
|
|
475
|
+
if (controller.signal.aborted) throw abortError(controller.signal);
|
|
319
476
|
if (!SAFE_STORE.test(item.store) || !HEX40.test(item.upstreamSha) || !repositorySlug(item.url)) {
|
|
320
477
|
fail(`unsafe reconciliation item for ${item?.store || item?.name || 'unknown store'}`);
|
|
321
478
|
}
|
|
@@ -324,28 +481,38 @@ export async function executeReconciliation({
|
|
|
324
481
|
const output = path.join(workerRoot, 'assets');
|
|
325
482
|
fs.mkdirSync(workerRoot, { recursive: true });
|
|
326
483
|
seedWorkerAssets({ assets, output, store: item.store, ledger: canonicalLedger, source: canonicalSource });
|
|
327
|
-
await checkedAsync(run, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir]);
|
|
328
|
-
await checkedAsync(run, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha]);
|
|
329
|
-
await checkedAsync(run, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD']);
|
|
330
|
-
const head = await checkedAsync(run, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
484
|
+
await checkedAsync(run, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir], { signal: controller.signal });
|
|
485
|
+
await checkedAsync(run, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha], { signal: controller.signal });
|
|
486
|
+
await checkedAsync(run, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD'], { signal: controller.signal });
|
|
487
|
+
const head = await checkedAsync(run, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD'], { signal: controller.signal });
|
|
331
488
|
if (String(head.stdout || '').trim().toLowerCase() !== item.upstreamSha) {
|
|
332
489
|
fail(`${item.store}: fresh clone did not resolve the exact upstream SHA`);
|
|
333
490
|
}
|
|
334
491
|
await checkedAsync(run, process.execPath, [forge, '--repo', cloneDir, '--out', output, '--name', item.store,
|
|
335
492
|
...(FULL_HINTS[item.store] ? ['--full', FULL_HINTS[item.store]] : []),
|
|
336
493
|
...(KEEP_DIRS[item.store] ? ['--keep', KEEP_DIRS[item.store]] : []),
|
|
337
|
-
], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' } });
|
|
494
|
+
], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' }, signal: controller.signal });
|
|
338
495
|
return validateWorkerOutput({ output, item });
|
|
339
496
|
};
|
|
340
497
|
|
|
341
498
|
const results = new Array(orderedPlan.length);
|
|
342
499
|
let next = 0;
|
|
500
|
+
let firstError = null;
|
|
343
501
|
await Promise.all(Array.from({ length: Math.min(concurrency, orderedPlan.length) }, async () => {
|
|
344
502
|
while (next < orderedPlan.length) {
|
|
503
|
+
if (controller.signal.aborted) return;
|
|
345
504
|
const index = next++;
|
|
346
|
-
|
|
505
|
+
try {
|
|
506
|
+
results[index] = await worker(orderedPlan[index]);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (!firstError) firstError = error;
|
|
509
|
+
controller.abort(error);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
347
512
|
}
|
|
348
513
|
}));
|
|
514
|
+
if (!firstError && controller.signal.aborted) firstError = abortError(controller.signal);
|
|
515
|
+
if (firstError) throw firstError;
|
|
349
516
|
if (!results.length) return { refreshed: [], workers: [] };
|
|
350
517
|
|
|
351
518
|
const merge = path.join(workspace, 'merge-candidate');
|
|
@@ -373,97 +540,145 @@ export async function executeReconciliation({
|
|
|
373
540
|
workers: results.map(({ output: _output, ...receipt }) => receipt) };
|
|
374
541
|
}
|
|
375
542
|
|
|
543
|
+
// syncCorpusInputs — Step 3 (2026-09-13): this used to ALSO sync public-prose inputs
|
|
544
|
+
// (capability-cards.md, primers, l2/, l2-topics.*.json, public-store-classes.json), and did it by
|
|
545
|
+
// OVERLAY -- copying this round's files onto whatever a prior round's assets directory already had,
|
|
546
|
+
// so a primer or topics file removed from the checkout never disappeared from a long-lived assets
|
|
547
|
+
// tree. That selection is now owned entirely by materializePublicInputs (scripts/public-inputs.mjs),
|
|
548
|
+
// called fresh every round from rebuildCorpusAggregates -- it positively selects (and fences) every
|
|
549
|
+
// public-prose input from nothing, so a removed/newly-private input simply is not reproduced.
|
|
550
|
+
// public-store-classes.json is no longer synced as an input at all (rule 9): it is generated by
|
|
551
|
+
// buildConceptAggregate from the stores actually accepted that round, never read from a checkout copy.
|
|
552
|
+
//
|
|
553
|
+
// What remains here is a SEPARATE, deliberately smaller concern (rule 5): CODE-INGESTION eligibility
|
|
554
|
+
// policy (which repositories/gists may enter the corpus at all) -- needed before the reconciliation
|
|
555
|
+
// loop can even observe the source universe, and unrelated to what public prose ships.
|
|
376
556
|
export function syncCorpusInputs({ root = DEFAULT_ROOT, assetsDir }) {
|
|
377
557
|
const sourceKb = path.join(path.resolve(root), 'kb');
|
|
378
558
|
const assets = path.resolve(assetsDir || '');
|
|
379
|
-
const required = ['
|
|
380
|
-
'public-store-classes.json'];
|
|
559
|
+
const required = ['external-sources.json', 'no-corpus-repos.json'];
|
|
381
560
|
for (const name of required) {
|
|
382
561
|
const source = path.join(sourceKb, name);
|
|
383
562
|
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail(`canonical corpus input missing (${source})`);
|
|
384
563
|
fs.copyFileSync(source, path.join(assets, name));
|
|
385
564
|
}
|
|
386
|
-
const sourceL2 = path.join(sourceKb, 'l2');
|
|
387
|
-
if (!fs.existsSync(sourceL2) || !fs.statSync(sourceL2).isDirectory()) fail(`canonical L2 input missing (${sourceL2})`);
|
|
388
|
-
fs.rmSync(path.join(assets, 'l2'), { recursive: true, force: true });
|
|
389
|
-
fs.cpSync(sourceL2, path.join(assets, 'l2'), { recursive: true });
|
|
390
|
-
for (const entry of fs.readdirSync(sourceKb)) {
|
|
391
|
-
if (entry.endsWith('-primer.md') || /^l2-topics\..+\.json$/.test(entry)) {
|
|
392
|
-
fs.copyFileSync(path.join(sourceKb, entry), path.join(assets, entry));
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
565
|
return { copied: required };
|
|
396
566
|
}
|
|
397
567
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
writeJsonAtomic(sourceFile, receipt);
|
|
409
|
-
return { sourceFile, receipt };
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
export async function observeAndMaterializeGistReceipts({ owner = 'ruvnet', assetsDir,
|
|
413
|
-
observe = observeSourceUniverse, fetchGist, fetchBody, now } = {}) {
|
|
568
|
+
// A PURE, READ-ONLY observation of the live source universe -- lists repositories and gists but
|
|
569
|
+
// NEVER materializes/binds a gist receipt. This is exactly what structurally prevents the
|
|
570
|
+
// 2026-09-12 "observation resets passage binding" bug: previously `observe()` both listed the live
|
|
571
|
+
// universe AND re-sealed `ruv-gists.sources.json` against whatever it just saw, so calling it a
|
|
572
|
+
// second time within one round (to detect drift after the repository refresh + gist aggregate build
|
|
573
|
+
// below) clobbered the receipt `rebuild()` had just sealed with a bound `passagesSha256` back to an
|
|
574
|
+
// unbound one. Gist capture/render/seal now happens EXACTLY once per round, inside `rebuild` (via
|
|
575
|
+
// rebuildCorpusAggregates -> buildGistAggregate) -- `observe` can be called as many times as
|
|
576
|
+
// stability detection needs without ever touching what `rebuild` already sealed.
|
|
577
|
+
async function observeSourceOnly({ owner, assetsDir }) {
|
|
414
578
|
const assets = path.resolve(assetsDir || '');
|
|
415
579
|
const externalFile = path.join(assets, 'external-sources.json');
|
|
416
580
|
const policy = fs.existsSync(externalFile) ? readJson(externalFile, 'external source policy') : { sources: [] };
|
|
417
581
|
if (!Array.isArray(policy.sources)) fail('external source policy has no sources array');
|
|
418
|
-
|
|
419
|
-
const materialized = await materializeGistReceipts({ observation, assetsDir: assets, fetchGist, fetchBody, now });
|
|
420
|
-
return { observation, ...materialized };
|
|
582
|
+
return observeSourceUniverse({ owner, externalSources: policy.sources });
|
|
421
583
|
}
|
|
422
584
|
|
|
423
|
-
export async function
|
|
424
|
-
root = DEFAULT_ROOT,
|
|
425
|
-
|
|
585
|
+
export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, workspaceDir,
|
|
586
|
+
root = DEFAULT_ROOT, maxAttempts = 3, closingObservation = null,
|
|
587
|
+
observe = null,
|
|
426
588
|
build = (observation) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation }),
|
|
427
589
|
readLedger = () => readJson(path.join(path.resolve(assetsDir || ''), 'RVF-GENERATIONS.json'),
|
|
428
590
|
'RVF generation ledger'),
|
|
429
591
|
execute = executeReconciliation,
|
|
430
|
-
|
|
431
|
-
|
|
592
|
+
// Step 4, rule 3: positive selection, not a no-op. `coverage` here is `preliminary` -- the FULL,
|
|
593
|
+
// freshly-measured coverage for the round about to run (every row, not just the ones needing
|
|
594
|
+
// rebuild) -- so the eligible set is always this round's own, never a stale snapshot.
|
|
595
|
+
prune = (coverage) => pruneIneligibleStores({
|
|
596
|
+
assetsDir,
|
|
597
|
+
eligibleStores: coverage.rows.filter((row) => row.kind === 'repository' && row.disposition === 'eligible')
|
|
598
|
+
.map((row) => row.artifact.store),
|
|
599
|
+
}),
|
|
600
|
+
// `coverage` is now threaded through (rule 8) rather than discarded: rebuildCorpusAggregates
|
|
601
|
+
// asserts the concepts observation identity exactly equals coverage's own, instead of trusting an
|
|
602
|
+
// accidental shared reference.
|
|
603
|
+
rebuild = (coverage, observation) => rebuildCorpusAggregates({ assetsDir, observation, coverage, root }),
|
|
432
604
|
} = {}) {
|
|
433
605
|
if (!assetsDir || !workspaceDir) fail('stable reconciliation requires explicit assets and workspace directories');
|
|
434
606
|
const workspace = path.resolve(workspaceDir || '');
|
|
435
|
-
|
|
436
|
-
|
|
607
|
+
const forbidden = forbiddenOutputRoots(root);
|
|
608
|
+
assertPathNotOverlapping('reconciliation assets directory', assetsDir, forbidden);
|
|
609
|
+
assertPathNotOverlapping('reconciliation workspace directory', workspace, forbidden);
|
|
610
|
+
assertPathNotOverlapping('reconciliation workspace directory', workspace,
|
|
611
|
+
[{ label: 'the assets directory', dir: assetsDir }]);
|
|
612
|
+
return acquireSealedGeneration({
|
|
613
|
+
maxAttempts,
|
|
614
|
+
closingObservation,
|
|
437
615
|
assetsDir,
|
|
438
|
-
observe:
|
|
439
|
-
? observeAndMaterialize({ owner, assetsDir })
|
|
440
|
-
: observeAndMaterializeGistReceipts({ owner, assetsDir }))).observation,
|
|
616
|
+
observe: () => (observe || observeSourceOnly)({ owner, assetsDir }),
|
|
441
617
|
build,
|
|
442
618
|
readLedger,
|
|
443
|
-
execute: (plan,
|
|
444
|
-
plan, assetsDir, workspaceDir: path.join(workspace, `
|
|
619
|
+
execute: (plan, attempt) => execute({
|
|
620
|
+
plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root,
|
|
445
621
|
}),
|
|
446
622
|
prune,
|
|
447
623
|
rebuild,
|
|
448
624
|
});
|
|
449
625
|
}
|
|
450
626
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
627
|
+
// Step 4, rule 4 (2026-09-13): this used to take an OPAQUE `plan`/`execute` pair, and main() below
|
|
628
|
+
// passed `plan: []` (an inert placeholder -- the real per-round plans are computed INSIDE the
|
|
629
|
+
// acquisition loop, never known up front) plus `execute: () => acquireCorpusGeneration(...)` (an
|
|
630
|
+
// override that threw the supplied `plan` away entirely and substituted the whole multi-round loop).
|
|
631
|
+
// That indirection existed only because this function's default (`executeReconciliation`) runs a
|
|
632
|
+
// SINGLE round against a caller-supplied plan, while production always needs the full
|
|
633
|
+
// round-until-stable loop -- so production always had to override the default just to get correct
|
|
634
|
+
// behavior. Now `reconcile` defaults directly to the stability loop itself: main() calls this with
|
|
635
|
+
// no override at all, and a caller that genuinely wants one-shot single-round execution (e.g. a
|
|
636
|
+
// test) can still supply its own `reconcile`.
|
|
637
|
+
export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceDir, root = DEFAULT_ROOT,
|
|
638
|
+
owner = 'ruvnet', builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity = null, maxAttempts = 3,
|
|
639
|
+
reconcile = (options) => acquireCorpusGeneration(options),
|
|
640
|
+
normalizeUpdaters = normalizeUpdaterManifest,
|
|
641
|
+
accuracyOracleFile = null, accuracyStores = null, accuracySample = null, accuracyTimeoutMs = null,
|
|
642
|
+
prepare = prepareCorpusCandidate } = {}) {
|
|
643
|
+
const finalized = await reconcile({ owner, assetsDir, workspaceDir, root, maxAttempts });
|
|
644
|
+
// Every shipped repository store needs a complete updater entry, and a seed that predates the
|
|
645
|
+
// convention leaves inherited stores without one -- measured 2026-09-15: 100 of 194 repository
|
|
646
|
+
// stores, none of them refreshed that run, which build-bundle rightly refused to ship. Normalize
|
|
647
|
+
// AFTER reconciliation and aggregate rebuild, BEFORE anything seals or assembles this corpus, and
|
|
648
|
+
// run even when nothing was refreshed. A store whose artifact cannot be verified against the ledger
|
|
649
|
+
// is never synthesized -- it is reported here and fails the build, because it needs a real rebuild.
|
|
650
|
+
const updaters = normalizeUpdaters({
|
|
651
|
+
assetsDir,
|
|
652
|
+
coverage: finalized.coverage,
|
|
653
|
+
refreshedStores: (finalized.attempts || []).flatMap((a) => (a.refreshed || []).map((r) => r?.store || r)).filter(Boolean),
|
|
654
|
+
seedIdentity: bootstrapIdentity,
|
|
655
|
+
});
|
|
656
|
+
if (updaters.missing?.length) {
|
|
657
|
+
fail(`${updaters.missing.length} repository store(s) still carry no updater entry after normalization `
|
|
658
|
+
+ `(${updaters.missing.slice(0, 5).join(', ')}${updaters.missing.length > 5 ? ', ...' : ''}); `
|
|
659
|
+
+ `unverified: ${JSON.stringify(updaters.unverified?.slice(0, 5) || [])}`);
|
|
660
|
+
}
|
|
661
|
+
const candidate = await prepare({
|
|
662
|
+
root, assetsDir, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
663
|
+
coverage: finalized.coverage,
|
|
664
|
+
accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
|
|
665
|
+
});
|
|
666
|
+
return { reconciliation: finalized, updaters, candidate };
|
|
457
667
|
}
|
|
458
668
|
|
|
459
669
|
export function prepareCorpusCandidate({
|
|
460
670
|
root = DEFAULT_ROOT,
|
|
461
671
|
assetsDir,
|
|
462
|
-
owner = 'ruvnet',
|
|
463
672
|
builderSha,
|
|
464
673
|
candidateDir,
|
|
465
674
|
receiptFile,
|
|
466
675
|
coverageFile,
|
|
676
|
+
bootstrapIdentity = null,
|
|
677
|
+
coverage,
|
|
678
|
+
accuracyOracleFile = null,
|
|
679
|
+
accuracyStores = null,
|
|
680
|
+
accuracySample = null,
|
|
681
|
+
accuracyTimeoutMs = null,
|
|
467
682
|
run = defaultRun,
|
|
468
683
|
}) {
|
|
469
684
|
const sourceRoot = path.resolve(root);
|
|
@@ -474,24 +689,92 @@ export function prepareCorpusCandidate({
|
|
|
474
689
|
if (!HEX40.test(String(builderSha || '').toLowerCase())) fail('builder SHA must be exact 40-character lowercase hex');
|
|
475
690
|
const expectedPolicy = path.join(sourceRoot, 'data', 'source-coverage.json');
|
|
476
691
|
if (policy !== expectedPolicy) fail(`coverage policy must be the generator's canonical projection (${expectedPolicy})`);
|
|
477
|
-
|
|
692
|
+
// Step 4, rules 5-6 (2026-09-13): prepareCorpusCandidate performs NO live source observation of
|
|
693
|
+
// its own. The old flow shelled out to `source-coverage.mjs --write` and then `--check --strict`
|
|
694
|
+
// as two SEPARATE live re-observations of the real GitHub source universe, mutating the tracked
|
|
695
|
+
// checkout's data/source-coverage.json and docs/RUVNET-COVERAGE.md a SECOND time, after
|
|
696
|
+
// acquireCorpusGeneration had already captured and measured the sealed observation -- exactly
|
|
697
|
+
// the "re-observes and mutates tracked checkout files after the stable observation was already
|
|
698
|
+
// captured" bug flagged 2026-09-13. `coverage` here is that already-stabilized measurement
|
|
699
|
+
// (FinalizedCorpus.coverage); both the committed JSON and the committed Markdown are now rendered
|
|
700
|
+
// from that SAME in-memory object, so they can never independently disagree with each other or
|
|
701
|
+
// with what the reconciliation loop actually verified.
|
|
702
|
+
if (!coverage || coverage.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)) {
|
|
703
|
+
fail('prepareCorpusCandidate requires an already-measured coverage object; it never re-observes live sources');
|
|
704
|
+
}
|
|
705
|
+
const blockers = coverage.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
|
|
706
|
+
if (blockers.length) fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT`);
|
|
707
|
+
assertPathNotOverlapping('candidate output directory', candidate, forbiddenOutputRoots(sourceRoot));
|
|
478
708
|
const buildScript = path.join(sourceRoot, 'scripts', 'build-bundle.mjs');
|
|
479
709
|
const receiptScript = path.join(sourceRoot, 'scripts', 'corpus-candidate.mjs');
|
|
480
|
-
|
|
710
|
+
const accuracyScript = path.join(sourceRoot, 'scripts', 'oracle', 'retrieval-accuracy.mjs');
|
|
711
|
+
const recallScript = path.join(sourceRoot, 'scripts', 'oracle', 'repo-recall.mjs');
|
|
712
|
+
for (const required of [buildScript, receiptScript, accuracyScript, recallScript]) {
|
|
481
713
|
if (!fs.existsSync(required)) fail(`required candidate builder missing (${required})`);
|
|
482
714
|
}
|
|
715
|
+
// ADR-086 Step 15 / C3. The oracle is a hard input, checked BEFORE the expensive single-pass
|
|
716
|
+
// assembly so a missing one fails in seconds rather than after a full corpus build. Producing it
|
|
717
|
+
// is Step 14's deliverable; this gate fails closed until it exists, which is the honest state —
|
|
718
|
+
// a corpus nobody has measured must not be sealable.
|
|
719
|
+
const accuracyOracle = path.resolve(accuracyOracleFile || path.join(sourceRoot, 'data', 'retrieval-accuracy-oracle.json'));
|
|
720
|
+
if (!fs.existsSync(accuracyOracle) || !fs.statSync(accuracyOracle).isFile()) {
|
|
721
|
+
fail(`retrieval-accuracy oracle missing (${accuracyOracle}); ADR-086 Step 15's C3 gate cannot seal an unmeasured corpus`);
|
|
722
|
+
}
|
|
723
|
+
// The recall gate's two inputs are hard inputs, checked BEFORE the expensive assembly for the same
|
|
724
|
+
// reason the oracle is: a missing ratchet must fail in seconds, not after an hour of building.
|
|
725
|
+
for (const required of [
|
|
726
|
+
path.join(sourceRoot, 'data', 'retrieval-query-evidence.json'),
|
|
727
|
+
path.join(sourceRoot, 'data', 'repo-recall-floor.json'),
|
|
728
|
+
]) {
|
|
729
|
+
if (!fs.existsSync(required)) fail(`repo-recall gate input missing (${required}); a corpus cannot be sealed without the frozen fixture and the ratchet it must not regress below`);
|
|
730
|
+
}
|
|
483
731
|
fs.mkdirSync(path.dirname(candidate), { recursive: true });
|
|
484
732
|
fs.mkdirSync(path.dirname(receipt), { recursive: true });
|
|
485
|
-
|
|
486
|
-
|
|
733
|
+
fs.mkdirSync(path.dirname(policy), { recursive: true });
|
|
734
|
+
fs.writeFileSync(policy, `${JSON.stringify(coverage, null, 2)}\n`);
|
|
735
|
+
const markdownPath = path.join(sourceRoot, 'docs', 'RUVNET-COVERAGE.md');
|
|
736
|
+
fs.mkdirSync(path.dirname(markdownPath), { recursive: true });
|
|
737
|
+
fs.writeFileSync(markdownPath, renderMarkdown(coverage));
|
|
487
738
|
checked(run, process.execPath, [buildScript, '--assets', assets, '--out', candidate,
|
|
488
739
|
'--coverage', policy], { stdio: 'inherit' });
|
|
489
740
|
const bundleFile = path.join(path.dirname(candidate), `${path.basename(candidate)}.zip`);
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
741
|
+
// ADR-086 Step 15: the benchmark runs HERE — after single-pass assembly and before the seal —
|
|
742
|
+
// against the EXTRACTED final archive through the customer query path, never against `assets`.
|
|
743
|
+
// The report is written detached, beside the archive, and the seal below binds its digest. A
|
|
744
|
+
// bounded run (--stores/--sample) still writes a report, but it marks itself incomplete and the
|
|
745
|
+
// seal refuses it, so a bounded measurement can never be presented as a corpus-wide pass.
|
|
746
|
+
const accuracyReportFile = `${bundleFile}.accuracy.json`;
|
|
747
|
+
checked(run, process.execPath, [accuracyScript, '--bundle', bundleFile,
|
|
748
|
+
'--oracle', accuracyOracle, '--out', accuracyReportFile,
|
|
749
|
+
...(accuracyStores != null ? ['--stores', String(accuracyStores)] : []),
|
|
750
|
+
...(accuracySample != null ? ['--sample', String(accuracySample)] : []),
|
|
751
|
+
...(accuracyTimeoutMs != null ? ['--timeout-ms', String(accuracyTimeoutMs)] : [])],
|
|
752
|
+
{ stdio: 'inherit' });
|
|
753
|
+
// THE BLOCKING RETRIEVAL GATE (ADR-086 amendment 2026-09-15). Same placement and same discipline
|
|
754
|
+
// as the C3 run above — the EXTRACTED final archive through the customer query path — but this is
|
|
755
|
+
// the measurement that can refuse a candidate. It asks the 194 frozen human questions, one per
|
|
756
|
+
// repository, and fails on any error, any repository that returns nothing of its own, or any
|
|
757
|
+
// exact-file Hit@5 below the committed ratchet floor.
|
|
758
|
+
const recallReportFile = `${bundleFile}.recall.json`;
|
|
759
|
+
checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile],
|
|
760
|
+
{ stdio: 'inherit' });
|
|
761
|
+
// The candidate receipt is derived ENTIRELY from the sealed bundle's own bytes plus the detached,
|
|
762
|
+
// digest-bound reports — the separate assets/policy directory used to build it is no longer an
|
|
763
|
+
// alternate verification root.
|
|
764
|
+
const bootstrapArgs = bootstrapIdentity?.tag && bootstrapIdentity?.sha256
|
|
765
|
+
? ['--bootstrap-tag', bootstrapIdentity.tag, '--bootstrap-sha256', bootstrapIdentity.sha256]
|
|
766
|
+
: [];
|
|
767
|
+
checked(run, process.execPath, [receiptScript, '--bundle', bundleFile,
|
|
768
|
+
'--receipt', receipt, '--builder-source-sha', builderSha,
|
|
769
|
+
'--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile,
|
|
770
|
+
...bootstrapArgs], { stdio: 'inherit' });
|
|
771
|
+
checked(run, process.execPath, [receiptScript, '--verify', '--bundle', bundleFile,
|
|
772
|
+
'--receipt', receipt, '--accuracy-report', accuracyReportFile,
|
|
773
|
+
'--recall-report', recallReportFile], { stdio: 'inherit' });
|
|
774
|
+
return {
|
|
775
|
+
bundleFile, receiptFile: receipt, coverageFile: policy,
|
|
776
|
+
accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile,
|
|
777
|
+
};
|
|
495
778
|
}
|
|
496
779
|
|
|
497
780
|
function arg(argv, name, fallback = null) {
|
|
@@ -511,8 +794,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
511
794
|
const receiptFile = path.resolve(arg(argv, '--receipt-out', path.join(root, 'dist', 'corpus-receipt.json')));
|
|
512
795
|
const builderSha = String(arg(argv, '--builder-sha', '')).toLowerCase();
|
|
513
796
|
const owner = arg(argv, '--owner', 'ruvnet');
|
|
514
|
-
|
|
515
|
-
|
|
797
|
+
const accuracyOracleFile = path.resolve(arg(argv, '--accuracy-oracle', path.join(root, 'data', 'retrieval-accuracy-oracle.json')));
|
|
798
|
+
// Bounded measurement is explicit and opt-in. It never yields a sealable candidate — the seal
|
|
799
|
+
// refuses an incomplete report — so these flags exist for measuring, not for shipping.
|
|
800
|
+
const accuracyStores = arg(argv, '--accuracy-stores') ? Number(arg(argv, '--accuracy-stores')) : null;
|
|
801
|
+
const accuracySample = arg(argv, '--accuracy-sample') ? Number(arg(argv, '--accuracy-sample')) : null;
|
|
802
|
+
const accuracyTimeoutMs = arg(argv, '--accuracy-timeout-ms') ? Number(arg(argv, '--accuracy-timeout-ms')) : null;
|
|
803
|
+
|
|
804
|
+
const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: process.argv.includes('--allow-pinned-seed-tag') });
|
|
516
805
|
if (fs.existsSync(assetsDir) && fs.readdirSync(assetsDir).length) fail(`bootstrap assets directory is not empty (${assetsDir})`);
|
|
517
806
|
fs.mkdirSync(path.dirname(assetsDir), { recursive: true });
|
|
518
807
|
const extractParent = fs.mkdtempSync(path.join(path.dirname(assetsDir), '.corpus-seed-extract-'));
|
|
@@ -523,16 +812,33 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
523
812
|
fs.copyFileSync(privateFence, path.join(assetsDir, 'PRIVATE-STORES.json'), fs.constants.COPYFILE_EXCL);
|
|
524
813
|
fs.rmSync(extractParent, { recursive: true, force: true });
|
|
525
814
|
syncCorpusInputs({ root, assetsDir });
|
|
815
|
+
const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256, privateFenceEvidence: seedPrivateFenceEvidence(assetsDir) };
|
|
526
816
|
const { reconciliation, candidate } = await reconcileAndPrepareCorpusCandidate({
|
|
527
|
-
|
|
528
|
-
|
|
817
|
+
assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
818
|
+
accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
|
|
529
819
|
});
|
|
530
820
|
const plan = reconciliation.rounds.flatMap((round) => round.plan);
|
|
531
821
|
process.stdout.write(`${JSON.stringify({ ok: true, seedTag, seedSha256, plan, reconciliation, ...candidate }, null, 2)}\n`);
|
|
532
822
|
return 0;
|
|
533
823
|
}
|
|
534
824
|
|
|
535
|
-
|
|
825
|
+
// REALPATH BOTH SIDES, or this CLI silently no-ops. argv[1] is whatever the caller typed, symlinks
|
|
826
|
+
// and all, while node resolves a module URL THROUGH symlinks before it reaches import.meta.url — so a
|
|
827
|
+
// symlinked invocation compares a link path against a real path, decides it is not the entry point,
|
|
828
|
+
// runs nothing, and EXITS 0. On macOS every os.tmpdir() path is symlinked (/var/folders -> /private/
|
|
829
|
+
// var/folders), so any caller staging work in a temp directory hits this. Measured 2026-09-14:
|
|
830
|
+
// build-bundle.mjs and corpus-candidate.mjs both no-opped and prepareCorpusCandidate reported SUCCESS
|
|
831
|
+
// with no archive and no receipt on disk. Same defect, same fix as plugin/scripts/hook-input.mjs:518.
|
|
832
|
+
function isMain() {
|
|
833
|
+
try {
|
|
834
|
+
if (!process.argv[1]) return false;
|
|
835
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
836
|
+
} catch {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (isMain()) {
|
|
536
842
|
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
537
843
|
console.error(error.message);
|
|
538
844
|
process.exitCode = 1;
|