ruvnet-brain 4.3.35 → 4.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,11 +16,15 @@ import { buildCoverage, observeSourceUniverse, renderMarkdown } from './source-c
16
16
  import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
17
17
  import { rebuildCorpusAggregates } from './corpus-aggregates.mjs';
18
18
  import { assertCapabilityOnlyStore, isCapabilityOnly, CAPABILITY_RETIRED_SUFFIXES } from '../kb/capability-only.mjs';
19
- import { fileIdentity } from '../plugin/scripts/coverage-integrity.mjs';
19
+ import { eligibleRepositoryStanding, fileIdentity, validateCoverageLedger } from '../plugin/scripts/coverage-integrity.mjs';
20
+ import {
21
+ CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound, degradedPublication, failureReason, isRetryable,
22
+ } from './corpus-store-failure.mjs';
20
23
  import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
21
24
  import { storeRoot } from '../kb/store-root.mjs';
22
25
  import { captureGistSources } from './gist-receipts.mjs';
23
26
  import { projectSourceStore, RUNTIME_LEDGER_KIND } from './rvf-generation.mjs';
27
+ import { compareKnowledgeInputs, fromCoverage as knowledgeFromCoverage, fromSeed as knowledgeFromSeed } from './knowledge-input-digest.mjs';
24
28
 
25
29
  export { rebuildCorpusAggregates };
26
30
 
@@ -128,6 +132,10 @@ function filesNamed(root, wanted) {
128
132
  // corpus-seed.yml can re-run seed extraction ONCE from the committed bootstrap in the same job.
129
133
  export const SEED_LEDGER_SCHEMA_VERSION = 2;
130
134
  export const SEED_LEDGER_INCOMPATIBLE_EXIT = 3;
135
+ // ADR-0091 D5 + D10: the generation was built and SEALED with carried/missing stores, but degraded
136
+ // publication is not yet allowed (no soaked tolerant-validator transition). It must not be published;
137
+ // the next night re-plans the carried stores automatically because their sourceCommit still differs.
138
+ export const DEGRADED_UNPUBLISHED_EXIT = 4;
131
139
 
132
140
  export class SeedLedgerIncompatibleError extends Error {
133
141
  constructor(reason) {
@@ -291,7 +299,7 @@ async function measureFreshness({ closingObservation, observation }) {
291
299
  * generation; `latest` is never substituted, and an exhausted partial generation is never accepted.
292
300
  */
293
301
  export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = null, observe, build,
294
- readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null } = {}) {
302
+ readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null, unchanged = null } = {}) {
295
303
  if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
296
304
  || [observe, build, currentLedger, execute, prune, rebuild].some((fn) => typeof fn !== 'function')) {
297
305
  fail('bounded acquisition configuration is invalid');
@@ -299,15 +307,33 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
299
307
  // ONE discovery pass. This observation is the sealed manifest every later step consumes; it is never
300
308
  // re-taken, so upstream churn cannot restart or invalidate the generation.
301
309
  const observation = await observe();
310
+ // NO-CHANGE, DECIDED BEFORE ANYTHING IS BUILT (2026-09-29 nightly redesign). When every knowledge
311
+ // input equals the seed's (scripts/knowledge-input-digest.mjs), the night ends here: no gist
312
+ // preflight, no clone, no embedding, no aggregate rebuild, nothing sealed or published.
313
+ if (typeof unchanged === 'function') {
314
+ const knowledgeInput = await unchanged(observation);
315
+ if (knowledgeInput?.unchanged === true) {
316
+ return { noChange: true, observation, attempts: [], consistencyModel: CONSISTENCY_MODEL, knowledgeInput };
317
+ }
318
+ }
302
319
  // Validate/fetch the source most likely to fail late (gist detail/raw access) before any expensive
303
320
  // repository clone and embedding work. Its verified bodies are the existing capture cache consumed
304
321
  // by the later aggregate build, so preflight does not double-fetch or weaken source binding.
305
322
  const preflightResult = typeof preflight === 'function' ? await preflight(observation) : null;
306
323
  const attempts = [];
324
+ // ADR-0091 D5: stores whose refresh FAILED this generation, keyed by folded store name, with what
325
+ // they became -- { carry } | { failure } | { integrity }. They are never re-executed by a later
326
+ // attempt of this loop and never count as remaining/unresolved: before D5 one stuck store cost all
327
+ // three attempts (each re-running a ~22-minute aggregate rebuild) and then failed the night anyway.
328
+ // The loop now re-attempts only for what it was built for: a gist revision that moved mid-fetch.
329
+ const storeOutcomes = {};
330
+ const recorded = (store) => Object.hasOwn(storeOutcomes, String(store || '').toLowerCase());
307
331
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
308
- const coverage = await build(observation);
309
- const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir });
332
+ const coverage = await build(observation, storeOutcomes);
333
+ const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir }).filter((item) => !recorded(item.store));
310
334
  const reconciliation = await execute(plan, attempt);
335
+ recordStoreOutcomes(storeOutcomes, reconciliation);
336
+ assertIsolatedFailures({ coverage, storeOutcomes });
311
337
  const pruning = await prune(coverage, attempt);
312
338
  let aggregates;
313
339
  try {
@@ -327,14 +353,17 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
327
353
  // row still pinned the PRE-rebuild ruv-gists digest and build-bundle refused the candidate with
328
354
  // "coverage row gist:... was measured against different ruv-gists RVF bytes than this corpus
329
355
  // carries", 56 minutes into an otherwise complete run.
330
- const settled = await build(observation);
331
- const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir });
332
- const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
356
+ const settled = await build(observation, storeOutcomes);
357
+ const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir })
358
+ .filter((item) => !recorded(item.store));
359
+ const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT'
360
+ && !(row.kind === 'repository' && recorded(row.artifact?.store)));
333
361
  attempts.push({ attempt, plan, ...reconciliation, ...pruning, ...aggregates,
334
362
  remainingArtifacts: remaining.length, unresolvedSources: unresolved.length });
335
363
  if (!remaining.length && !unresolved.length) {
336
364
  return {
337
365
  observation, coverage: settled, attempts, consistencyModel: CONSISTENCY_MODEL,
366
+ degraded: degradedSummary(storeOutcomes),
338
367
  freshness: await measureFreshness({ closingObservation, observation }),
339
368
  };
340
369
  }
@@ -345,6 +374,45 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
345
374
  + 'eligible source(s) remain unresolved against the sealed manifest');
346
375
  }
347
376
 
377
+ function recordStoreOutcomes(storeOutcomes, reconciliation) {
378
+ for (const { store, carry } of reconciliation?.carried || []) storeOutcomes[store.toLowerCase()] = { carry };
379
+ for (const { store, failure } of reconciliation?.missing || []) storeOutcomes[store.toLowerCase()] = { failure };
380
+ for (const { store, integrity } of reconciliation?.integrityFailures || []) storeOutcomes[store.toLowerCase()] = { integrity };
381
+ }
382
+
383
+ export function degradedSummary(storeOutcomes) {
384
+ const entries = Object.entries(storeOutcomes || {}).sort(([a], [b]) => a.localeCompare(b));
385
+ return {
386
+ carried: entries.filter(([, outcome]) => outcome.carry).map(([store, outcome]) => ({ store, ...outcome.carry })),
387
+ missing: entries.filter(([, outcome]) => outcome.failure).map(([store, outcome]) => ({ store, ...outcome.failure })),
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Fail the generation the moment isolation stops being the right answer, BEFORE pruning and the
393
+ * ~22-minute aggregate rebuild are spent on it:
394
+ * - any integrity failure (carried bytes that no longer match the seed ledger, or a same-commit
395
+ * rebuild that failed) -- such a row is FAILED, and FAILED is never shippable, so no bound can
396
+ * admit it;
397
+ * - more carried + missing stores than max(3, 5% of eligible) -- the failure is systemic, and
398
+ * publishing around it would hide a forge or network regression.
399
+ */
400
+ export function assertIsolatedFailures({ coverage, storeOutcomes }) {
401
+ const outcomes = Object.entries(storeOutcomes || {});
402
+ const integrity = outcomes.filter(([, outcome]) => outcome.integrity);
403
+ if (integrity.length) {
404
+ fail(`integrity failure in ${integrity.length} store(s): `
405
+ + `${integrity.map(([store, outcome]) => `${store} (${outcome.integrity})`).join('; ')} -- the generation fails`);
406
+ }
407
+ const eligible = (coverage?.rows || []).filter((row) => row.kind === 'repository' && row.disposition === 'eligible').length;
408
+ const isolated = outcomes.length;
409
+ const bound = degradedBound(eligible);
410
+ if (isolated > bound) {
411
+ fail(`systemic failure: ${isolated} of ${eligible} eligible store(s) failed to refresh `
412
+ + `(${outcomes.map(([store]) => store).join(', ')}); the bound is max(3, 5% of eligible) = ${bound} -- the generation fails`);
413
+ }
414
+ }
415
+
348
416
  /**
349
417
  * The ONE reader of an acquisition result's per-attempt history (ADR-0091 D1).
350
418
  *
@@ -369,6 +437,12 @@ export function summarizeReconciliation(reconciliation) {
369
437
  refreshed: across('refreshed'),
370
438
  pruned: across('pruned'),
371
439
  rebuilt: across('rebuilt'),
440
+ // ADR-0091 D5: the stores this generation carries (STALE) or lacks (MISSING) after an isolated
441
+ // refresh failure. Empty lists on an all-CURRENT generation.
442
+ degraded: {
443
+ carried: reconciliation.degraded?.carried || [],
444
+ missing: reconciliation.degraded?.missing || [],
445
+ },
372
446
  };
373
447
  }
374
448
 
@@ -385,7 +459,7 @@ function checked(run, command, args, options = {}) {
385
459
  return result;
386
460
  }
387
461
 
388
- function defaultRunAsync(command, args, options = {}) {
462
+ export function defaultRunAsync(command, args, options = {}) {
389
463
  return new Promise((resolve) => {
390
464
  const inherited = options.stdio === 'inherit';
391
465
  const child = spawn(command, args, { ...options, encoding: undefined,
@@ -401,15 +475,6 @@ function defaultRunAsync(command, args, options = {}) {
401
475
  });
402
476
  }
403
477
 
404
- async function checkedAsync(run, command, args, options = {}) {
405
- const result = await run(command, args, options) || {};
406
- if (result.error || result.status !== 0) {
407
- const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim();
408
- fail(`${command} ${args.join(' ')} failed${detail ? ` (${detail})` : ''}`);
409
- }
410
- return result;
411
- }
412
-
413
478
  const storeArtifacts = (store) => STORE_ARTIFACT_SUFFIXES.map((suffix) => `${store}${suffix}`);
414
479
 
415
480
  function writeJsonAtomic(file, value) {
@@ -505,6 +570,81 @@ function validateWorkerOutput({ output, item }) {
505
570
  return { ...payload, receiptSha256: crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'), output };
506
571
  }
507
572
 
573
+ // ADR-0091 D5: the commit date of the bytes a carried store keeps, when the PREVIOUS generation's
574
+ // sealed coverage proves it. A row whose observed upstream SHA IS the carried commit dates that exact
575
+ // commit; a row that was itself carried passes its own carriedCommittedAt on. Anything else -- no
576
+ // prior coverage (the bootstrap lineage), an invalid one, a different commit -- is null, never
577
+ // estimated (D7.1 reads this for `oldestCarried`).
578
+ export function readPriorCoverage(assetsDir) {
579
+ const file = path.join(path.resolve(assetsDir || ''), 'CORPUS-COVERAGE.json');
580
+ if (!fs.existsSync(file)) return null;
581
+ try {
582
+ const coverage = JSON.parse(fs.readFileSync(file, 'utf8'));
583
+ return coverage?.kind === 'ruvnet-brain-corpus-coverage' && validateCoverageLedger(coverage).valid ? coverage : null;
584
+ } catch {
585
+ return null;
586
+ }
587
+ }
588
+
589
+ export function carriedCommittedAt({ priorCoverage, store, sourceCommit }) {
590
+ const folded = String(store || '').toLowerCase();
591
+ const commit = String(sourceCommit || '').toLowerCase();
592
+ const row = (priorCoverage?.rows || []).find((candidate) => candidate?.kind === 'repository'
593
+ && String(candidate?.artifact?.store || '').toLowerCase() === folded);
594
+ const iso = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null);
595
+ if (!row || !HEX40.test(commit)) return null;
596
+ if (String(row.upstream?.sha || '').toLowerCase() === commit) return iso(row.upstream?.committedAt);
597
+ if (String(row.carry?.carriedSourceCommit || '').toLowerCase() === commit) return iso(row.carry?.carriedCommittedAt);
598
+ return null;
599
+ }
600
+
601
+ // ADR-0091 D5: what a store whose refresh FAILED becomes. planReconciliation's byte check never
602
+ // covers a store it plans (it runs only when sourceCommit already equals upstream), so the seed
603
+ // bytes are re-hashed HERE before they may stand in for the missed refresh. The ledger binds one file
604
+ // per store (the .big.rvf: file, bytes, sha256); the rest of the family must be present as regular
605
+ // files. The seed archive itself was digest-verified on download (assertBootstrapIdentity).
606
+ export function dispositionForFailedStore({ assetsDir, ledger, item, attempts, reason, priorCoverage = null }) {
607
+ const assets = path.resolve(assetsDir || '');
608
+ const generation = Object.entries(ledger?.stores || {})
609
+ .find(([name]) => name.toLowerCase() === item.store.toLowerCase())?.[1] || null;
610
+ if (!generation) return { store: item.store, failure: { reason, attempts } };
611
+ const carried = String(generation.sourceCommit || '').toLowerCase();
612
+ if (!HEX40.test(carried)) {
613
+ return { store: item.store, integrity: 'carried bytes have no exact 40-hex ledger sourceCommit to carry' };
614
+ }
615
+ if (carried === item.upstreamSha) {
616
+ // Planned for a policy or receipt reason at the SAME commit (a capability-only clean rebuild, or
617
+ // seed bytes that already failed the receipt check): the seed bytes are exactly what the rebuild
618
+ // was meant to replace, so they cannot stand in for it.
619
+ return { store: item.store, integrity: 'the refresh was a same-commit rebuild the seed bytes cannot stand in for' };
620
+ }
621
+ const regular = (name) => {
622
+ try { const stat = fs.lstatSync(path.join(assets, name)); return stat.isFile() && !stat.isSymbolicLink(); }
623
+ catch { return false; }
624
+ };
625
+ const rvf = `${item.store}.big.rvf`;
626
+ const bound = generation.file === rvf && regular(rvf)
627
+ && generation.bytes === fs.statSync(path.join(assets, rvf)).size
628
+ && generation.sha256 === sha256File(path.join(assets, rvf))
629
+ && REQUIRED_STORE_ARTIFACT_SUFFIXES.every((suffix) => regular(`${item.store}${suffix}`));
630
+ if (!bound) return { store: item.store, integrity: 'carried bytes differ from the seed generation ledger' };
631
+ return { store: item.store, carry: {
632
+ reason,
633
+ carriedSourceCommit: carried,
634
+ missedUpstream: item.upstreamSha,
635
+ attempts,
636
+ carriedCommittedAt: carriedCommittedAt({ priorCoverage, store: item.store, sourceCommit: carried }),
637
+ } };
638
+ }
639
+
640
+ // Only a TRANSIENT failure is retried, exactly once, in a fresh directory: `<store>-retry1`. The first
641
+ // attempt's directory is never reused -- a clone or a half-written worker output from the failed
642
+ // attempt must not be mistaken for the retry's own (the pre-D5 path collided).
643
+ export function workerRootFor(workspace, store, retry) {
644
+ return path.join(workspace, 'workers', retry === 0 ? store : `${store}-retry${retry}`);
645
+ }
646
+ export const MAX_TRANSIENT_RETRIES = 1;
647
+
508
648
  export async function executeReconciliation({
509
649
  plan,
510
650
  assetsDir,
@@ -513,6 +653,8 @@ export async function executeReconciliation({
513
653
  run = defaultRunAsync,
514
654
  concurrency = 5,
515
655
  signal,
656
+ priorCoverage = null,
657
+ log = (line) => console.log(line),
516
658
  }) {
517
659
  if (!Array.isArray(plan) || !Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 10) {
518
660
  fail('reconciliation plan or worker concurrency is invalid');
@@ -532,50 +674,94 @@ export async function executeReconciliation({
532
674
  const lowerStores = orderedPlan.map(({ store }) => store.toLowerCase());
533
675
  if (new Set(lowerStores).size !== lowerStores.length) fail('reconciliation plan has duplicate or case-fold-colliding stores');
534
676
 
535
- // Step 4, required proof 4 (2026-09-13): every worker in this pool shares ONE internal
536
- // AbortController. Before this, `Promise.all` over the fixed-size worker pool below rejected as
537
- // soon as ANY lane's `worker()` threw -- but the OTHER lanes kept running their own `while` loop
538
- // completely unobserved: still cloning, still spawning forge-refresh, with nobody left awaiting
539
- // them once the outer Promise.all had already settled. A later failure (or success) in one of
540
- // those orphaned lanes could then surface as an unhandled rejection, or simply keep doing
541
- // unnecessary work after the round was already lost. Now: the first failure aborts the shared
542
- // signal, every lane observes it (both at its own loop-top and via the signal threaded into every
543
- // child-process spawn below) and returns promptly, and `Promise.all` -- which no lane's promise
544
- // ever rejects out of directly -- only resolves once every lane has actually stopped. Only then do
545
- // we throw the FIRST real error (an aborted sibling's own error is discarded, never overwrites it).
546
- // An externally supplied `signal` (a caller discarding this whole round) aborts the same
547
- // controller, so both cancellation paths join through the one place.
677
+ // ADR-0091 D5: ONE store failing no longer aborts the round. Before D5 the first worker error
678
+ // aborted every sibling (2 of 9 corpus runs died that way: one deterministic `ruvector` QA miss
679
+ // threw away 93 other stores' refreshes). Now each store's failure is recorded and its lane moves
680
+ // on; the shared AbortController below exists ONLY for an externally supplied `signal` -- a caller
681
+ // discarding the whole round -- which still stops and joins every lane (required proof 4).
548
682
  const controller = new AbortController();
549
683
  if (signal) {
550
684
  if (signal.aborted) controller.abort(signal.reason);
551
685
  else signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
552
686
  }
553
687
 
554
- const worker = async (item) => {
688
+ const stage = async (item, name, classify, command, args, options = {}) => {
689
+ const result = await run(command, args, { ...options, signal: controller.signal }) || {};
690
+ if (controller.signal.aborted) throw abortError(controller.signal);
691
+ if (result.error || result.status !== 0) {
692
+ const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim().slice(0, 400);
693
+ throw new StoreWorkerError({ store: item.store, stage: name, failureClass: classify(result), detail });
694
+ }
695
+ return result;
696
+ };
697
+ const transient = () => FAILURE_CLASS.TRANSIENT;
698
+ // forge-refresh's exit status is the structured reason: CORPUS_QA_FAILED_EXIT means corpus-qa refused
699
+ // the candidate (deterministic, never retried); a spawn error is runner I/O (transient); any other
700
+ // non-zero exit is a build failure (not retried: a retry is a full re-embed with no reason to differ).
701
+ const forgeClass = (result) => (result.status === CORPUS_QA_FAILED_EXIT ? FAILURE_CLASS.QA
702
+ : result.error ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.BUILD);
703
+
704
+ const worker = async (item, retry) => {
555
705
  if (controller.signal.aborted) throw abortError(controller.signal);
556
706
  if (!SAFE_STORE.test(item.store) || !HEX40.test(item.upstreamSha) || !repositorySlug(item.url)) {
557
- fail(`unsafe reconciliation item for ${item?.store || item?.name || 'unknown store'}`);
707
+ throw new StoreWorkerError({ store: item?.store || item?.name || 'unknown store', stage: 'plan item',
708
+ failureClass: FAILURE_CLASS.INTEGRITY, detail: 'unsafe reconciliation item' });
709
+ }
710
+ const workerRoot = workerRootFor(workspace, item.store, retry);
711
+ if (fs.existsSync(workerRoot)) {
712
+ throw new StoreWorkerError({ store: item.store, stage: 'worker directory', failureClass: FAILURE_CLASS.INTEGRITY,
713
+ detail: 'a fresh worker directory already exists' });
558
714
  }
559
- const workerRoot = path.join(workspace, 'workers', item.store);
560
715
  const cloneDir = path.join(workerRoot, 'clone');
561
716
  const output = path.join(workerRoot, 'assets');
562
- fs.mkdirSync(workerRoot, { recursive: true });
563
- seedWorkerAssets({ assets, output, store: item.store, ledger: canonicalLedger, source: canonicalSource });
564
- await checkedAsync(run, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir], { signal: controller.signal });
565
- await checkedAsync(run, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha], { signal: controller.signal });
566
- await checkedAsync(run, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD'], { signal: controller.signal });
567
- const head = await checkedAsync(run, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD'], { signal: controller.signal });
717
+ try {
718
+ fs.mkdirSync(workerRoot, { recursive: true });
719
+ seedWorkerAssets({ assets, output, store: item.store, ledger: canonicalLedger, source: canonicalSource });
720
+ } catch (error) {
721
+ // An errno (disk, file table) is runner I/O; our own refusal is an integrity failure.
722
+ throw new StoreWorkerError({ store: item.store, stage: 'worker seed copy',
723
+ failureClass: typeof error?.code === 'string' ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.INTEGRITY, detail: error.message });
724
+ }
725
+ await stage(item, 'git clone', transient, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir]);
726
+ await stage(item, 'git fetch', transient, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha]);
727
+ await stage(item, 'git checkout', transient, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD']);
728
+ const head = await stage(item, 'git rev-parse', transient, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD']);
568
729
  if (String(head.stdout || '').trim().toLowerCase() !== item.upstreamSha) {
569
- fail(`${item.store}: fresh clone did not resolve the exact upstream SHA`);
730
+ throw new StoreWorkerError({ store: item.store, stage: 'exact-sha checkout', failureClass: FAILURE_CLASS.INTEGRITY,
731
+ detail: 'fresh clone did not resolve the exact upstream SHA' });
570
732
  }
571
- await checkedAsync(run, process.execPath, [forge, '--repo', cloneDir, '--out', output, '--name', item.store,
733
+ await stage(item, 'forge-refresh', forgeClass, process.execPath, [forge, '--repo', cloneDir, '--out', output, '--name', item.store,
572
734
  ...(FULL_HINTS[item.store] ? ['--full', FULL_HINTS[item.store]] : []),
573
735
  ...(KEEP_DIRS[item.store] ? ['--keep', KEEP_DIRS[item.store]] : []),
574
- ], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' }, signal: controller.signal });
575
- return validateWorkerOutput({ output, item });
736
+ ], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' } });
737
+ try {
738
+ return validateWorkerOutput({ output, item });
739
+ } catch (error) {
740
+ throw new StoreWorkerError({ store: item.store, stage: 'worker output validation', failureClass: FAILURE_CLASS.INTEGRITY,
741
+ detail: error.message });
742
+ }
743
+ };
744
+
745
+ const runStore = async (item) => {
746
+ let lastError = null;
747
+ let attempts = 0;
748
+ for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry += 1) {
749
+ attempts += 1;
750
+ try {
751
+ return { ok: true, attempts, result: await worker(item, retry) };
752
+ } catch (error) {
753
+ if (controller.signal.aborted) throw error;
754
+ lastError = error;
755
+ const retrying = isRetryable(error) && retry < MAX_TRANSIENT_RETRIES;
756
+ log(`[corpus-reconcile] ${item.store}: attempt ${attempts} failed -- ${error.message}`
757
+ + (retrying ? '; retrying once in a fresh worker directory' : '; not retried'));
758
+ if (!retrying) break;
759
+ }
760
+ }
761
+ return { ok: false, attempts, error: lastError };
576
762
  };
577
763
 
578
- const results = new Array(orderedPlan.length);
764
+ const outcomes = new Array(orderedPlan.length);
579
765
  let next = 0;
580
766
  let firstError = null;
581
767
  await Promise.all(Array.from({ length: Math.min(concurrency, orderedPlan.length) }, async () => {
@@ -583,17 +769,35 @@ export async function executeReconciliation({
583
769
  if (controller.signal.aborted) return;
584
770
  const index = next++;
585
771
  try {
586
- results[index] = await worker(orderedPlan[index]);
772
+ outcomes[index] = await runStore(orderedPlan[index]);
587
773
  } catch (error) {
774
+ // Only an external cancellation reaches here; every lane is already observing the same signal.
588
775
  if (!firstError) firstError = error;
589
- controller.abort(error);
590
776
  return;
591
777
  }
592
778
  }
593
779
  }));
594
780
  if (!firstError && controller.signal.aborted) firstError = abortError(controller.signal);
595
781
  if (firstError) throw firstError;
596
- if (!results.length) return { refreshed: [], workers: [] };
782
+
783
+ const carried = [];
784
+ const missing = [];
785
+ const integrityFailures = [];
786
+ outcomes.forEach((outcome, index) => {
787
+ if (!outcome || outcome.ok) return;
788
+ const disposition = dispositionForFailedStore({ assetsDir: assets, ledger: canonicalLedger, item: orderedPlan[index],
789
+ attempts: outcome.attempts, reason: failureReason(outcome.error), priorCoverage });
790
+ if (disposition.carry) carried.push(disposition);
791
+ else if (disposition.failure) missing.push(disposition);
792
+ else integrityFailures.push(disposition);
793
+ log(`[corpus-reconcile] ${orderedPlan[index].store}: ${disposition.carry ? 'CARRIED at its verified seed bytes (STALE)'
794
+ : disposition.failure ? 'MISSING (no prior bytes)' : `INTEGRITY FAILURE (${disposition.integrity})`}`);
795
+ });
796
+ // The merge reads SUCCESSFUL results only. `outcomes` is indexed by plan position, so a failed
797
+ // store leaves a slot with no worker result; the pre-D5 merge read `result.files` off every slot.
798
+ const results = outcomes.filter((outcome) => outcome?.ok).map((outcome) => outcome.result);
799
+ const failed = { carried, missing, integrityFailures };
800
+ if (!results.length) return { refreshed: [], workers: [], ...failed };
597
801
 
598
802
  const merge = path.join(workspace, 'merge-candidate');
599
803
  fs.mkdirSync(merge);
@@ -629,7 +833,7 @@ export async function executeReconciliation({
629
833
  assertCapabilityOnlyStore(assets, store);
630
834
  }
631
835
  return { refreshed: results.map(({ store }) => store),
632
- workers: results.map(({ output: _output, ...receipt }) => receipt) };
836
+ workers: results.map(({ output: _output, ...receipt }) => receipt), ...failed };
633
837
  }
634
838
 
635
839
  // syncCorpusInputs — Step 3 (2026-09-13): this used to ALSO sync public-prose inputs
@@ -677,7 +881,8 @@ async function observeSourceOnly({ owner, assetsDir }) {
677
881
  export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, workspaceDir,
678
882
  root = DEFAULT_ROOT, maxAttempts = 3, closingObservation = null,
679
883
  observe = null,
680
- build = (observation) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation }),
884
+ build = (observation, storeOutcomes = null) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation,
885
+ storeOutcomes }),
681
886
  readLedger = () => readJson(path.join(path.resolve(assetsDir || ''), 'RVF-GENERATIONS.json'),
682
887
  'RVF generation ledger'),
683
888
  execute = executeReconciliation,
@@ -696,6 +901,14 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
696
901
  rebuild = (coverage, observation, _attempt, capturedGists) => rebuildCorpusAggregates({
697
902
  assetsDir, observation, coverage, root, cache: capturedGists,
698
903
  }),
904
+ // The seed's knowledge inputs come from the evidence it carries (read FIRST: a seed without it --
905
+ // the pre-contract bootstrap -- always builds, and costs no second coverage measurement); tonight's
906
+ // from the coverage `build` measures off the sealed observation, plus this checkout's public prose.
907
+ unchanged = async (observation) => {
908
+ const seed = knowledgeFromSeed(assetsDir);
909
+ if (!seed) return { unchanged: false, reason: 'the seed carries no knowledge-input evidence' };
910
+ return compareKnowledgeInputs({ seed, tonight: await knowledgeFromCoverage(await build(observation, {}), root) });
911
+ },
699
912
  } = {}) {
700
913
  if (!assetsDir || !workspaceDir) fail('stable reconciliation requires explicit assets and workspace directories');
701
914
  const workspace = path.resolve(workspaceDir || '');
@@ -704,6 +917,8 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
704
917
  assertPathNotOverlapping('reconciliation workspace directory', workspace, forbidden);
705
918
  assertPathNotOverlapping('reconciliation workspace directory', workspace,
706
919
  [{ label: 'the assets directory', dir: assetsDir }]);
920
+ // Read ONCE, before anything is rebuilt: the seed's own sealed coverage dates a carried store's bytes.
921
+ const priorCoverage = readPriorCoverage(assetsDir);
707
922
  return acquireSealedGeneration({
708
923
  maxAttempts,
709
924
  closingObservation,
@@ -712,11 +927,12 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
712
927
  build,
713
928
  readLedger,
714
929
  execute: (plan, attempt) => execute({
715
- plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root,
930
+ plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root, priorCoverage,
716
931
  }),
717
932
  prune,
718
933
  rebuild,
719
934
  preflight,
935
+ unchanged,
720
936
  });
721
937
  }
722
938
 
@@ -738,6 +954,8 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
738
954
  accuracyTimeoutMs = null,
739
955
  prepare = prepareCorpusCandidate } = {}) {
740
956
  const finalized = await reconcile({ owner, assetsDir, workspaceDir, root, maxAttempts });
957
+ // Nothing the corpus is built from changed since the seed: nothing to normalize, seal or measure.
958
+ if (finalized?.noChange === true) return { reconciliation: finalized, noChange: true, updaters: null, candidate: null };
741
959
  // Every shipped repository store needs a complete updater entry, and a seed that predates the
742
960
  // convention leaves inherited stores without one -- measured 2026-09-15: 100 of 194 repository
743
961
  // stores, none of them refreshed that run, which build-bundle rightly refused to ship. Normalize
@@ -803,8 +1021,7 @@ export function prepareCorpusCandidate({
803
1021
  if (!coverage || coverage.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)) {
804
1022
  fail('prepareCorpusCandidate requires an already-measured coverage object; it never re-observes live sources');
805
1023
  }
806
- const blockers = coverage.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
807
- if (blockers.length) fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT`);
1024
+ const degraded = assessCandidateCoverage(coverage);
808
1025
  assertPathNotOverlapping('candidate output directory', candidate, forbiddenOutputRoots(sourceRoot));
809
1026
  const buildScript = path.join(sourceRoot, 'scripts', 'build-bundle.mjs');
810
1027
  const receiptScript = path.join(sourceRoot, 'scripts', 'corpus-candidate.mjs');
@@ -881,10 +1098,12 @@ export function prepareCorpusCandidate({
881
1098
  // THE BLOCKING RETRIEVAL GATE (ADR-086 amendment 2026-09-15). Same placement and same discipline
882
1099
  // as the C3 run above — the EXTRACTED final archive through the customer query path — but this is
883
1100
  // the measurement that can refuse a candidate. It asks the 194 frozen human questions, one per
884
- // repository, and fails on any error, any repository that returns nothing of its own, or any
885
- // exact-file Hit@5 below the committed ratchet floor.
1101
+ // repository, and fails on any error or any repository that returns nothing of its own. The
1102
+ // exact-file Hit@5 floor is RECORDED in the report and never fails the CLI (ADR-0091 D7.6).
1103
+ // `--coverage` is this candidate's sealed observation: a fixture repository with no row in it is
1104
+ // retired (D7.2) instead of asked a question it has no store to answer.
886
1105
  const recallReportFile = `${bundleFile}.recall.json`;
887
- checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile],
1106
+ checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile, '--coverage', policy],
888
1107
  { stdio: 'inherit' });
889
1108
  // The candidate receipt is derived ENTIRELY from the sealed bundle's own bytes plus the detached,
890
1109
  // digest-bound reports — the separate assets/policy directory used to build it is no longer an
@@ -894,17 +1113,50 @@ export function prepareCorpusCandidate({
894
1113
  : [];
895
1114
  checked(run, process.execPath, [receiptScript, '--bundle', bundleFile,
896
1115
  '--receipt', receipt, '--builder-source-sha', builderSha,
897
- '--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile,
1116
+ '--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile, '--coverage', policy,
898
1117
  ...bootstrapArgs], { stdio: 'inherit' });
899
1118
  checked(run, process.execPath, [receiptScript, '--verify', '--bundle', bundleFile,
900
1119
  '--receipt', receipt, '--accuracy-report', accuracyReportFile,
901
- '--recall-report', recallReportFile], { stdio: 'inherit' });
1120
+ '--recall-report', recallReportFile, '--coverage', policy], { stdio: 'inherit' });
902
1121
  return {
903
1122
  bundleFile, receiptFile: receipt, coverageFile: policy,
904
- accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile,
1123
+ accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile, degraded,
905
1124
  };
906
1125
  }
907
1126
 
1127
+ /**
1128
+ * ADR-0091 D5 -- the gate that replaced "every eligible row is CURRENT". An eligible row passes when
1129
+ * it is CURRENT, or when it is a repository row the shipped validator itself accepts
1130
+ * (eligibleRepositoryStanding: STALE with a verified `carry`, MISSING with a `failure`). Everything
1131
+ * else -- a STALE/MISSING row with no record, FAILED, UNVERIFIED, any non-CURRENT gist -- still fails
1132
+ * closed, and so does a count of carried + missing stores above max(3, 5% of eligible).
1133
+ */
1134
+ export function assessCandidateCoverage(coverage) {
1135
+ const eligible = coverage.rows.filter((row) => row.disposition === 'eligible');
1136
+ const carried = [];
1137
+ const missing = [];
1138
+ const blockers = [];
1139
+ for (const row of eligible) {
1140
+ const standing = row.kind === 'repository' ? eligibleRepositoryStanding(row)
1141
+ : row.status === 'CURRENT' && row.carry === undefined && row.failure === undefined ? 'shipped' : null;
1142
+ if (standing === null) blockers.push(row);
1143
+ else if (row.carry) carried.push({ store: row.artifact.store, ...row.carry });
1144
+ else if (row.failure) missing.push({ store: row.artifact.store, ...row.failure });
1145
+ }
1146
+ if (blockers.length) {
1147
+ fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT and carry no verified carry/failure record `
1148
+ + `(${blockers.slice(0, 5).map((row) => `${row.artifact?.store || row.key}:${row.status}`).join(', ')}`
1149
+ + `${blockers.length > 5 ? ', ...' : ''})`);
1150
+ }
1151
+ const repositories = eligible.filter((row) => row.kind === 'repository').length;
1152
+ const bound = degradedBound(repositories);
1153
+ if (carried.length + missing.length > bound) {
1154
+ fail(`degraded coverage: ${carried.length} carried + ${missing.length} missing store(s) exceed `
1155
+ + `max(3, 5% of ${repositories} eligible) = ${bound}`);
1156
+ }
1157
+ return { carried, missing, bound, eligibleRepositories: repositories };
1158
+ }
1159
+
908
1160
  function arg(argv, name, fallback = null) {
909
1161
  const index = argv.indexOf(name);
910
1162
  return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
@@ -936,7 +1188,11 @@ export async function main(argv = process.argv.slice(2), {
936
1188
  ? Number(arg(argv, '--accuracy-sample-per-partition')) : null;
937
1189
  const accuracyTimeoutMs = arg(argv, '--accuracy-timeout-ms') ? Number(arg(argv, '--accuracy-timeout-ms')) : null;
938
1190
 
939
- const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: process.argv.includes('--allow-pinned-seed-tag') });
1191
+ // `--no-change-out <file>`: always written (true or false) once reconciliation returns, so
1192
+ // corpus-seed.yml never has to infer a no-change night from a missing file.
1193
+ const noChangeOut = arg(argv, '--no-change-out');
1194
+ // The argv this main() was HANDED, never process.argv: an injected invocation must mean what it says.
1195
+ const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: argv.includes('--allow-pinned-seed-tag') });
940
1196
  if (fs.existsSync(assetsDir) && fs.readdirSync(assetsDir).length) fail(`bootstrap assets directory is not empty (${assetsDir})`);
941
1197
  fs.mkdirSync(path.dirname(assetsDir), { recursive: true });
942
1198
  const extractParent = fs.mkdtempSync(path.join(path.dirname(assetsDir), '.corpus-seed-extract-'));
@@ -958,12 +1214,35 @@ export async function main(argv = process.argv.slice(2), {
958
1214
  fs.rmSync(extractParent, { recursive: true, force: true });
959
1215
  syncCorpusInputs({ root, assetsDir });
960
1216
  const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256, privateFenceEvidence: seedPrivateFenceEvidence(assetsDir) };
961
- const { reconciliation, candidate } = await reconcileAndPrepare({
1217
+ const { reconciliation, candidate, noChange = false } = await reconcileAndPrepare({
962
1218
  assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
963
1219
  accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
964
1220
  });
1221
+ if (noChangeOut) {
1222
+ fs.mkdirSync(path.dirname(path.resolve(noChangeOut)), { recursive: true });
1223
+ fs.writeFileSync(path.resolve(noChangeOut), `${JSON.stringify({
1224
+ noChange: noChange === true,
1225
+ knowledgeInputSha256: noChange === true ? reconciliation?.knowledgeInput?.tonightSha256 ?? null : null,
1226
+ observationSha256: reconciliation?.observation?.observationSha256 ?? null,
1227
+ })}\n`);
1228
+ }
1229
+ if (noChange === true) {
1230
+ stdout.write(`${JSON.stringify({ ok: true, noChange: true, seedTag, seedSha256,
1231
+ knowledgeInput: reconciliation?.knowledgeInput ?? null }, null, 2)}\n`);
1232
+ return 0;
1233
+ }
965
1234
  const { plan } = summarizeReconciliation(reconciliation);
966
- stdout.write(`${JSON.stringify({ ok: true, seedTag, seedSha256, plan, reconciliation, ...candidate }, null, 2)}\n`);
1235
+ const degraded = candidate.degraded || { carried: [], missing: [] };
1236
+ const isDegraded = degraded.carried.length + degraded.missing.length > 0;
1237
+ const publication = isDegraded ? degradedPublication() : { allowed: true, reason: 'every eligible row is CURRENT' };
1238
+ stdout.write(`${JSON.stringify({ ok: publication.allowed, seedTag, seedSha256, plan, reconciliation, ...candidate,
1239
+ degraded: { ...degraded, publishable: publication.allowed, reason: publication.reason } }, null, 2)}\n`);
1240
+ if (!publication.allowed) {
1241
+ stderr.write(`::warning title=Degraded corpus generation sealed, not published::${degraded.carried.length} carried `
1242
+ + `(${degraded.carried.map((row) => row.store).join(', ') || 'none'}), ${degraded.missing.length} missing `
1243
+ + `(${degraded.missing.map((row) => row.store).join(', ') || 'none'}); ${publication.reason}\n`);
1244
+ return DEGRADED_UNPUBLISHED_EXIT;
1245
+ }
967
1246
  return 0;
968
1247
  }
969
1248