ruvnet-brain 4.3.21 → 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.
Files changed (141) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +141 -9
  4. package/console/index.html +51 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/tips.html +1 -0
  9. package/kb/corpus-release-identity.mjs +239 -0
  10. package/kb/update-storage-transaction.mjs +20 -3
  11. package/package.json +9 -2
  12. package/plugin/.claude-plugin/plugin.json +2 -2
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/commands/checkpoint.md +61 -0
  15. package/plugin/hooks/codex-hooks.json +64 -1
  16. package/plugin/hooks/hook-contracts.json +299 -6
  17. package/plugin/hooks/hooks.json +81 -1
  18. package/plugin/mcp/server.mjs +23 -0
  19. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  20. package/plugin/scripts/advocacy-route.mjs +460 -0
  21. package/plugin/scripts/continuation-gate.mjs +25 -2
  22. package/plugin/scripts/continuation-objective.mjs +7 -1
  23. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  24. package/plugin/scripts/coverage-integrity.mjs +7 -0
  25. package/plugin/scripts/gates.mjs +113 -10
  26. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  27. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  28. package/plugin/scripts/hook-shim.mjs +14 -0
  29. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  30. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  31. package/plugin/scripts/project-progression-contract.mjs +16 -0
  32. package/plugin/scripts/project-progression-hook.mjs +3 -0
  33. package/plugin/scripts/project-progression-producer.mjs +252 -0
  34. package/plugin/scripts/project-progression-reader.mjs +271 -0
  35. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  36. package/plugin/scripts/project-progression-sources.mjs +220 -0
  37. package/plugin/scripts/project-progression-store.mjs +106 -13
  38. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  39. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  40. package/plugin/scripts/session-start-budget.mjs +59 -0
  41. package/plugin/scripts/session-start-core.mjs +234 -457
  42. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  43. package/plugin/scripts/session-start-health.mjs +64 -0
  44. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  45. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  46. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  47. package/plugin/scripts/session-start-signals.mjs +73 -0
  48. package/plugin/scripts/session-start-trace.mjs +86 -0
  49. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  50. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  51. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  52. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  53. package/scripts/adr-072-completion.mjs +1 -1
  54. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  55. package/scripts/approved-runtime.mjs +197 -0
  56. package/scripts/brain-novice-50.mjs +16 -1
  57. package/scripts/brain-score.mjs +23 -5
  58. package/scripts/build-bundle.mjs +971 -530
  59. package/scripts/build-concepts.mjs +36 -116
  60. package/scripts/console-engine.test.mjs +8 -7
  61. package/scripts/console-runtime-identity.mjs +4 -0
  62. package/scripts/corpus-aggregates.mjs +94 -77
  63. package/scripts/corpus-candidate.mjs +475 -222
  64. package/scripts/corpus-next-seed.mjs +225 -0
  65. package/scripts/corpus-promotion.mjs +58 -0
  66. package/scripts/corpus-reconcile.mjs +411 -105
  67. package/scripts/doc-currency.mjs +16 -1
  68. package/scripts/dual-host-deliberation.mjs +25 -2
  69. package/scripts/dual-host-suggest.mjs +17 -1
  70. package/scripts/falsify.mjs +13 -3
  71. package/scripts/gist-receipts.mjs +482 -87
  72. package/scripts/github-health-watch.mjs +12 -2
  73. package/scripts/handoff-asset.mjs +34 -0
  74. package/scripts/hook-retirement-check.mjs +8 -1
  75. package/scripts/host-registry.mjs +1 -1
  76. package/scripts/ingest-gists.mjs +74 -101
  77. package/scripts/job-heartbeat.sh +77 -14
  78. package/scripts/learning-replay-execution.mjs +10 -4
  79. package/scripts/nightly-gists.sh +27 -13
  80. package/scripts/nightly-two-run-proof.mjs +1 -1
  81. package/scripts/nightly-watchdog.mjs +61 -4
  82. package/scripts/onboarding-console.mjs +319 -27
  83. package/scripts/oracle/produce-questions.mjs +293 -0
  84. package/scripts/oracle/producer-hosts.mjs +235 -0
  85. package/scripts/oracle/repo-recall.mjs +448 -0
  86. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  87. package/scripts/oracle/source-tree.mjs +165 -0
  88. package/scripts/oracle/source-units.mjs +391 -0
  89. package/scripts/oracle/spike-run.mjs +98 -0
  90. package/scripts/oracle/unit-inventory.mjs +141 -0
  91. package/scripts/oracle/unit-sampling.mjs +128 -0
  92. package/scripts/oracle/validate-labels.mjs +250 -0
  93. package/scripts/private-overlay.mjs +248 -0
  94. package/scripts/product-integrity-contract.mjs +1 -1
  95. package/scripts/proxy/claude-proxied.sh +6 -0
  96. package/scripts/proxy/proxy-revert.sh +5 -0
  97. package/scripts/proxy/proxy-up.sh +6 -0
  98. package/scripts/proxy/proxy-verify.mjs +4 -0
  99. package/scripts/public-inputs.mjs +409 -0
  100. package/scripts/public-verification-inputs.mjs +112 -26
  101. package/scripts/public-verification-lane.mjs +1 -1
  102. package/scripts/published-surface-probe.mjs +34 -4
  103. package/scripts/qe/card-lane-gate.mjs +16 -1
  104. package/scripts/qe/session-start-gate.mjs +16 -1
  105. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  106. package/scripts/record-lesson.mjs +4 -1
  107. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  108. package/scripts/release-abort-stale.mjs +5 -1
  109. package/scripts/release-authority.mjs +104 -12
  110. package/scripts/release-channel-kind.mjs +86 -0
  111. package/scripts/release-convergence-watchdog.mjs +7 -2
  112. package/scripts/release-projection.mjs +177 -72
  113. package/scripts/release-transaction-provider.mjs +23 -6
  114. package/scripts/release.mjs +252 -17
  115. package/scripts/retrieval-canary.mjs +87 -0
  116. package/scripts/rvf-index-audit.mjs +573 -13
  117. package/scripts/rvf-wire.mjs +269 -0
  118. package/scripts/seal-gist-receipt.mjs +65 -0
  119. package/scripts/selfcheck.mjs +42 -21
  120. package/scripts/source-coverage.mjs +253 -24
  121. package/scripts/status-honesty.mjs +25 -0
  122. package/scripts/sync-census.mjs +0 -0
  123. package/scripts/sync-version.mjs +2 -0
  124. package/scripts/trismart.mjs +42 -0
  125. package/scripts/updater-manifest.mjs +162 -0
  126. package/scripts/verify-channels.mjs +17 -5
  127. package/scripts/wired-check.mjs +48 -10
  128. package/tri-smart-skill/QUICKSTART.md +37 -0
  129. package/tri-smart-skill/README.md +92 -0
  130. package/tri-smart-skill/install.cmd +14 -0
  131. package/tri-smart-skill/install.command +13 -0
  132. package/tri-smart-skill/install.mjs +51 -0
  133. package/tri-smart-skill/install.sh +9 -0
  134. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  135. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  136. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  137. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  138. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  139. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  140. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  141. 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-publish.mjs.
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, sourceObservationDigest } from './source-coverage.mjs';
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
- if (fs.existsSync(path.join(corpusRoot, 'PRIVATE-STORES.json'))) {
94
- fail('a published seed must not supply a private-store fence; the exact builder checkout owns that policy');
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)) fs.renameSync(path.join(corpusRoot, entry), path.join(assets, entry));
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 async function reconcileUntilStable({ maxRounds = 3, assetsDir = null, observe, build, readLedger: currentLedger,
152
- execute, prune, rebuild } = {}) {
153
- if (!Number.isSafeInteger(maxRounds) || maxRounds < 1 || maxRounds > 10
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 reconciliation loop configuration is invalid');
249
+ fail('bounded acquisition configuration is invalid');
156
250
  }
157
- const rounds = [];
158
- let observation = await observe();
159
- for (let round = 1; round <= maxRounds; round += 1) {
160
- const preliminary = await build(observation);
161
- const plan = planReconciliation({ coverage: preliminary, ledger: currentLedger(), assetsDir });
162
- const reconciliation = await execute(plan, round);
163
- const pruning = await prune(preliminary, round);
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(preliminary, observation, round);
262
+ aggregates = await rebuild(coverage, observation, attempt);
167
263
  } catch (error) {
168
264
  if (error?.code !== 'GIST_OBSERVATION_MOVED') throw error;
169
- const nextObservation = await observe();
170
- rounds.push({ round, before: observation.observationSha256, after: nextObservation.observationSha256,
171
- plan, ...reconciliation, ...pruning, rebuilt: [], invalidated: {
172
- reason: 'gist observation moved during exact detail fetch', gistId: error.gistId || null } });
173
- observation = nextObservation;
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
- const nextObservation = await observe();
177
- rounds.push({ round, before: observation.observationSha256, after: nextObservation.observationSha256,
178
- plan, ...reconciliation, ...pruning, ...aggregates });
179
- if (nextObservation.observationSha256 === observation.observationSha256) {
180
- const coverage = await build(nextObservation);
181
- const remaining = planReconciliation({ coverage, ledger: currentLedger(), assetsDir });
182
- if (remaining.length) fail(`reconciliation stabilized with ${remaining.length} unresolved repository artifact(s)`);
183
- const unresolved = coverage.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
184
- if (unresolved.length) fail(`reconciliation stabilized with ${unresolved.length} unresolved eligible source(s)`);
185
- return { observation: nextObservation, coverage, rounds };
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
- fail(`source observation did not stabilize within ${maxRounds} reconciliation rounds`);
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
- results[index] = await worker(orderedPlan[index]);
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 = ['capability-cards.md', 'external-sources.json', 'no-corpus-repos.json',
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
- export async function materializeGistReceipts({ observation, assetsDir, fetchGist, fetchBody, now } = {}) {
399
- if (observation?.observationSha256 !== sourceObservationDigest(observation)) {
400
- fail('gist receipts require an exact sealed source observation');
401
- }
402
- const sourceFile = path.join(path.resolve(assetsDir || ''), 'ruv-gists.sources.json');
403
- const existing = fs.existsSync(sourceFile) ? readJson(sourceFile, 'existing gist receipts') : null;
404
- const receipt = await reconcileGistReceipts({ observation, existing, fetchGist, fetchBody, now });
405
- if (receipt.sourceObservationSha256 !== observation.observationSha256) {
406
- fail('gist receipts differ from the sealed source observation');
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
- const observation = await observe({ owner, externalSources: policy.sources });
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 reconcileCorpusUntilStable({ owner = 'ruvnet', assetsDir, workspaceDir,
424
- root = DEFAULT_ROOT, maxRounds = 3,
425
- observeAndMaterialize = null,
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
- prune = () => ({ pruned: [] }),
431
- rebuild = (_coverage, observation) => rebuildCorpusAggregates({ assetsDir, observation, root }),
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
- return reconcileUntilStable({
436
- maxRounds,
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: async () => (await (observeAndMaterialize
439
- ? observeAndMaterialize({ owner, assetsDir })
440
- : observeAndMaterializeGistReceipts({ owner, assetsDir }))).observation,
616
+ observe: () => (observe || observeSourceOnly)({ owner, assetsDir }),
441
617
  build,
442
618
  readLedger,
443
- execute: (plan, round) => execute({
444
- plan, assetsDir, workspaceDir: path.join(workspace, `round-${round}`), root,
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
- export async function reconcileAndPrepareCorpusCandidate({ plan, assetsDir, workspaceDir, root = DEFAULT_ROOT,
452
- owner = 'ruvnet', builderSha, candidateDir, receiptFile, coverageFile,
453
- execute = executeReconciliation, prepare = prepareCorpusCandidate } = {}) {
454
- const reconciliation = await execute({ plan, assetsDir, workspaceDir, root });
455
- const candidate = await prepare({ root, assetsDir, owner, builderSha, candidateDir, receiptFile, coverageFile });
456
- return { reconciliation, candidate };
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
- const coverageScript = path.join(sourceRoot, 'scripts', 'source-coverage.mjs');
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
- for (const required of [coverageScript, buildScript, receiptScript]) {
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
- checked(run, process.execPath, [coverageScript, '--owner', owner, '--assets', assets, '--write'], { stdio: 'inherit' });
486
- checked(run, process.execPath, [coverageScript, '--owner', owner, '--assets', assets, '--check', '--strict'], { stdio: 'inherit' });
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
- checked(run, process.execPath, [receiptScript, '--assets', assets, '--bundle', bundleFile,
491
- '--policy', policy, '--receipt', receipt, '--builder-source-sha', builderSha], { stdio: 'inherit' });
492
- checked(run, process.execPath, [receiptScript, '--verify', '--assets', assets, '--bundle', bundleFile,
493
- '--policy', policy, '--receipt', receipt], { stdio: 'inherit' });
494
- return { bundleFile, receiptFile: receipt, coverageFile: policy };
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
- assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: process.argv.includes('--allow-pinned-seed-tag') });
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
- plan: [], assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile,
528
- execute: () => reconcileCorpusUntilStable({ owner, assetsDir, workspaceDir, root }),
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
- if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
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;