clearotron 0.5.0-beta.2 → 0.5.0-beta.4

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 (73) hide show
  1. package/.env.example +7 -0
  2. package/build-info.json +2 -2
  3. package/docs/architecture/03-run-lifecycle.md +6 -0
  4. package/docs/architecture/04-configuration-reference.md +1 -0
  5. package/docs/architecture/05-config-governance.md +1 -1
  6. package/driver/CHANGELOG.md +21 -0
  7. package/driver/band-shape.mjs +19 -11
  8. package/driver/contract-vocabulary.mjs +3 -3
  9. package/driver/coverage-form-io.mjs +3 -1
  10. package/driver/coverage-form.mjs +21 -2
  11. package/driver/coverage-ledger.mjs +7 -1
  12. package/driver/declination-call.mjs +33 -16
  13. package/driver/degraded-parts.mjs +6 -0
  14. package/driver/engine/mcp/band-server.mjs +12 -12
  15. package/driver/engine/mcp/clarivate-server.mjs +2 -0
  16. package/driver/engine/mcp/codex-config.mjs +12 -7
  17. package/driver/engine/mcp/corsearch-server.mjs +2 -0
  18. package/driver/engine/mcp/declination-server.mjs +4 -5
  19. package/driver/engine/mcp/dispositions-server.mjs +7 -0
  20. package/driver/engine/mcp/euipo-server.mjs +2 -0
  21. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  22. package/driver/engine/mcp/gather-config.mjs +15 -0
  23. package/driver/engine/mcp/held-answers.mjs +11 -3
  24. package/driver/engine/mcp/perplexity-server.mjs +29 -1
  25. package/driver/engine/mcp/recording-server.mjs +16 -1
  26. package/driver/engine/mcp/signa-server.mjs +2 -0
  27. package/driver/engine/mcp/supplemental.mjs +11 -3
  28. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  29. package/driver/engine/openai-agent.mjs +48 -9
  30. package/driver/envelope-settle.mjs +13 -3
  31. package/driver/findings-model.mjs +17 -0
  32. package/driver/gateway.mjs +31 -10
  33. package/driver/package.json +1 -1
  34. package/driver/parts-not-completed.mjs +52 -11
  35. package/driver/pipeline-knockout.mjs +5 -4
  36. package/driver/pipeline.mjs +474 -81
  37. package/driver/placement-decisions.mjs +24 -9
  38. package/driver/placement-form-io.mjs +21 -1
  39. package/driver/placement-form.mjs +25 -15
  40. package/driver/placement-union.mjs +53 -8
  41. package/driver/predelivery-lint.mjs +8 -1
  42. package/driver/publish/index.mjs +7 -3
  43. package/driver/register-digest-record.mjs +6 -3
  44. package/driver/research-answers.mjs +54 -7
  45. package/driver/runner.mjs +4 -4
  46. package/driver/saved-findings-block.mjs +36 -0
  47. package/driver/skills/clearance-register/digest.md +1 -1
  48. package/driver/skills/clearance-search/phase2-execution.md +7 -5
  49. package/driver/skills/clearance-search/synthesis-rules.md +3 -3
  50. package/driver/skills/placement-inquiry/SKILL.md +3 -3
  51. package/driver/stage-context.mjs +18 -0
  52. package/driver/stages.mjs +41 -13
  53. package/driver/suite-census.json +140 -14
  54. package/driver/synthesis-record.mjs +167 -1
  55. package/driver/verify.mjs +6 -6
  56. package/mcp-server/CHANGELOG.md +8 -0
  57. package/mcp-server/package.json +1 -1
  58. package/package.json +1 -1
  59. package/portal-ui/package.json +1 -1
  60. package/providers/_shared/README.md +1 -0
  61. package/providers/_shared/enumerate.mjs +4 -3
  62. package/providers/_shared/execute-plan.mjs +33 -7
  63. package/providers/_shared/send-until.mjs +25 -0
  64. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  65. package/providers/oauth-mcp-bridge/package.json +1 -1
  66. package/scripts/band-shape-probe.mjs +1 -1
  67. package/scripts/e2e-first-time.mjs +2 -1
  68. package/scripts/release-notes-lint.mjs +1 -1
  69. package/shared/inside-run.mjs +54 -0
  70. package/shared/names-in-force.mjs +1 -0
  71. package/shared/slices-taken-out.mjs +65 -0
  72. package/skills/clearotron-account/SKILL.md +1 -1
  73. package/skills/clearotron-ops/SKILL.md +1 -1
@@ -30,13 +30,15 @@ import { engineProvenance } from "./engine-build.mjs"; // —
30
30
  import { preflightEngineTurn } from "./engine/probe.mjs"; // — …and the engine can complete a turn, not merely exist
31
31
  import { rankClusterLegs, verdictDrivingFindings } from "./senior-rights.mjs";
32
32
  import { buildRunContext, deriveSlug, kebab } from "./phase0.mjs";
33
- import { paths, STAGES, axisTier, decideAxes, assertTierSanity, assertEffectiveTier, lines, AGENT_WHATSAPP, whatsappRouting,
33
+ import { paths, STAGES, axisTier, decideAxes, assertTierSanity, assertEffectiveTier, lines, whatsappRouting,
34
34
  chainEntries, stageOrdinal, stageInputs, stageOutputs, dependencyOrder, REGISTER_AXES, REGISTER_ENUMERATE_TOOL,
35
35
  buildEscalationFollowup, buildEnvelopeCloseFollowup, buildFrameReopenFollowup,
36
36
  buildFrameReopenRetryMessage, thinkingFor, composeFollowup, stampDispatchBlocks, recordEmptyReturn, nothingFound, nothingToRead, PROVIDER_META, proseRungDirective, inquiryRungDirective } from "./stages.mjs"; import { bandSizeForStage, derivedLimitSec, limitExceedsCeiling, ceilingRefusal, stagesRefusingBand } from "./band-size.mjs";
37
- import { handResearchAnswers } from "./research-answers.mjs"; // the research a stage's earlier attempts got, handed to a later one
37
+ import { handResearchAnswers, RESEARCH_REPLAY_FILE, savedResearchCount } from "./research-answers.mjs"; // the research a stage's earlier attempts got, handed to a later one; a replay of it
38
38
  import { IDENTITY_FILE as REPORT_IDENTITY_FILE, renderReportOverview } from "./report-overview-record.mjs";
39
- import { writePartNotCompleted, readPartNotCompleted, clearPartNotCompleted, clearPartsNotCompleted, NOT_COMPLETED_DIR } from "./parts-not-completed.mjs";
39
+ import { writePartNotCompleted, readPartNotCompleted, clearPartNotCompleted, clearPartsNotCompleted, NOT_COMPLETED_DIR, slicesNotYetRecorded, axesNotYetDisclosed, unitsNotYetRecorded } from "./parts-not-completed.mjs";
40
+ import { renderUnitNote, readBandAccount } from "./register-unit-record.mjs"; // a unit that could not finish, its note rendered from its band
41
+ import { bandWithSlicesOut, slicesTakenOutByAxis } from "../shared/slices-taken-out.mjs"; // a band read with the slices a check could not prove ran taken out
40
42
  import { dispatchRows, clearedSignatures } from "./seat-attempts.mjs";
41
43
  import { CONTEXT_DERIVATIONS, DISPATCH_EXTRAS, INLINE_CONTEXT, sandboxManifest, sandboxGaps, derivationsFor } from "./stage-context.mjs"; // — what a stage is actually handed
42
44
  import { parseVerdict, countCitedDefects, parseCorrectionKinds, parseCorrections, validators, findReviewerCoherenceFlags, lateReviewAgainst, VERDICT_RANK } from "./verify.mjs";
@@ -151,7 +153,7 @@ import { escalatedAxes } from "./skeptic-record.mjs"; // THE escalation parse
151
153
  // — every placed candidate ends somewhere a reader can see; the ones that do not are counted by name
152
154
  import { PLACEMENT_CARRY_SCHEMA_VERSION, reconcilePlacementCarry, placementCarryEvent, mintPlacementCarryDoubts, entryUris, parseCarrySurfaces, classifyPlacement } from "./placement-carry.mjs";
153
155
  import { FLOOR_DUTY_SCHEMA_VERSION, reconcileFloorDuty, floorDutyEvent, armFloorDuty, floorDutyArmed, floorDutyBlock, floorDutyClosed, floorDutyBlocksSkip } from "./floor-duty.mjs"; // — the era stamp that turns disclosure into a delivery floor
154
- import { synthesisDutyForRun, snapshotSynthesisBase, restoreSynthesisBase, rebaseSynthesis, lastAcceptedCall, acceptSynthesis, driverReadsFor } from "./synthesis-record.mjs"; // — the duty checked against the DELIVERED document; the merge base a corrective pass holds and puts back
156
+ import { synthesisDutyForRun, snapshotSynthesisBase, restoreSynthesisBase, rebaseSynthesis, lastAcceptedCall, acceptSynthesis, driverReadsFor, handSavedFindings } from "./synthesis-record.mjs"; // — the duty checked against the DELIVERED document; the merge base a corrective pass holds and puts back
155
157
  import { RECORD_CARRY_SCHEMA_VERSION, traceRecordCarry, parseStageOutcomes, recordCarryEvent, mintRecordCarryDoubts, recordCarryDoubtsAgain, bandRecordUri, placementIndex, findingUris , silentlyLostFindings, statedDivergenceFindings } from "./record-carry.mjs"; import { pickingExits, notesExits, notesPageRows, exitsForLog } from "./hand-off-exits.mjs";
156
158
  import { reconcileSurfaceDuty, surfaceDutyNote } from "./surface-duty.mjs"; // item 3 — silence at the findings surface, read off the rows above
157
159
  import { DISCARD_LEDGER_NAME, seamRows, appendDiscardRows, foldDiscardLedger } from "./record-discard.mjs";
@@ -458,6 +460,78 @@ export function loadCoverageLedger(runDir) { // @internal
458
460
  return { rows: relabel(rows), source: "prose", dropped };
459
461
  }
460
462
 
463
+ /**
464
+ * Which still-tainted axes are disclosed and which are parked. Every axis once synthesis is done (the audit spine is
465
+ * locked); an axis the searches record lists, whose recovery is spent; and the rest parked, to be run again. PURE.
466
+ */
467
+ export function taintDisposition(stillTainted, { digestLocked = false, disclosedAxes = new Set() } = {}) { // @internal
468
+ const disclose = (stillTainted ?? []).filter((a) => digestLocked || disclosedAxes.has(a));
469
+ return { disclose, park: (stillTainted ?? []).filter((a) => !disclose.includes(a)) };
470
+ }
471
+
472
+ /**
473
+ * Put back what a taint park renamed, so the band and the digest are read as the last pass left them. A renamed copy
474
+ * whose original has since been written again stays where it is. Returns the paths put back. IMPURE.
475
+ */
476
+ export function undoTaintRenames(renames) { // @internal
477
+ const back = [];
478
+ for (const r of Array.isArray(renames) ? renames : []) {
479
+ try { if (r?.to && r?.from && existsSync(r.to) && !existsSync(r.from)) { renameSync(r.to, r.from); back.push(r.from); } }
480
+ catch { /* best-effort: what stays renamed is re-run on the resume, as before */ }
481
+ }
482
+ return back;
483
+ }
484
+
485
+ // THE BANDS ARE READ WITH THE RECORDED SLICES OUT, whatever wrote them since. A unit re-run after the fan-in, by the
486
+ // envelope, the escalation or the taint chain, writes its axis's band whole, and a slice the searches' record took
487
+ // out would come back with the records nobody could prove were searched. So the band's two readers, the merge and the
488
+ // plan join, take the recorded slices out of what they read, in memory and never by writing the file: a unit or the
489
+ // executor may be writing that band at the same moment. Every other reader of a band as answers does the same, from
490
+ // the one definition (shared/slices-taken-out.mjs).
491
+ const recordedSlicesByAxis = (P) => slicesTakenOutByAxis(P?.runDir);
492
+
493
+ // A band's text with the slices taken out, for a reader that parses text. Text that does not parse is handed on whole,
494
+ // for the reader's own parse to refuse. PURE.
495
+ function withSlicesOut(raw, slices) {
496
+ if (!Array.isArray(slices) || !slices.length) return raw;
497
+ try {
498
+ const parsed = JSON.parse(raw);
499
+ if (!Array.isArray(parsed)) return raw;
500
+ const { blocks, taken } = bandWithSlicesOut(parsed, slices);
501
+ return taken.length ? JSON.stringify(blocks) : raw;
502
+ } catch { return raw; }
503
+ }
504
+
505
+ /**
506
+ * THE SLICES A RUN COULD NOT PROVE IT SEARCHED, TAKEN OUT OF ITS BAND. Each recorded slice's block becomes the
507
+ * executor's own deferred block (providers/_shared/execute-plan.mjs), carrying the slice's reason, or is added as one
508
+ * where the band holds none for it. The plan join files it as deferred, the coverage form gives it an open row the
509
+ * write-up may not call clean, and the axis's other blocks stand. The band as it was is kept beside it the first time,
510
+ * as evidence. An axis whose band is absent or unreadable is left to the band's own checks. Returns the slices taken
511
+ * out on this call. IMPURE (rewrites the bands).
512
+ */
513
+ export function takeSlicesOutOfTheBand(P, slices) { // @internal
514
+ const taken = [];
515
+ const byAxis = new Map();
516
+ for (const s of Array.isArray(slices) ? slices : []) {
517
+ if (!s?.axis || (!s?.qid && !s?.query)) continue;
518
+ if (!byAxis.has(s.axis)) byAxis.set(s.axis, []);
519
+ byAxis.get(s.axis).push(s);
520
+ }
521
+ for (const [axis, list] of byAxis) {
522
+ const at = P.registerBand(axis);
523
+ let blocks;
524
+ try { blocks = JSON.parse(readFileSync(at, "utf8")); } catch { continue; }
525
+ if (!Array.isArray(blocks)) continue;
526
+ const out = bandWithSlicesOut(blocks, list);
527
+ if (!out.taken.length) continue;
528
+ taken.push(...out.taken);
529
+ try { if (!existsSync(`${at}.before-taken-out`)) copyFileSync(at, `${at}.before-taken-out`); } catch { /* the evidence is best-effort */ }
530
+ atomicWrite(at, JSON.stringify(out.blocks, null, 2) + "\n");
531
+ }
532
+ return taken;
533
+ }
534
+
461
535
  // ── judgment-relocation (2026-06-23): the COMPLETE NAMED BAND flow (lift the firewall) ───────────────
462
536
  // The register-unit funnel writes register-units/<axis>-band.json (a JSON ARRAY of named-band blocks). The
463
537
  // driver MERGES the per-axis bands into one complete band (register-named-band.json) and feeds it to Layer B
@@ -466,6 +540,7 @@ export function loadCoverageLedger(runDir) { // @internal
466
540
  // de-dups enumerated records by record_id and preserves every crowd descriptor. Returns the merged band
467
541
  // object {enumerated, crowds} plus { axes:[…], invalid:[{axis,reason}] } so the caller can flag a bad axis.
468
542
  export function mergeRegisterBands(P, axes = []) { // @internal
543
+ const recorded = recordedSlicesByAxis(P);
469
544
  const bands = [];
470
545
  const merged = [];
471
546
  const invalid = [];
@@ -473,7 +548,7 @@ export function mergeRegisterBands(P, axes = []) { // @internal
473
548
  const bandPath = P.registerBand(a);
474
549
  if (!existsSync(bandPath)) continue; // an axis that found nothing may legitimately write no band
475
550
  try {
476
- const band = parseNamedBand(readFileSync(bandPath, "utf8")); // throws named_band_* on a malformed artifact
551
+ const band = parseNamedBand(withSlicesOut(readFileSync(bandPath, "utf8"), recorded.get(a))); // throws named_band_* on a malformed artifact
477
552
  bands.push(band);
478
553
  merged.push(a);
479
554
  } catch (e) {
@@ -489,7 +564,7 @@ export function mergeRegisterBands(P, axes = []) { // @internal
489
564
  writeFileSync(`${bandPath}.tmp`, JSON.stringify(blocks, null, 2) + "\n");
490
565
  renameSync(`${bandPath}.tmp`, bandPath);
491
566
  runLog(P.runDir, { event: "named-band-state-quarantined", axis: a, states: quarantined.map((q) => q.state), site: "merge" });
492
- const band = parseNamedBand(readFileSync(bandPath, "utf8"));
567
+ const band = parseNamedBand(withSlicesOut(readFileSync(bandPath, "utf8"), recorded.get(a)));
493
568
  bands.push(band);
494
569
  merged.push(a);
495
570
  continue;
@@ -521,12 +596,13 @@ export function mergeRegisterBands(P, axes = []) { // @internal
521
596
  // chain, the clean-gates, and dispatchPlanQids cover them with zero extra code. Run-local only: the
522
597
  // slug store is never written (clearances run once; senior lawyer 2026-07-10).
523
598
  export function readRegisterBands(P, axes) { // @internal
599
+ const recorded = recordedSlicesByAxis(P);
524
600
  const byAxis = {};
525
601
  for (const a of axes ?? []) {
526
602
  if (!existsSync(P.registerBand(a))) continue;
527
603
  try {
528
604
  const parsed = JSON.parse(readFileSync(P.registerBand(a), "utf8"));
529
- byAxis[a] = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.blocks) ? parsed.blocks : []);
605
+ byAxis[a] = bandWithSlicesOut(Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.blocks) ? parsed.blocks : []), recorded.get(a)).blocks;
530
606
  } catch { byAxis[a] = []; }
531
607
  }
532
608
  return byAxis;
@@ -1603,6 +1679,8 @@ const AXIS_VOCABULARY = {
1603
1679
  // a preserved run's canonical artefact came from); the rest are the re-dispatch triggers runDigest
1604
1680
  // labels, and they carry the placement rulings tail a fresh pass does not.
1605
1681
  export const DISPATCH_TRIGGERS = ["fresh", "escalation", "envelope", "late-bind", "stale-repair", "settlement-flush", "corrective"]; // @internal — — the corrective pass is the seam the losses happen in; see driver/corrective-arm.mjs for warm vs cold
1682
+ // How an --experiment arm's research is answered: sent out as the run sent it, or replayed from the run's own answers.
1683
+ export const RESEARCH_MODES = ["live", "replay"]; // @internal
1606
1684
 
1607
1685
  /**
1608
1686
  * The sha of a sandbox file AS IT WOULD READ on the canonical run — the sandbox's own directory
@@ -3465,7 +3543,7 @@ function findingsSurfaceRows(P) {
3465
3543
  // cut, or none. Only the dispatch's copy carries it; the spec the declination tool reads is the positions alone.
3466
3544
  // A list the label could not be added to keeps its lines as they were, and the run says so: a write-up handed no goods
3467
3545
  // source must not read as one whose records have none.
3468
- export function withGoodsFrom(ctx, P, rows) { // @internal — driven by its test with a run's own files
3546
+ export function withGoodsFrom(ctx, P, rows, read = null) { // @internal — driven by its test with a run's own files
3469
3547
  const unlabelled = (reason) => {
3470
3548
  try { runLog(P.runDir, { event: "write-up-goods-from-failed", rows: rows.length, reason: String(reason).slice(0, 120) }); } catch { /* the list still goes */ }
3471
3549
  return rows;
@@ -3474,7 +3552,7 @@ export function withGoodsFrom(ctx, P, rows) { // @internal — driven by its t
3474
3552
  const runPrefix = ctx.run?.slug && ctx.run?.codename ? `clearance-${ctx.run.slug}-${ctx.run.codename}-` : null;
3475
3553
  if (!runPrefix) return unlabelled("the run has no session prefix to read its evidence by");
3476
3554
  const uris = rows.filter((r) => r?.kind !== "page" && r?.uri).map((r) => r.uri);
3477
- const evidence = goodsEvidenceOfDrops(P, runPrefix, uris);
3555
+ const evidence = read ?? goodsEvidenceOfDrops(P, runPrefix, uris);
3478
3556
  return rows.map((r) => {
3479
3557
  const e = r?.kind !== "page" && r?.uri ? evidence.get(r.uri) : null;
3480
3558
  return e ? { ...r, goods_from: goodsFromLabel({ state: e.goods, source: e.source }) } : r;
@@ -3482,6 +3560,68 @@ export function withGoodsFrom(ctx, P, rows) { // @internal — driven by its t
3482
3560
  } catch (e) { return unlabelled(e?.message ?? e); }
3483
3561
  }
3484
3562
 
3563
+ // THE TOOL IS TOLD WHAT EACH RECORD'S GOODS REST ON, from the same derivation (ruled 2026-10-27). A goods decision on a
3564
+ // mark identical to the applied-for mark, live in a filed class, stands only where the run holds that record's goods
3565
+ // complete, so the spec the declination tool judges carries each record's state, a position's members' included. A
3566
+ // list the states could not be added to keeps its rows as they were: the refusal then reads no state and refuses, and
3567
+ // the run says so.
3568
+ export function withGoodsStates(ctx, P, rows, read = null) { // @internal — driven by its test with a run's own files
3569
+ const unstated = (reason) => {
3570
+ try { runLog(P.runDir, { event: "declination-goods-states-failed", rows: rows.length, reason: String(reason).slice(0, 120) }); } catch { /* the list still goes */ }
3571
+ return rows;
3572
+ };
3573
+ try {
3574
+ const runPrefix = ctx.run?.slug && ctx.run?.codename ? `clearance-${ctx.run.slug}-${ctx.run.codename}-` : null;
3575
+ if (!runPrefix) return unstated("the run has no session prefix to read its evidence by");
3576
+ const evidence = read ?? goodsEvidenceOfDrops(P, runPrefix, surfaceUris(rows));
3577
+ const state = (u) => evidence.get(u)?.goods ?? null;
3578
+ return rows.map((r) => (r?.kind === "page" || !r?.uri ? r : { ...r, goods: state(r.uri),
3579
+ ...(Array.isArray(r.members) ? { members: r.members.map((m) => ({ ...m, goods: state(m?.uri) })) } : {}) }));
3580
+ } catch (e) { return unstated(e?.message ?? e); }
3581
+ }
3582
+
3583
+ /**
3584
+ * THE FLOORS THE WRITER DECLINED, AS THE REVIEW IS TOLD THEM (ruled 2026-10-27): one line per floor on a position the
3585
+ * writer declined, with its reason and its ground, after placement's decisions. The writer may decline a mark identical
3586
+ * to the applied-for mark on goods the run holds complete, and a review told only placement's decisions read such a
3587
+ * floor as one the narrative neither rates nor reasons away, and sent it back. [] when nothing stands. IMPURE.
3588
+ */
3589
+ export function writerFloorDeclinationLines(P) { // @internal — driven by its test with a run's own files
3590
+ try {
3591
+ const floors = safeReadJson(P.bandShape)?.floors?.in_class_identical_or_near;
3592
+ if (!Array.isArray(floors) || !floors.length) return [];
3593
+ const declined = readDeclinations(P.runDir);
3594
+ if (!declined.byUri.size) return [];
3595
+ // A declination is the position's: each record of a row the writer was handed reads its row's.
3596
+ const rowOf = new Map();
3597
+ for (const row of findingsSurfaceRows(P))
3598
+ for (const u of (Array.isArray(row?.uris) && row.uris.length ? row.uris : [row?.uri])) rowOf.set(normalizeRecordUri(u), normalizeRecordUri(row?.uri));
3599
+ const out = [];
3600
+ for (const f of floors) {
3601
+ const u = normalizeRecordUri(f?.record_id);
3602
+ const d = u ? declined.byUri.get(rowOf.get(u) ?? u) : null;
3603
+ if (d) out.push(`- ${f.mark_text ?? "(unnamed)"}${f.owner_name ? ` — ${f.owner_name}` : ""} (${f.record_id}): declined by the writer as ${d.reason}: ${d.grounds}`);
3604
+ }
3605
+ return out;
3606
+ } catch { return []; }
3607
+ }
3608
+ /** Every record a list of the writer's rows names, a position's members included. PURE. */
3609
+ const surfaceUris = (rows) => [...new Set(rows.filter((r) => r?.kind !== "page" && r?.uri)
3610
+ .flatMap((r) => [r.uri, ...(Array.isArray(r.members) ? r.members.map((m) => m?.uri).filter(Boolean) : [])]))];
3611
+ /**
3612
+ * What the goods of the writer's records rest on, READ ONCE for the labels its message carries and the states its tool
3613
+ * judges: the read walks the run's held records, which run to hundreds of megabytes, and took half a minute a pass on a
3614
+ * test run. null where it cannot be read; each reader then reads for itself and says so as before. IMPURE.
3615
+ */
3616
+ function surfaceGoodsEvidence(ctx, P, rows) {
3617
+ try {
3618
+ const runPrefix = ctx.run?.slug && ctx.run?.codename ? `clearance-${ctx.run.slug}-${ctx.run.codename}-` : null;
3619
+ return runPrefix ? goodsEvidenceOfDrops(P, runPrefix, surfaceUris(rows)) : null;
3620
+ } catch { return null; }
3621
+ }
3622
+ /** Every floor decision the review reads: placement's, then the writer's. IMPURE (reads the run). */
3623
+ const reviewFloorDecisionLines = (P) => [...placementFloorDecisionLines(P), ...writerFloorDeclinationLines(P)];
3624
+
3485
3625
  export function prepareDeclinationSpec(ctx, P) { // @internal
3486
3626
  const rows = [...findingsSurfaceRows(P), ...notesPageRows(existsSync(P.commonLaw) ? readFileSync(P.commonLaw, "utf8") : null)]; // records, then the web notes' pages
3487
3627
  if (!rows.length) {
@@ -3497,9 +3637,10 @@ export function prepareDeclinationSpec(ctx, P) { // @internal
3497
3637
  try { scope = JSON.parse(readFileSync(P.instructedScope, "utf8")) ?? {}; } catch { scope = {}; }
3498
3638
  try {
3499
3639
  ensureDriverDir(P.runDir);
3640
+ const read = surfaceGoodsEvidence(ctx, P, rows);
3500
3641
  atomicWrite(driverDir(P.runDir, "declination-spec.json"),
3501
- JSON.stringify({ ts: new Date().toISOString(), rows, scope: { marks: scope.marks ?? [], classes: scope.classes ?? [] } }, null, 2) + "\n");
3502
- ctx.findingsSurface = withGoodsFrom(ctx, P, rows);
3642
+ JSON.stringify({ ts: new Date().toISOString(), rows: withGoodsStates(ctx, P, rows, read), scope: { marks: scope.marks ?? [], classes: scope.classes ?? [] } }, null, 2) + "\n");
3643
+ ctx.findingsSurface = withGoodsFrom(ctx, P, rows, read);
3503
3644
  } catch (e) {
3504
3645
  // The dispatch block is gated on ctx.findingsSurface, so a failed write leaves the stage UNORDERED
3505
3646
  // rather than ordered to call a tool whose spec it cannot read. Loud in the log, never a dead order.
@@ -4570,6 +4711,46 @@ export function recoverWinningAttempt(runDir, label) { // @internal
4570
4711
  return null;
4571
4712
  }
4572
4713
 
4714
+ // ── A REGISTER UNIT THAT COULD NOT FINISH IS DELIVERED AS ITS BAND STANDS ─────────────────────────────
4715
+ //
4716
+ // A unit that wrote no result once its attempts were spent ended the run, though the run held every other unit: on a
4717
+ // production run on 2026-10-21 one unit's plan search timed out at the register on every attempt, and the client got
4718
+ // nothing. Such a unit now fails as the searches not completed (the gather barrier below), naming itself and the planned
4719
+ // slices its band never reached. Once its recovery is spent, the run-level catch records both, and the resumed pass
4720
+ // takes the unit as it stands rather than sending it again: its band is what the kept answers left, or empty, and its
4721
+ // note is the driver's own account of that band, with no word from the seat. The band merge then takes the recorded
4722
+ // slices out as deferred blocks, so they are delivered as open rows.
4723
+
4724
+ /**
4725
+ * The planned slices of `axis` that its band holds no block for, each `{axis, qid, reason}` with the shipped reason for a
4726
+ * search that never reached the register. A slice with a block, a timed-out one included, is not among them: the band
4727
+ * already accounts for it. A guarded child is left to the plan join, which reads it off its parent. PURE but for the read.
4728
+ */
4729
+ export function slicesUnitNeverReached(P, plan, axis) { // @internal
4730
+ let band = [];
4731
+ try { const parsed = JSON.parse(readFileSync(P.registerBand(axis), "utf8")); band = Array.isArray(parsed) ? parsed : []; } catch { band = []; }
4732
+ const held = new Set(band.map((b) => b?.qid).filter(Boolean).map(String));
4733
+ return (plan?.entries ?? []).filter((e) => e?.axis === axis && e?.qid && !e.when && !held.has(String(e.qid)))
4734
+ .map((e) => ({ axis, qid: String(e.qid), reason: plainDeferralReason("slice-not-landed") }));
4735
+ }
4736
+
4737
+ /** The unit a run records as unfinished, taken as its band stands: its note rendered from it, nothing dispatched. */
4738
+ export function unfinishedUnitAsItStands(ctx) { // @internal
4739
+ const P = ctx.paths, a = ctx.axis, label = `register-unit:${a}`;
4740
+ const t0 = Date.now();
4741
+ mkdirSync(join(P.runDir, "register-units"), { recursive: true });
4742
+ const bandPath = P.registerBand(a);
4743
+ if (!existsSync(bandPath)) { writeFileSync(`${bandPath}.tmp`, "[]\n"); renameSync(`${bandPath}.tmp`, bandPath); }
4744
+ const out = P.registerUnit(a);
4745
+ writeFileSync(`${out}.tmp`, renderUnitNote({ axis: a, account: readBandAccount(P.runDir, a) }));
4746
+ renameSync(`${out}.tmp`, out);
4747
+ runLog(P.runDir, { event: "unit-not-completed-delivery", stage: label, output: outputMeta(out), ...stageWallFields(t0) });
4748
+ note(`[${label}] could not be completed once its recovery was spent — taken as its band stands and not sent again`);
4749
+ (ctx.skippedStages ??= []).push(label);
4750
+ recordTransition(ctx, "register-unit");
4751
+ return { ok: true, skipped: true, notCompleted: true, sessionKey: null, model: null };
4752
+ }
4753
+
4573
4754
  // ── saturation-probe runs CODE-SIDE (2026-07-14, fabricated-band root cause) ────────────────────────
4574
4755
  // The axis is fully dictated (count-only crowd descriptors; judgment proposes nothing on it), yet it was
4575
4756
  // staffed by the cheapest model whose ONLY job was one register_execute_plan call — and the provider
@@ -4825,13 +5006,18 @@ function clearedForSeat(runDir, label) {
4825
5006
  */
4826
5007
  export function messageCtxFor(name, ctx) { // @internal — driven by its test
4827
5008
  if (!ctx?.paths) return ctx;
4828
- if (name === "narrative-refutation") return { ...ctx, placementFloorDecisions: placementFloorDecisionLines(ctx.paths) };
5009
+ if (name === "narrative-refutation") return { ...ctx, placementFloorDecisions: reviewFloorDecisionLines(ctx.paths) };
4829
5010
  // The reading step and the writer are told placement's accepted set-asides the same way, read when the message is built.
4830
5011
  if (name === "register-digest") return { ...ctx, placementSetAsides: placementSetAsideLines(ctx.paths) };
4831
- // The writer is also handed the research its earlier attempts got (research-answers.mjs), written to the run folder when
4832
- // the message is built: none on a first attempt, so its message is unchanged.
5012
+ // The writer is also handed the research this run's earlier stages and attempts got (research-answers.mjs), written to
5013
+ // the run folder when the message is built: none on a run that has asked nothing yet, whose message is unchanged.
5014
+ // A sandbox arm reproducing a FIRST attempt hands on nothing: its copy holds the run's own answers under this stage's
5015
+ // key, which a first attempt in a run never has (`researchHandOff`, set by the experiment).
4833
5016
  if (name === "synthesis") return { ...ctx, placementSetAsides: placementSetAsideLines(ctx.paths),
4834
- researchAnswered: ctx.run ? handResearchAnswers(ctx.paths.runDir, stageSessionKey(ctx, name)) : 0 };
5017
+ researchAnswered: ctx.run && ctx.researchHandOff !== false ? handResearchAnswers(ctx.paths.runDir, stageSessionKey(ctx, name), { ofRun: true }) : 0,
5018
+ // The findings an earlier attempt saved, handed on as a file (synthesis-record.mjs): none on a first attempt, and none
5019
+ // in a sandbox arm, whose copy holds no saved findings.
5020
+ savedSynthesis: handSavedFindings(ctx.paths.runDir) };
4835
5021
  return ctx;
4836
5022
  }
4837
5023
 
@@ -5395,7 +5581,7 @@ export function terminalReasonFields(reason, { cap = 200, fullCap = 4000 } = {})
5395
5581
  // every terminal failure to preserve. And per the payload stays OUT of the sentence — `whatsappText`
5396
5582
  // and the CLIENT body are untouched; the staff body gets it in its own <pre>, beside the verbatim reason
5397
5583
  // that body already quotes.
5398
- export function buildFailurePacket({ runId, agent, job = {}, failedStage, shortReason, reasonVerbatim, sig, failClass, terminalKind, repairs = [], priorAttempts = 0, whatsappTo = null, reasonDetail = null, reasonQuantity = null, capWait = null }) {
5584
+ export function buildFailurePacket({ runId, agent, job = {}, failedStage, shortReason, reasonVerbatim, sig, failClass, terminalKind, repairs = [], priorAttempts = 0, reasonDetail = null, reasonQuantity = null, capWait = null }) {
5399
5585
  const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;");
5400
5586
  const detail = typeof reasonDetail === "string" && reasonDetail.trim() ? reasonDetail.trim() : null;
5401
5587
  const quantity = Number.isFinite(reasonQuantity) ? reasonQuantity : null;
@@ -5514,7 +5700,10 @@ export function buildFailurePacket({ runId, agent, job = {}, failedStage, shortR
5514
5700
  : refused
5515
5701
  ? `<p>${esc(orderedIs)} for <b>${esc(mark)}</b> was REFUSED at stage <b>${esc(failedStage)}</b> before any work was done, and NOTHING was delivered.</p><p>${esc(kindLine)}</p><p>The driver reported, verbatim:</p><pre style="white-space:pre-wrap">${esc(reasonVerbatim)}</pre><p>The run directory is preserved; this is not a defect to diagnose.</p>`
5516
5702
  : `<p>${esc(orderedIs)} for <b>${esc(mark)}</b> FAILED at stage <b>${esc(failedStage)}</b>${priorAttempts ? ` after <b>${priorAttempts} automatic recovery attempt${priorAttempts === 1 ? "" : "s"}</b>` : ""} and NOTHING was delivered.</p><p>${esc(kindLine)}</p><p>The driver reported, verbatim:</p><pre style="white-space:pre-wrap">${esc(reasonVerbatim)}</pre>${detailBlock}${attempted}<p>The run directory is preserved for diagnosis; re-trigger the run when the cause is addressed.</p>`,
5517
- whatsappTo,
5703
+ // THE NOTICE GOES TO WHOEVER ASKED, as the completion notice does (whatsappRouting, stages.mjs): the requester's own
5704
+ // number where one is held, the gap stated where none is, and the operator's copy beside it. It went to the agent's
5705
+ // number, which every user of a deployment shares, so the operator was told of a failure the requester never heard.
5706
+ ...whatsappRouting(job, agent),
5518
5707
  whatsappText: refused
5519
5708
  ? `⛔ Clearotron search for ${mark} REFUSED at ${failedStage} [${terminalKind}]: ${shortReason} — nothing was delivered and nothing broke; ${retryHint}.`
5520
5709
  : `❌ Clearotron search for ${mark} FAILED at ${failedStage}${priorAttempts ? ` after ${priorAttempts} automatic recovery attempt${priorAttempts === 1 ? "" : "s"}` : ""}${terminalKind ? ` [${terminalKind}]` : ""}: ${shortReason} — nothing was delivered; ${retryHint}.`,
@@ -6941,7 +7130,7 @@ async function runPostRepairReview(ctx, P, run, scope) {
6941
7130
  const changedInputs = changedInputsOf(scope.changed, { narrative: P.narrative, findings: P.findings });
6942
7131
  const message = repairFollowup("narrative-refutation:post-repair-review", {
6943
7132
  findings: P.findings, ...list, registerOnly: Boolean(ctx.registerOnly), job: ctx.job ?? {},
6944
- skepticFlags: P.skepticFlags, frameReopenReceipt: P.frameReopenReceipt, floorDecisions: placementFloorDecisionLines(P),
7133
+ skepticFlags: P.skepticFlags, frameReopenReceipt: P.frameReopenReceipt, floorDecisions: reviewFloorDecisionLines(P),
6945
7134
  });
6946
7135
  const { text: blocks } = composeDispatchExtra("narrative-refutation", ctx, { skip: ["refute-plain-register"] });
6947
7136
  const t0 = Date.now();
@@ -7055,6 +7244,16 @@ function trustedStateBeforeCorrection(Pv, runDir, before) {
7055
7244
  base: { accepted: k.accepted, narrative: k.narrative }, from: "state-before-the-review" };
7056
7245
  }
7057
7246
  } catch { /* no state kept, or it does not read: the saves below */ }
7247
+ return recordFromTheSaves(Pv, runDir, before);
7248
+ }
7249
+
7250
+ /**
7251
+ * THE LAST RECORD A SAVE PASSED, rebuilt through the driver's own acceptance: the save the review read (`before`), then
7252
+ * the save kept from before the delivery-check repair (sources 3 and 4 of `trustedStateBeforeCorrection`, with the
7253
+ * same duty rule). Null when neither stands. Never throws.
7254
+ */
7255
+ function recordFromTheSaves(Pv, runDir, before) {
7256
+ const sha = (t) => createHash("sha256").update(t).digest("hex");
7058
7257
  const reads = (() => { try { return driverReadsFor(runDir); } catch { return null; } })();
7059
7258
  const fromSave = (params, accepted, from) => {
7060
7259
  if (!params || accepted == null || !reads) return null;
@@ -7075,6 +7274,46 @@ function trustedStateBeforeCorrection(Pv, runDir, before) {
7075
7274
  ?? fromSave(readRepairBase(runDir), bytes(repairBasePath(runDir)), "save-before-the-repair");
7076
7275
  }
7077
7276
 
7277
+ /**
7278
+ * A FINDINGS RECORD BROKEN AFTER SYNTHESIS PASSED IT IS RESTORED AT DELIVERY, NOT ENDED ON. The delivery checks
7279
+ * find the record broken at the top level, so a write after synthesis broke it. The record comes back from the
7280
+ * last save that passed, by the corrective rollback's own search (`recordFromTheSaves`): the save the review read, then
7281
+ * the save kept from before the delivery-check repair. It is folded, and given the seam's writes, as after any save, and
7282
+ * the check runs again on the record that now stands. The broken record is kept under `_driver/` as evidence. Returns
7283
+ * {from, duty}, or null where no save stands or the record restored fails the check too. IMPURE (writes the run).
7284
+ */
7285
+ async function restoreTheRecordThatPassed(ctx, P, runDir, check) {
7286
+ const pre = recordFromTheSaves(P, runDir, lastAcceptedCall(runDir));
7287
+ if (!pre?.raw) return null;
7288
+ try { copyFileSync(P.findings, driverDir(runDir, "findings-broken-at-delivery.json")); } catch { /* the evidence is best-effort */ }
7289
+ atomicWrite(P.findings, pre.raw);
7290
+ restoreSynthesisBase(runDir, pre.base);
7291
+ try { foldAfterSave(runDir, P.findings); } catch (e) { note(`[core-artifact] the restored record could not be folded (${String(e?.message ?? e).slice(0, 100)})`); }
7292
+ try { await ctx.afterSynthesisSave?.(); } catch (e) { note(`[core-artifact] the seam's writes could not be made again over the restored record (${String(e?.message ?? e).slice(0, 100)})`); }
7293
+ try { check(readFileSync(P.findings, "utf8")); } catch { return null; }
7294
+ return { from: pre.from, duty: pre.duty ?? "current" };
7295
+ }
7296
+
7297
+ // A correction row's outcomes that say the pass changed the record or the narrative (corrections-feedforward.mjs).
7298
+ const CORRECTION_CHANGED = new Set(["section-changed", "line-removed", "line-changed", "findings-removed", "findings-changed"]);
7299
+
7300
+ /**
7301
+ * THE CORRECTIONS A RECORD RESTORED FROM THE SAVE BEFORE THE REPAIR DOES NOT CARRY: what the correction after the review
7302
+ * of that repair changed, by the names its points targeted. The corrections' state is judged once, when the correction
7303
+ * runs, so nothing else would see that the restored record predates it. Written as that state, the client gate's
7304
+ * "findings-stale" reason closes the client export over it, as when a correction did not reach the findings. [] when
7305
+ * the record came from the accepted save, or no correction after the repair changed anything. Never throws.
7306
+ */
7307
+ export function correctionsTheRestoreLacks(runDir, from) { // @internal
7308
+ if (from !== "save-before-the-repair") return [];
7309
+ try {
7310
+ const rec = JSON.parse(readFileSync(postRepairCorrectionsPath(runDir), "utf8"));
7311
+ const named = (r) => (Array.isArray(r.targets) && r.targets.length ? r.targets : [(rec.classes ?? []).find((c) => c?.n === r.n)?.target]);
7312
+ return [...new Set((rec.rows ?? []).filter((r) => CORRECTION_CHANGED.has(r?.outcome)).flatMap(named)
7313
+ .map((t) => String(t ?? "").trim()).filter(Boolean))];
7314
+ } catch { return []; }
7315
+ }
7316
+
7078
7317
  /**
7079
7318
  * A FAILED CORRECTION AFTER A POST-REPAIR REVIEW NEVER ENDS THE RUN (the owner's ruling of 2026-10-19: a run is never
7080
7319
  * held). The trusted state comes back whole: the record, its narrative and the merge base, then the fold and the seam's
@@ -7083,13 +7322,14 @@ function trustedStateBeforeCorrection(Pv, runDir, before) {
7083
7322
  * not waited for either: it is rolled back as a failed one is, since the report it would correct is already reviewed,
7084
7323
  * and waiting for the reset would hold the run.
7085
7324
  *
7086
- * WITH NO TRUSTED STATE, THIS PASS DOES NOT DELIVER. That is the precise limit: the record, the state kept whole before
7087
- * the review, the save the review read and the save kept from before the repair are all unreadable or refused, a
7088
- * storage failure. Nothing the failed pass wrote is adopted: the narrative and the base
7325
+ * WITH NO TRUSTED STATE, THIS PASS DOES NOT DELIVER THE ASSESSMENT. That is the precise limit: the record, the state
7326
+ * kept whole before the review, the save the review read and the save kept from before the repair are all unreadable
7327
+ * or refused, a storage failure. Nothing the failed pass wrote is adopted: the narrative and the base
7089
7328
  * go back to what they were, and the failed pass's record is set aside under `_driver/` as evidence. A pass that went on from there delivered a
7090
7329
  * report with no findings, labelled with the verdict as it stood (measured on the mock pipeline, 2026-10-19): a clear
7091
- * result over a record nobody can read. So this case alone keeps the failure it always had. Returns the rollback
7092
- * record, or null for this case.
7330
+ * result over a record nobody can read. So the failure is the assessment not completed (parts-not-completed.mjs): once
7331
+ * its recovery is spent, the run delivers what it holds without the assessment. Returns the rollback record, or null
7332
+ * for this case.
7093
7333
  */
7094
7334
  async function recoverFromFailedCorrection(ctx, Pv, runDir, pre, pass, { baseBefore, findingsBefore }) {
7095
7335
  // A rate limit is a failure of this pass like any other here: the shared rollback would leave it to wait.
@@ -7156,7 +7396,7 @@ async function runPostRepairCorrection(ctx, P, run, { rec, items, points, ground
7156
7396
  ...(rollback.duty ? { duty: rollback.duty } : {}) });
7157
7397
  note(`[post-repair] the correction after the review of the repair failed (${rollback.reason}) — the record it started from is back, and the run delivers with the points recorded`);
7158
7398
  } else {
7159
- must(pass, "synthesis(post-repair)");
7399
+ await asPartNotCompleted("assessment", async () => must(pass, "synthesis(post-repair)"));
7160
7400
  let preDocForNames = null;
7161
7401
  try { preDocForNames = pre ? parseFindingsJsonLenient(pre.raw) : null; } catch { /* fall back to the file */ }
7162
7402
  const repaired = repairUnnamedRemovals(Pv, runDir, pre, correctionNamedOrdinals(Pv), correctionNamedSet(Pv, preDocForNames), correctionNamedLines(Pv, preDocForNames));
@@ -10145,7 +10385,7 @@ async function pipelineInner(job, opts = {}) {
10145
10385
  const p = writeOutboxPacket(`${runId}.bindack`, {
10146
10386
  kind: "late-bind-ack", runId, agent,
10147
10387
  forwarder: job.forwarder ?? null, forwarderEmail: job.forwarderEmail ?? null, msgId: job.msgId ?? null,
10148
- customer: bind.customer, action, whatsappTo: AGENT_WHATSAPP[agent] ?? null, text,
10388
+ customer: bind.customer, action, ...whatsappRouting(job, agent), text,
10149
10389
  });
10150
10390
  runLog(run.runDir, { event: "customer-late-bind-ack", sent: Boolean(p), via: "outbox" });
10151
10391
  } catch (e) { note(`late-bind ack failed (non-fatal): ${e?.message ?? e}`); }
@@ -10653,9 +10893,13 @@ async function pipelineInner(job, opts = {}) {
10653
10893
  // opt out — their scenarios pre-date the code-side member and script the AGENT path (and an armed
10654
10894
  // real executor lane would dial the provider from a test). Production default is ON.
10655
10895
  const satProbeCodeSide = process.env.CLEAROTRON_SATPROBE_CODESIDE !== "0" && Boolean(planExec && ctx.registerPlan);
10896
+ // The units this run records as unfinished are taken as their bands stand (unfinishedUnitAsItStands, above).
10897
+ const unfinishedUnits = new Set(readPartNotCompleted(run.runDir, "searches")?.unfinished_units ?? []);
10656
10898
  const [gatherResults, blindRes] = await Promise.all([
10657
10899
  runBatched(gather, config.gatherConcurrency, (g) =>
10658
- g.name === "register-unit" && g.axis === "saturation-probe" && satProbeCodeSide
10900
+ g.name === "register-unit" && unfinishedUnits.has(g.axis)
10901
+ ? unfinishedUnitAsItStands({ ...ctx, axis: g.axis })
10902
+ : g.name === "register-unit" && g.axis === "saturation-probe" && satProbeCodeSide
10659
10903
  ? runSaturationProbeCodeSide({ ...ctx, axis: g.axis }, planExec)
10660
10904
  : stage(g.name, { ...ctx, axis: g.axis })),
10661
10905
  stage("blind-frame", ctx).catch((e) => ({ ok: false, fail: String(e?.message ?? e) })),
@@ -10774,7 +11018,17 @@ async function pipelineInner(job, opts = {}) {
10774
11018
  runLog(run.runDir, { event: "connotation-reissue-failed", error: String(err?.message ?? err).slice(0, 160) });
10775
11019
  }
10776
11020
  }
10777
- gather.forEach((g, i) => must(gatherResults[i], g.name + (g.axis ? `:${g.axis}` : "")));
11021
+ // A REGISTER UNIT THAT WROTE NO RESULT is the searches not completed, after every other member has been held to its
11022
+ // own failure as before (unfinishedUnitAsItStands, above). A rate limit keeps its own path.
11023
+ const unfinished = gather.map((g, i) => i).filter((i) => gather[i].name === "register-unit" && gather[i].axis
11024
+ && !gatherResults[i]?.ok && !gatherResults[i]?.resetsAt && !existsSync(P.registerUnit(gather[i].axis)));
11025
+ gather.forEach((g, i) => { if (!unfinished.includes(i)) must(gatherResults[i], g.name + (g.axis ? `:${g.axis}` : "")); });
11026
+ if (unfinished.length) {
11027
+ const axesOut = unfinished.map((i) => gather[i].axis), first = gatherResults[unfinished[0]];
11028
+ throw Object.assign(new StageFailure(`register-unit:${axesOut[0]}`, first?.fail ?? "failed", undefined, { quantity: first?.quantity }),
11029
+ { notCompletedPart: "searches", unfinishedUnits: axesOut,
11030
+ excludedSlices: axesOut.flatMap((a) => slicesUnitNeverReached(P, ctx.registerPlan, a)) });
11031
+ }
10778
11032
  // winning session key per axis → a later escalation resumes the EXACT key the unit succeeded on.
10779
11033
  // (register units only — a common-law half's axis is its half id, tracked in clHalves, never here.)
10780
11034
  const unitKey = {};
@@ -11227,6 +11481,16 @@ async function pipelineInner(job, opts = {}) {
11227
11481
  for (const f of [...(runCommonLaw ? [P.commonLaw] : []), ...axes.map((a) => P.registerUnit(a))]) {
11228
11482
  if (!existsSync(f)) throw new StageFailure("fan-in", `missing ${f}`);
11229
11483
  }
11484
+ // THE SLICES A CHECK COULD NOT PROVE RAN, ONCE ITS RECOVERY WAS SPENT: each is taken out of its axis's band and
11485
+ // delivered as an open row, before the band's checks and the plan join read the band. The checks below name the
11486
+ // slices they fail on, and the run-level catch records them (parts-not-completed.mjs).
11487
+ const searchesOpen = readPartNotCompleted(run.runDir, "searches");
11488
+ if (Array.isArray(searchesOpen?.slices) && searchesOpen.slices.length) {
11489
+ const taken = takeSlicesOutOfTheBand(P, searchesOpen.slices);
11490
+ runLog(run.runDir, { event: "searches-not-completed-delivery", slices: searchesOpen.slices.length, taken: taken.length,
11491
+ reasons: [...new Set(searchesOpen.slices.map((s) => s.reason))] });
11492
+ note(`register: ${searchesOpen.slices.length} slice(s) a check could not prove ran are taken out of the band and delivered as open rows`);
11493
+ }
11230
11494
  // judgment-relocation LINCHPIN (2026-06-24): the load-bearing funnel artifact is the COMPLETE NAMED BAND
11231
11495
  // (register-units/<axis>-band.json) — the real material that crosses the lifted firewall. A unit that RAN
11232
11496
  // THIS process (unitKey[a] set) MUST have written it; a missing band would merge to empty and let a clean
@@ -11239,6 +11503,8 @@ async function pipelineInner(job, opts = {}) {
11239
11503
  // archived) keeps the lenient path. copper-spire's shipping path was exactly fail → manual resume →
11240
11504
  // resumed-past leniency → degrade-with-note → ship.
11241
11505
  const anyBands = axes.some((x) => existsSync(P.registerBand(x)));
11506
+ const unprovenBands = []; // every axis whose band the ledger cannot evidence, failed on together below
11507
+ const collapsedSlices = []; // every axis's qid-stamped slices that claimed hits and carried no record, likewise
11242
11508
  for (const a of axes) {
11243
11509
  if ((unitKey[a] || anyBands) && !existsSync(P.registerBand(a)))
11244
11510
  throw new StageFailure("fan-in", `missing named band for axis "${a}" — the funnel produced no complete band (a clean can never ship over an unsearched band)`, undefined, { detail: `${P.registerBand(a)}${unitKey[a] ? "" : " — resumed-past axis on a run that carries bands: a hole, not legacy"}` });
@@ -11255,8 +11521,9 @@ async function pipelineInner(job, opts = {}) {
11255
11521
  // collapsed slices already had their re-run via the validator ladder — they stay terminal.
11256
11522
  const collapsedQids = collapsed.map((x) => x.qid).filter(Boolean);
11257
11523
  if (collapsedQids.length && (await dispatchPlanQids(a, collapsedQids, "collapsed-reexecute")) === "ok") collapsed = readCollapsed();
11258
- if (collapsed.length)
11259
- throw new StageFailure("fan-in", `collapsed named band for axis "${a}" — ${collapsed.length} enumerated slice(s) claimed hits but carried ZERO records into the band (a clean can never ship over a collapsed slice)`, undefined, { failClass: "deterministic", repairs: fanInRepairs, detail: `${P.registerBand(a)} — ${collapsed.slice(0, 6).map((x) => `${(x.query || "slice").slice(0, 40)}~${x.total_hits}`).join("; ")}${collapsed.length > 6 ? ` (+${collapsed.length - 6} more)` : ""}` });
11524
+ // The collapsed slices are the searches not completed, failed on with every axis's below. One with no qid is named by
11525
+ // its query, and its block becomes a deferred block on that query.
11526
+ if (collapsed.length) collapsedSlices.push({ axis: a, collapsed });
11260
11527
  }
11261
11528
  // ── band-truth gate (2026-07-14, teal-foundry): the band must be EVIDENCED, not just well-formed ──
11262
11529
  // A qid-stamped band whose unit lane logged ZERO real provider calls was AUTHORED, not executed —
@@ -11276,10 +11543,12 @@ async function pipelineInner(job, opts = {}) {
11276
11543
  if (process.env.CLEAROTRON_BAND_TRUTH_GATE !== "0"
11277
11544
  && unitKey[a] && existsSync(P.registerBand(a)) && (ctx.registerPlan?.entries ?? []).some((e) => e?.axis === a)) {
11278
11545
  const laneKey = `clearance-${run.slug}-${run.codename}-register-unit-${a}`;
11279
- const bandHasQids = (() => {
11280
- try { const b = JSON.parse(readFileSync(P.registerBand(a), "utf8")); return Array.isArray(b) && b.some((x) => x && typeof x === "object" && x.qid); }
11281
- catch { return false; /* parse defects are the stage validator's problem, not this gate's */ }
11546
+ // A slice taken out of the band, or one the provider could not express, is a deferred block that made no call.
11547
+ const qidBlocks = (() => {
11548
+ try { const b = JSON.parse(readFileSync(P.registerBand(a), "utf8")); return Array.isArray(b) ? b.filter((x) => x && typeof x === "object" && x.qid && !(x.error === true && x.deferred === true)) : []; }
11549
+ catch { return []; /* parse defects are the stage validator's problem, not this gate's */ }
11282
11550
  })();
11551
+ const bandHasQids = qidBlocks.length > 0;
11283
11552
  if (bandHasQids && countLaneCalls(laneKey) === 0) {
11284
11553
  note(`register-unit ${a}: band carries qid blocks but its lane logged ZERO provider calls — authored, not executed; direct re-execution from the frozen plan`);
11285
11554
  runLog(run.runDir, { event: "band-truth-gate", axis: a, lane: laneKey, calls: 0 });
@@ -11289,11 +11558,36 @@ async function pipelineInner(job, opts = {}) {
11289
11558
  runLog(run.runDir, { event: "band-truth-rebuilt", axis: a, outcome, calls: callsNow ?? 0 });
11290
11559
  note(`register-unit ${a}: band re-executed from the frozen plan (dispatch ${outcome ?? "unavailable"}, ${callsNow ?? 0} ledger call(s)) — fan-in continues over executor data`);
11291
11560
  } else {
11292
- throw new StageFailure("fan-in", `fabricated named band for axis "${a}" — qid-stamped blocks, ZERO provider calls in the ledger, re-execution not evidenced (a clean can never ship over an unexecuted band)`, undefined, { failClass: "deterministic", repairs: fanInRepairs, detail: `${P.registerBand(a)} — lane "${laneKey}", re-execution ${outcome ?? "no executor lane"}` });
11561
+ unprovenBands.push({ axis: a, laneKey, reexecution: outcome, qids: qidBlocks.map((x) => x.qid) });
11293
11562
  }
11294
11563
  }
11295
11564
  }
11296
11565
  }
11566
+ // THE SLICES THE BAND'S CHECKS COULD NOT PROVE RAN, failed on together, so one park takes every axis's. They are the
11567
+ // searches not completed: once this is terminal they are taken out of the band and delivered as open rows, each with
11568
+ // its shipped reason. A band no ledger call evidences reads "nothing in the run's own record confirms it was
11569
+ // searched"; a slice that claimed hits and carried no record reads "it could not be completed this run". A slice that
11570
+ // is both keeps the first.
11571
+ const notCompleted = [
11572
+ ...unprovenBands.flatMap((u) => u.qids.map((qid) => ({ axis: u.axis, qid, reason: plainDeferralReason("not-verified-closed") }))),
11573
+ ...collapsedSlices.flatMap((c) => c.collapsed.map((x) => (x.qid ? { axis: c.axis, qid: x.qid, reason: plainDeferralReason("collapsed-slice") }
11574
+ : { axis: c.axis, query: String(x.query ?? ""), reason: plainDeferralReason("collapsed-slice") }))),
11575
+ ];
11576
+ if (unprovenBands.length) {
11577
+ const [first] = unprovenBands, more = unprovenBands.length - 1;
11578
+ throw Object.assign(new StageFailure("fan-in", `fabricated named band for ${more ? `${unprovenBands.length} axes` : `axis "${first.axis}"`} — qid-stamped blocks, ZERO provider calls in the ledger, re-execution not evidenced (a clean can never ship over an unexecuted band)`,
11579
+ undefined, { failClass: "deterministic", repairs: fanInRepairs,
11580
+ detail: `${P.registerBand(first.axis)} — lane "${first.laneKey}", re-execution ${first.reexecution ?? "no executor lane"}${more ? `; ${more} more axis(es): ${unprovenBands.slice(1).map((u) => u.axis).join(", ")}` : ""}` }),
11581
+ { notCompletedPart: "searches", excludedSlices: notCompleted });
11582
+ }
11583
+ if (collapsedSlices.length) {
11584
+ const [{ axis: a, collapsed }] = collapsedSlices;
11585
+ const n = collapsedSlices.reduce((k, c) => k + c.collapsed.length, 0);
11586
+ throw Object.assign(new StageFailure("fan-in", `collapsed named band for axis "${a}" — ${n} enumerated slice(s) claimed hits but carried ZERO records into the band (a clean can never ship over a collapsed slice)`,
11587
+ undefined, { failClass: "deterministic", repairs: fanInRepairs,
11588
+ detail: `${P.registerBand(a)} — ${collapsed.slice(0, 6).map((x) => `${(x.query || "slice").slice(0, 40)}~${x.total_hits}`).join("; ")}${collapsedSlices.length > 1 ? `; and on ${collapsedSlices.length - 1} more axis(es)` : ""}` }),
11589
+ { notCompletedPart: "searches", excludedSlices: notCompleted });
11590
+ }
11297
11591
 
11298
11592
  // ── timeout-taint chain (copper-lattice 2026-07-08): unfinished ⇒ deferred ⇒ recover ⇒ converge ──────
11299
11593
  // A register-unit pass SIGKILLed at the wall leaves partial band writes the merge preserves; the
@@ -11318,6 +11612,8 @@ async function pipelineInner(job, opts = {}) {
11318
11612
  const taintedNow = axes.filter((a) => taint0[a]?.tainted);
11319
11613
  if (taintedNow.length) {
11320
11614
  const recoveryAttempts = (() => { try { return Number(JSON.parse(readFileSync(join(run.runDir, "status.json"), "utf8")).recoveryAttempts) || 0; } catch { return 0; } })();
11615
+ // The axes whose recovery is spent: the searches record lists them, and each is read as it stands and disclosed.
11616
+ const disclosedAxes = new Set(Array.isArray(searchesOpen?.disclosed_axes) ? searchesOpen.disclosed_axes : []);
11321
11617
  const axesRec = {};
11322
11618
  const digestLocked = existsSync(P.narrative); // synthesis done — the audit spine is locked; disclose, never mutate under it
11323
11619
  for (const a of taintedNow) {
@@ -11336,8 +11632,9 @@ async function pipelineInner(job, opts = {}) {
11336
11632
  }
11337
11633
  }
11338
11634
  } catch (e) { note(`register-unit ${a}: taint quarantine not applicable (${String(e.message).slice(0, 100)})`); }
11339
- // T0.5 — deterministic re-execution of the dictated half (repair-ledger bounded)
11340
- const dispatched = await dispatchPlanQids(a, null, "taint-dispatch");
11635
+ // T0.5 — deterministic re-execution of the dictated half (repair-ledger bounded). Not for an axis the searches
11636
+ // record discloses: its recovery is spent, and its band is read as the last pass left it.
11637
+ const dispatched = disclosedAxes.has(a) ? null : await dispatchPlanQids(a, null, "taint-dispatch");
11341
11638
  if (dispatched) repairs.push(`taint-dispatch:${dispatched}`);
11342
11639
  // T1 — ONE fresh in-loop re-run, pre-synthesis only (a fresh full pass, full fallback chain)
11343
11640
  // — held until the real result is in hand; see the push below the taint re-read.
@@ -11345,7 +11642,7 @@ async function pipelineInner(job, opts = {}) {
11345
11642
  // — ONE ceiling, named once and read by BOTH ends. A literal repeated at the record site is a
11346
11643
  // second statement of the same rule, and two ends stating the same rule is how they drift apart.
11347
11644
  const taintRerunMax = 1;
11348
- if (!digestLocked && repairLedger.canAttempt("taint-rerun", a, { max: taintRerunMax, epoch: repairEpoch })) {
11645
+ if (!digestLocked && !disclosedAxes.has(a) && repairLedger.canAttempt("taint-rerun", a, { max: taintRerunMax, epoch: repairEpoch })) {
11349
11646
  note(`register-unit ${a}: taint-rerun — one fresh re-run (fresh session; a killed session is never warm-resumed)`);
11350
11647
  const r = await stage("register-unit", { ...ctx, axis: a },
11351
11648
  { force: true, trigger: "taint-rerun", sessionKey: `clearance-${run.slug}-${run.codename}-register-unit-${a}-taint-rerun-${recoveryAttempts}` });
@@ -11390,34 +11687,18 @@ async function pipelineInner(job, opts = {}) {
11390
11687
  const stillTainted = Object.entries(axesRec)
11391
11688
  .filter(([a, v]) => v.status === "active" && !NON_MATERIAL_AXES.includes(String(a).toLowerCase()))
11392
11689
  .map(([a]) => a);
11393
- if (stillTainted.length && !digestLocked) {
11394
- // PARK — the converging outer net. Rename (never delete — forensics) so stageOnce's
11395
- // exists-and-validates skip MISSES and the resume genuinely re-executes the tainted units;
11396
- // drop the plan-execution receipt so the re-join runs over the fresh bands; a digest already
11397
- // derived from tainted material is renamed too, else its skip would ship stale rows.
11398
- for (const a of stillTainted) {
11399
- for (const f of [P.registerUnit(a), P.registerBand(a)]) {
11400
- try { if (existsSync(f)) renameSync(f, `${f}.tainted-${recoveryAttempts}`); } catch { /* best-effort */ }
11401
- }
11402
- }
11403
- try { if (existsSync(P.planExecution)) rmSync(P.planExecution); } catch { /* best-effort */ }
11404
- for (const f of taintParkJudgmentArtifacts(P, run.runDir)) {
11405
- try { if (existsSync(f)) renameSync(f, `${f}.tainted-${recoveryAttempts}`); } catch { /* best-effort */ }
11406
- }
11407
- runLog(run.runDir, { event: "taint-park", axes: stillTainted, attempt: recoveryAttempts });
11408
- throw new StageFailure(`register-unit:${stillTainted[0]}`,
11409
- `register slice timeout-tainted and unrecovered — an unfinished pass cannot ship self-reported clean (axes: ${stillTainted.join(", ")})`,
11410
- undefined, { failClass: "transient", repairs: fanInRepairs });
11411
- }
11412
- if (stillTainted.length && digestLocked) {
11413
- // DISCLOSE — the sanctioned post-synthesis lever: the client export is withheld until the
11414
- // search is re-run to closure (the same class as a failed escalation/envelope), the internal
11415
- // report ships CONDITIONAL via the ledger relabel + registerGap clamp.
11690
+ // Disclosed: every axis once synthesis is done, and an axis the searches record lists once its recovery is spent.
11691
+ // Parked: the rest, while the run can still re-run them.
11692
+ const { disclose: toDisclose, park: toPark } = taintDisposition(stillTainted, { digestLocked, disclosedAxes });
11693
+ if (toDisclose.length) {
11694
+ // DISCLOSE — the sanctioned lever once synthesis is done, and once an axis's recovery is spent: the client
11695
+ // export is withheld until the search is re-run to closure (the same class as a failed escalation/envelope),
11696
+ // the internal report ships CONDITIONAL via the ledger relabel + registerGap clamp.
11416
11697
  try {
11417
11698
  const escPath = driverDir(run.runDir, "escalation-state.json");
11418
11699
  let esc = {}; try { esc = JSON.parse(readFileSync(escPath, "utf8")); } catch { /* fresh */ }
11419
11700
  const failed = Array.isArray(esc.failed) ? esc.failed : [];
11420
- for (const a of stillTainted) if (!failed.includes(`taint:${a}`)) failed.push(`taint:${a}`);
11701
+ for (const a of toDisclose) if (!failed.includes(`taint:${a}`)) failed.push(`taint:${a}`);
11421
11702
  atomicWrite(escPath, JSON.stringify({ ...esc, ts: new Date().toISOString(), failed }, null, 2) + "\n"); // B5 — the client-gate reads this: never truncated
11422
11703
  } catch (e) { note(`taint disclose: escalation-state append failed (${String(e.message).slice(0, 80)})`); }
11423
11704
  // Receipt status → "disclosed": the LEDGER keeps treating the axis as a gap (deferred → clamp +
@@ -11426,11 +11707,31 @@ async function pipelineInner(job, opts = {}) {
11426
11707
  try {
11427
11708
  const tp = driverDir(run.runDir, "register-taint.json");
11428
11709
  const rec = JSON.parse(readFileSync(tp, "utf8"));
11429
- for (const a of stillTainted) if (rec?.axes?.[a]) rec.axes[a] = { ...rec.axes[a], status: "disclosed" };
11710
+ for (const a of toDisclose) if (rec?.axes?.[a]) rec.axes[a] = { ...rec.axes[a], status: "disclosed" };
11430
11711
  writeFileSync(`${tp}.tmp`, JSON.stringify(rec, null, 2) + "\n"); renameSync(`${tp}.tmp`, tp);
11431
11712
  } catch (e) { note(`register-taint receipt disclose update failed (non-fatal): ${String(e.message).slice(0, 80)}`); }
11432
- runLog(run.runDir, { event: "taint-disclose", axes: stillTainted });
11433
- note(`timeout-taint on ${stillTainted.join(", ")} past the locked audit spine — disclosed: client export withheld, verdict clamps CONDITIONAL (internal report still ships)`);
11713
+ runLog(run.runDir, { event: "taint-disclose", axes: toDisclose });
11714
+ note(`timeout-taint on ${toDisclose.join(", ")} ${digestLocked ? "past the locked audit spine" : "with its recovery spent"} — disclosed: client export withheld, verdict clamps CONDITIONAL (internal report still ships)`);
11715
+ }
11716
+ if (toPark.length) {
11717
+ // PARK — the converging outer net. Rename (never delete — forensics) so stageOnce's
11718
+ // exists-and-validates skip MISSES and the resume genuinely re-executes the tainted units;
11719
+ // drop the plan-execution receipt so the re-join runs over the fresh bands; a digest already
11720
+ // derived from tainted material is renamed too, else its skip would ship stale rows. The renames ride the
11721
+ // failure: once its recovery is spent, the run-level catch puts them back and the axes are disclosed instead.
11722
+ const undoRenames = [];
11723
+ const parkRename = (f) => {
11724
+ try { if (existsSync(f)) { renameSync(f, `${f}.tainted-${recoveryAttempts}`); undoRenames.push({ from: f, to: `${f}.tainted-${recoveryAttempts}` }); } }
11725
+ catch { /* best-effort */ }
11726
+ };
11727
+ for (const a of toPark) for (const f of [P.registerUnit(a), P.registerBand(a)]) parkRename(f);
11728
+ try { if (existsSync(P.planExecution)) rmSync(P.planExecution); } catch { /* best-effort */ }
11729
+ for (const f of taintParkJudgmentArtifacts(P, run.runDir)) parkRename(f);
11730
+ runLog(run.runDir, { event: "taint-park", axes: toPark, attempt: recoveryAttempts });
11731
+ throw Object.assign(new StageFailure(`register-unit:${toPark[0]}`,
11732
+ `register slice timeout-tainted and unrecovered — an unfinished pass cannot ship self-reported clean (axes: ${toPark.join(", ")})`,
11733
+ undefined, { failClass: "transient", repairs: fanInRepairs }),
11734
+ { notCompletedPart: "searches", taintedAxes: toPark, undoRenames });
11434
11735
  }
11435
11736
  }
11436
11737
  }
@@ -11743,7 +12044,9 @@ async function pipelineInner(job, opts = {}) {
11743
12044
  // The rows are excluded here and announced by their own note: they are NOT slices the provider
11744
12045
  // cannot express — it accepted them and then failed — and one note claiming both would make the
11745
12046
  // run's own record of WHY a territory went unsearched wrong.
11746
- const capabilityDeferred = (joinRes.deferred ?? []).filter((d) => !isProviderHardErrorReason(d.reason));
12047
+ // And the slices a check could not prove ran, taken out of the band (searchesOpen above): announced on their own.
12048
+ const takenOut = new Set((Array.isArray(searchesOpen?.slices) ? searchesOpen.slices : []).map((s) => s.qid));
12049
+ const capabilityDeferred = (joinRes.deferred ?? []).filter((d) => !isProviderHardErrorReason(d.reason) && !takenOut.has(d.qid));
11747
12050
  if (capabilityDeferred.length) {
11748
12051
  runLog(run.runDir, { event: "plan-qids-deferred", count: capabilityDeferred.length,
11749
12052
  qids: capabilityDeferred.slice(0, 8).map((d) => d.qid) });
@@ -11792,12 +12095,17 @@ async function pipelineInner(job, opts = {}) {
11792
12095
  if (r.quote) return `${r.qid} ← ${r.quote.slice(0, 160)}`;
11793
12096
  return `${r.qid} ← no error block and no repair outcome — nothing ran this slice`;
11794
12097
  });
11795
- throw new StageFailure("fan-in", `register plan unexecuted after direct dispatch + followup — ${joinRes.missing.length} dictated qid(s) own no band block: ${detail.join("; ")}${joinRes.missing.length > 6 ? " …" : ""} (a clean can never ship over a slice the plan dictated and nothing ran)`, undefined,
12098
+ // Once their recovery is spent, these are the searches not completed: each is added to its axis's band as the
12099
+ // executor's deferred block and delivered as an open row, reading that it was planned and never reached the
12100
+ // register (parts-not-completed.mjs).
12101
+ throw Object.assign(new StageFailure("fan-in", `register plan unexecuted after direct dispatch + followup — ${joinRes.missing.length} dictated qid(s) own no band block: ${detail.join("; ")}${joinRes.missing.length > 6 ? " …" : ""} (a clean can never ship over a slice the plan dictated and nothing ran)`, undefined,
11796
12102
  // `quantity` is the missing count — what CONVERGES across attempts (5 → 3 → 1 is a run closing
11797
12103
  // on the hole; 5 → 5 → 5 is one that has stopped). repairs.mjs progressQuantity cannot read it
11798
12104
  // out of this prose: its census table is an allowlist and this template is not in it, so the
11799
12105
  // ladder has been reading `unknown` here and a converging run looked plateaued.
11800
- { failClass, repairs: fanInRepairs, quantity: joinRes.missing.length });
12106
+ { failClass, repairs: fanInRepairs, quantity: joinRes.missing.length }),
12107
+ { notCompletedPart: "searches", excludedSlices: joinRes.missing.map((qid) => ({ axis: byQid.get(qid)?.axis, qid, reason: plainDeferralReason("slice-not-landed") }))
12108
+ .filter((x) => x.axis) });
11801
12109
  }
11802
12110
  runLog(run.runDir, { event: "plan-execution", executed: joinRes.executed.length, skipped: joinRes.skipped.length, unplanned: joinRes.unplanned.length,
11803
12111
  awaiting: joinRes.awaiting?.length ?? 0, asked: joinRes.asked?.length ?? 0,
@@ -14073,7 +14381,9 @@ async function pipelineInner(job, opts = {}) {
14073
14381
  + `${correctiveRollback.restored ? "findings.json restored to the version the reviewer read" : "findings.json was never changed"}`
14074
14382
  + `; the run delivers with the reviewer's points printed rather than failing`);
14075
14383
  } else {
14076
- must(correctivePass, `synthesis(${verdict.toLowerCase()})`);
14384
+ // The rollback declined: a rate limit, which waits, or no record from before the pass to trust, which is the
14385
+ // assessment not completed, as for the correction after the review of a repair.
14386
+ await asPartNotCompleted("assessment", async () => must(correctivePass, `synthesis(${verdict.toLowerCase()})`));
14077
14387
  // ── — THE THIRD BRANCH, AND IT IS ON THE SUCCESS SIDE ON PURPOSE ─────────
14078
14388
  //
14079
14389
  // A repair is not a rollback and must never be reached by falling through to one. The rollback
@@ -15216,6 +15526,7 @@ async function pipelineInner(job, opts = {}) {
15216
15526
  // P2 clause 3 — assigned in the lint block below, consumed by the freshness precondition before the
15217
15527
  // client gate. Declared here so both live in the same scope regardless of the lint block's nesting.
15218
15528
  let staleStages = [];
15529
+ let staleOnTheDeliveryPath = () => staleStages; // the same question, asked again: set where the lint block asks it
15219
15530
  // ── §9 — THE CARD SET IS `fullProseOrdinals ∧ form === "full"` ───────────────────────────
15220
15531
  //
15221
15532
  // On one country every form is `full`, so this reduces to `fullProseOrdinals` — the same list, in the
@@ -15397,6 +15708,46 @@ async function pipelineInner(job, opts = {}) {
15397
15708
  // composeEmailHtml now takes its headline tier from the verdict sidecar and its conditions from
15398
15709
  // the report's own `# Actions`.
15399
15710
 
15711
+ // A FINDINGS RECORD BROKEN AFTER SYNTHESIS PASSED IT IS RESTORED, NOT ENDED ON. It is checked as the delivery
15712
+ // gate checks it, before the audit and the delivery checks are built from it, and again at that gate. A broken record
15713
+ // comes back from the last save that passed (restoreTheRecordThatPassed). A save rebuilt with the duty set aside is
15714
+ // judged again here, as after a rollback, and the corrections a save from before the repair lacks close the client
15715
+ // export (correctionsTheRestoreLacks). With no save to restore, the failure is the assessment not completed: its
15716
+ // recovery re-runs synthesis, which a broken record does not let skip, and once that is spent the run delivers
15717
+ // without the assessment. Returns the restore, or null where no save passes.
15718
+ const restoreTheBrokenRecord = async (failures, where) => {
15719
+ const restored = await restoreTheRecordThatPassed(ctx, P, run.runDir, (raw) => { parseFindingsJsonLenient(raw); });
15720
+ if (!restored) {
15721
+ note(`core-artifact (${where}): ${failures.join("; ")} — no save passes either, so the assessment is not completed`);
15722
+ return null;
15723
+ }
15724
+ const lacks = correctionsTheRestoreLacks(run.runDir, restored.from);
15725
+ if (lacks.length) {
15726
+ // Joined to what the state already holds: an earlier correction's names stay in the reason.
15727
+ const at = driverDir(run.runDir, "corrections-state.json");
15728
+ const held = (() => { try { const s = JSON.parse(readFileSync(at, "utf8"))?.stale; return Array.isArray(s) ? s : []; } catch { return []; } })();
15729
+ try { atomicWrite(at, JSON.stringify({ ts: new Date().toISOString(), stale: [...new Set([...held, ...lacks])] }, null, 2)); }
15730
+ catch { /* the event below records it */ }
15731
+ }
15732
+ runLog(run.runDir, { event: "core-artifact-restored", where, from: restored.from, duty: restored.duty, ...(lacks.length ? { correctionsLacked: lacks.length } : {}) });
15733
+ note(`core-artifact (${where}): ${failures.join("; ")} — the record is restored from the ${restored.from}, and the run delivers from it`);
15734
+ if (restored.duty === "relaxed") {
15735
+ const was = verdict, stood = JSON.stringify(clampReasons);
15736
+ clampOnTheWritersDuty({ again: true });
15737
+ if ((JSON.stringify(clampReasons) !== stood || verdict !== was) && saveTheLabel("after the record was restored")) applyVerdictFrontMatter();
15738
+ }
15739
+ return restored;
15740
+ };
15741
+ if (existsSync(P.findings)) {
15742
+ let broken = null;
15743
+ try { parseFindingsJsonLenient(readFileSync(P.findings, "utf8")); } catch (e) { broken = `findings.json — ${String(e.message).replace(/\s+/g, " ").slice(0, 140)}`; }
15744
+ if (broken) {
15745
+ runLog(run.runDir, { event: "core-artifact-invalid", failures: [broken], where: "delivery-checks" });
15746
+ if (!(await restoreTheBrokenRecord([broken], "delivery-checks")))
15747
+ throw Object.assign(new StageFailure("core-artifact-gate", `a core machine artifact is invalid and cannot ship: ${broken}`), { notCompletedPart: "assessment" });
15748
+ }
15749
+ }
15750
+
15400
15751
  // A2 — full audit: DETERMINISTIC CODE (parse the spine tables → audit.md, count-guarded). Replaces the LLM
15401
15752
  // audit-emit (output varied 47↔69 on identical input). NON-FATAL: if it fails, the report still ships (publish
15402
15753
  // renders the report with no xlsx when audit.md is absent), and the audit self-recovers on a re-run.
@@ -16298,8 +16649,9 @@ async function pipelineInner(job, opts = {}) {
16298
16649
  // again, and nothing re-runs either.
16299
16650
  const deliveryPathNow = () => deliveryPathStages().filter((l) => !(overviewOpen && l === "report-overview")
16300
16651
  && !(reviewOpen && l === "narrative-refutation"));
16301
- staleStages = [...staleOnPath(run.runDir, deliveryPathNow(), deliveryInputsFor, { project: projectStageInput }),
16652
+ staleOnTheDeliveryPath = () => [...staleOnPath(run.runDir, deliveryPathNow(), deliveryInputsFor, { project: projectStageInput }),
16302
16653
  ...cardsNeverBuilt(P, ctx.depth, cardOrdinals)]; // a finding a later save added is owed its one card call
16654
+ staleStages = staleOnTheDeliveryPath();
16303
16655
  // A DELIVERY-CHECK REPAIR THAT CHANGED ONLY THE FINDINGS RECORD leaves the narrative's bytes, and so the
16304
16656
  // reviewer, fresh — the reviewer does not declare findings.json. The change is still content no review has
16305
16657
  // read, so the reviewer is queued for the review after a repair, marked as the record's change.
@@ -16978,8 +17330,9 @@ async function pipelineInner(job, opts = {}) {
16978
17330
  // doc-44 Fix C — machine-file DELIVERY gate (mechanism + a pluggable core-artifact seam). A top-level-invalid
16979
17331
  // CORE machine artifact must NOT ship: it is repaired UPSTREAM (the synthesis quarantine + the corrective
16980
17332
  // must() re-synthesis already fail a top-level-invalid findings.json), and THIS is the final backstop before
16981
- // publish so nothing invalid slips through a post-synthesis mutation. Fails LOUDLY via the technical-failure
16982
- // path — never quarantine-and-ship. We validate with the LENIENT parser so a legitimate PER-FINDING quarantine
17333
+ // publish so nothing invalid slips through a post-synthesis mutation. Never quarantine-and-ship: a broken
17334
+ // findings.json is restored from the last save that passed (restoreTheRecordThatPassed), and with none to restore
17335
+ // the failure is the assessment not completed. We validate with the LENIENT parser so a legitimate PER-FINDING quarantine
16983
17336
  // (one bad finding dropped, the rest shipped) still passes — THAT (whether a dropped finding is a core failure)
16984
17337
  // is a POLICY call the definition-of-done (spec 45) owns, not this gate. The CORE_ARTIFACTS list is the SEAM:
16985
17338
  // spec 45 owns its contents; it is seeded with the machine findings file doc-44 names. This enforces machine-
@@ -16995,8 +17348,13 @@ async function pipelineInner(job, opts = {}) {
16995
17348
  }
16996
17349
  if (coreInvalid.length) {
16997
17350
  runLog(run.runDir, { event: "core-artifact-invalid", failures: coreInvalid });
16998
- note(`core-artifact gate: ${coreInvalid.join("; ")} — failing the run (a report cannot ship over a broken machine artifact)`);
16999
- throw new StageFailure("core-artifact-gate", `a core machine artifact is invalid and cannot ship: ${coreInvalid.join("; ")}`);
17351
+ // The record, broken after the delivery checks first read it, is restored as it is there (restoreTheBrokenRecord),
17352
+ // and what was built from it is judged again against the record that stands: the freshness check below parks a
17353
+ // stage it left stale, and the resumed pass rebuilds it.
17354
+ const failure = new StageFailure("core-artifact-gate", `a core machine artifact is invalid and cannot ship: ${coreInvalid.join("; ")}`);
17355
+ if (!coreInvalid.every((f) => f.startsWith("findings.json "))) throw failure; // only the findings record is the assessment's
17356
+ if (!(await restoreTheBrokenRecord(coreInvalid, "delivery-gate"))) throw Object.assign(failure, { notCompletedPart: "assessment" });
17357
+ staleStages = staleOnTheDeliveryPath();
17000
17358
  }
17001
17359
 
17002
17360
  // P2 clause 3 — FRESHNESS PRECONDITION, immediately before the client gate. copper-vault's gate
@@ -17350,17 +17708,30 @@ async function pipelineInner(job, opts = {}) {
17350
17708
  // a run that delivers carries neither. A rate limit and a stop never reach this line (both are handled above), and
17351
17709
  // a storage failure is not a covered part: no throw site names one.
17352
17710
  const coveredPart = e instanceof StageFailure ? (e.notCompletedPart ?? null) : null;
17353
- if (coveredPart && run?.runDir && !readPartNotCompleted(run.runDir, coveredPart)) {
17711
+ const heldRecord = coveredPart && run?.runDir ? readPartNotCompleted(run.runDir, coveredPart) : null;
17712
+ // THE SEARCHES' RECORD GROWS: a failure is routed again while it names a slice the record does not hold yet.
17713
+ const addedSlices = coveredPart === "searches" ? slicesNotYetRecorded(heldRecord, e.excludedSlices) : [];
17714
+ const addedAxes = coveredPart === "searches" ? axesNotYetDisclosed(heldRecord, e.taintedAxes) : [];
17715
+ const addedUnits = coveredPart === "searches" ? unitsNotYetRecorded(heldRecord, e.unfinishedUnits) : [];
17716
+ if (coveredPart && run?.runDir && (coveredPart === "searches" ? addedSlices.length + addedAxes.length + addedUnits.length > 0 : !heldRecord)) {
17354
17717
  let parked = null;
17355
17718
  try {
17356
- writePartNotCompleted(run.runDir, coveredPart, { stage: failedStage, reason: String(reason), sig: failSig.sig, terminalKind });
17719
+ // A TIMED-OUT AXIS IS READ AS THE LAST PASS LEFT IT: the park renamed its unit, band and digest so a resume would
17720
+ // run it again, and with its recovery spent nothing will, so they are put back.
17721
+ if (addedAxes.length) undoTaintRenames(e.undoRenames);
17722
+ writePartNotCompleted(run.runDir, coveredPart, { stage: failedStage, reason: String(reason), sig: failSig.sig, terminalKind,
17723
+ ...(coveredPart === "searches" ? { slices: [...(Array.isArray(heldRecord?.slices) ? heldRecord.slices : []), ...addedSlices],
17724
+ disclosedAxes: [...(Array.isArray(heldRecord?.disclosed_axes) ? heldRecord.disclosed_axes : []), ...addedAxes],
17725
+ unfinishedUnits: [...(Array.isArray(heldRecord?.unfinished_units) ? heldRecord.unfinished_units : []), ...addedUnits] } : {}) });
17357
17726
  const resumesAt = new Date().toISOString();
17358
17727
  // The payload the runner's self-resume needs (job, agent, codename), and the recovery kind, whose stated
17359
17728
  // clock it keeps exactly: now. No recovery attempt is counted, because nothing is being retried.
17360
17729
  sentinel(run.runDir, ".postponed", { kind: "recovery", recoveryResumesAt: resumesAt, postponedAt: resumesAt,
17361
17730
  codename: run.codename, job, agent, attempt: priorAttempts, reason: String(reason).slice(0, 300), sig: failSig.sig,
17362
17731
  class: failClass, notCompleted: coveredPart });
17363
- writeRunStatus(ctx, { state: "recovering", failedStage, ...terminalReasonFields(reason), recoveryResumesAt: resumesAt, resetsAt: null });
17732
+ // The cause's payload rides beside the sentence, as on the stop it replaces, so the record still points at it.
17733
+ writeRunStatus(ctx, { state: "recovering", failedStage, ...terminalReasonFields(reason), recoveryResumesAt: resumesAt, resetsAt: null,
17734
+ reasonDetail: reasonDetail ? abbrev(String(reasonDetail).replace(/\s+/g, " ").trim(), 600) : null });
17364
17735
  rollupStatus(run.studioRoot);
17365
17736
  runLog(run.runDir, { event: "part-not-completed", part: coveredPart, stage: failedStage, sig: failSig.sig, class: failClass,
17366
17737
  terminalKind: terminalKind ?? null, resumesAt });
@@ -17449,7 +17820,7 @@ async function pipelineInner(job, opts = {}) {
17449
17820
  runId, agent, job, failedStage, shortReason,
17450
17821
  reasonVerbatim: String(reason).slice(0, 1000), sig: failSig.sig, failClass,
17451
17822
  terminalKind: terminalKind ?? null, repairs: attemptedRepairs ?? [],
17452
- priorAttempts, whatsappTo: AGENT_WHATSAPP[agent] ?? null,
17823
+ priorAttempts,
17453
17824
  // Read off the park history rather than recomputed: the history is what survived the parks.
17454
17825
  capWait: capWaitFrom(Array.isArray(statusSnapshot?.recoveryHistory) ? statusSnapshot.recoveryHistory : []),
17455
17826
  reasonDetail: reasonDetailField, reasonQuantity: quantity, // — the payload reaches the notice
@@ -17506,7 +17877,7 @@ async function pipelineInner(job, opts = {}) {
17506
17877
  const packet = buildFailurePacket({
17507
17878
  runId: `${run.slug}-${run.date}-${run.codename}`, agent, job, failedStage, shortReason, // canonical runId form (charter P1 §3)
17508
17879
  reasonVerbatim: String(reason).slice(0, 1000), sig: failSig.sig, failClass, terminalKind,
17509
- repairs: attemptedRepairs ?? [], priorAttempts, whatsappTo: AGENT_WHATSAPP[agent] ?? null,
17880
+ repairs: attemptedRepairs ?? [], priorAttempts,
17510
17881
  reasonDetail: reasonDetailField, reasonQuantity: quantity, // — the BACKSTOP notice carries it too
17511
17882
  });
17512
17883
  writeFileSync(driverDir(run.runDir, "failure.json"), JSON.stringify(packet, null, 2) + "\n");
@@ -18074,6 +18445,12 @@ async function runExperimentInner(job, opts) {
18074
18445
  throw new Error(`--experiment ${name}: --axis "${axis}" is not ${vocab.what} (${vocab.oneOf})`);
18075
18446
  // A typo here changes WHICH driver-computed blocks the arm carries and nothing would say so — the
18076
18447
  // same silent-degradation shape the RETIRED_FLAGS table at the bottom of this file exists for.
18448
+ // `--research replay` answers the arm's research from the run's own answers and sends nothing out
18449
+ // (research-answers.mjs). Only a stage that holds the research tool has research to replay.
18450
+ if (opts.research != null && !RESEARCH_MODES.includes(opts.research))
18451
+ throw new Error(`--research: unknown value "${opts.research}" — one of: ${RESEARCH_MODES.join(", ")}`);
18452
+ if (opts.research === "replay" && !experimentToolGroups(name).includes("perplexity"))
18453
+ throw new Error(`--experiment ${name} --research replay: ${name} holds no research tool, so there is no research to replay`);
18077
18454
  if (opts.dispatchTrigger != null && !DISPATCH_TRIGGERS.includes(opts.dispatchTrigger))
18078
18455
  throw new Error(`--dispatch-trigger: unknown value "${opts.dispatchTrigger}" — it decides which production pass the arm reproduces, so a typo would compose a quietly different prompt. One of: ${DISPATCH_TRIGGERS.join(", ")}`);
18079
18456
  const ctx = reconstructCtx(job, opts);
@@ -18087,7 +18464,10 @@ async function runExperimentInner(job, opts) {
18087
18464
  // Any ctx field holding a PATH is dropped rather than inherited: reconstructCtx resolved them against
18088
18465
  // the canonical run, and a sandbox whose prompt names a canonical path is not a sandbox. The
18089
18466
  // derivations below re-set them against ctx.paths, which here is the shadow dir.
18090
- const shadowCtx = { ...ctx, paths: paths(shadowDir), axis, gridSpecPath: null, clHalfTerms: null };
18467
+ // A `fresh` arm is a stage's first attempt, which is handed no earlier attempt's research: the copied answers are
18468
+ // what the replay answers from, never a hand-off production's first attempt does not get.
18469
+ const shadowCtx = { ...ctx, paths: paths(shadowDir), axis, gridSpecPath: null, clHalfTerms: null,
18470
+ researchHandOff: (opts.dispatchTrigger ?? "fresh") !== "fresh" };
18091
18471
  const inputOpts = { axes: ctx.axes, axis, registerOnly: ctx.registerOnly };
18092
18472
  const manifest = sandboxManifest(name, ctx.paths, inputOpts);
18093
18473
  // ── 1. GATHER — the sandbox manifest, not the freshness list ──────────────────────────────────────
@@ -18138,6 +18518,15 @@ async function runExperimentInner(job, opts) {
18138
18518
  throw new Error(`--experiment ${name}: the sandbox could not reproduce ${gaps.length} thing(s) this stage's context has on the canonical run — refusing to dispatch against an incomplete context:\n` +
18139
18519
  gaps.map((g) => ` · ${g.rel} [${g.kind}${g.via ? ` via ${g.via}` : ""}]${g.why ? ` — ${g.why}` : ""}`).join("\n"));
18140
18520
  }
18521
+ // A REPLAY NEEDS ANSWERS TO REPLAY. Refused by name when the run kept none, rather than dispatched to refuse
18522
+ // every question it asks; the marker is the research server's whole switch, and only a sandbox writes it.
18523
+ let replayedAnswers = null;
18524
+ if (opts.research === "replay") {
18525
+ replayedAnswers = savedResearchCount(shadowDir);
18526
+ if (!replayedAnswers)
18527
+ throw new Error(`--experiment ${name} --research replay: this run kept no research answers (_driver/research-answers.jsonl is absent or holds none), so there is nothing to replay — run the arm with --research live`);
18528
+ atomicWrite(driverDir(shadowDir, RESEARCH_REPLAY_FILE), JSON.stringify({ replayOf: "the run's saved research answers", answers: replayedAnswers }, null, 2) + "\n");
18529
+ }
18141
18530
  // ── 4. INLINE context — what no file copy can supply ──────────────────────────────────────────────
18142
18531
  //
18143
18532
  // `report-card` receives its finding as ctx.finding; its declared findings.json exists for staleness,
@@ -18224,6 +18613,10 @@ async function runExperimentInner(job, opts) {
18224
18613
  // omitted: absent and "there was none" are different facts and only one of them is a defect.
18225
18614
  ratedUnder: ctx.profile?.profileKey ?? null,
18226
18615
  dispatchTrigger: opts.dispatchTrigger ?? "fresh",
18616
+ // How the arm's research was answered: sent out live, or replayed from the run's own answers.
18617
+ research: opts.research === "replay" ? { mode: "replay", answers: replayedAnswers } : { mode: "live" },
18618
+ // Whether the arm's message hands on an earlier attempt's research, as a retry's does: never on a `fresh` arm.
18619
+ researchHandOff: shadowCtx.researchHandOff,
18227
18620
  // — WHICH PASS THIS ARM REPRODUCED, in a word. null on a non-corrective arm;
18228
18621
  // never "dispatched-warm", which is production's alone.
18229
18622
  correctivePass: whichPass,
@@ -18329,7 +18722,7 @@ function parseArgv(argv) {
18329
18722
  const o = {};
18330
18723
  const flags = { "--job": "job", "--agent": "agent", "--resume": "codename", "--from": "fromStage",
18331
18724
  "--experiment": "experiment", "--model": "model", "--instructions": "instructions",
18332
- "--label": "label", "--axis": "axis", "--dispatch-trigger": "dispatchTrigger" };
18725
+ "--label": "label", "--axis": "axis", "--dispatch-trigger": "dispatchTrigger", "--research": "research" };
18333
18726
  for (let i = 0; i < argv.length; i++) {
18334
18727
  const key = flags[argv[i]];
18335
18728
  if (!key) {
@@ -18497,7 +18890,7 @@ export function resumeAdvice({ result = null, error = null, signal = null, scrip
18497
18890
 
18498
18891
  const USAGE = "usage: node pipeline.mjs --job <file.json> [--agent <id>]\n" +
18499
18892
  " [--resume <codename> [--from <stage>]]\n" +
18500
- " [--resume <codename> --experiment <stage> [--model <id>] [--instructions <text>] [--axis <axis>] [--label <text>] [--dispatch-trigger <t>]]\n" +
18893
+ " [--resume <codename> --experiment <stage> [--model <id>] [--instructions <text>] [--axis <axis>] [--label <text>] [--dispatch-trigger <t>] [--research live|replay]]\n" +
18501
18894
  " --instructions appends to a stage's prompt (a light tweak); a full prompt rewrite means editing the stage's skill file.\n" +
18502
18895
  " --axis also names the ORDINAL for report-card (its dispatch label is report-card:<ordinal>).\n" +
18503
18896
  " --dispatch-trigger picks WHICH production pass the arm reproduces (default \"fresh\"); a corrective pass carries prompt blocks a fresh one does not.";
@@ -18612,7 +19005,7 @@ if (isEntrypoint(import.meta.url)) void (async () => {
18612
19005
  // hangs `__runRef` on the caller's own opts the moment the run context is built, which is how pipeline()'s
18613
19006
  // rate-limit catch already finds the run to postpone; the same hook lets a THROWN exit name the run.
18614
19007
  const opts = a.experiment
18615
- ? { ...base, codename: a.codename, experiment: a.experiment, model: a.model, instructions: a.instructions, axis: a.axis, label: a.label, dispatchTrigger: a.dispatchTrigger }
19008
+ ? { ...base, codename: a.codename, experiment: a.experiment, model: a.model, instructions: a.instructions, axis: a.axis, label: a.label, dispatchTrigger: a.dispatchTrigger, research: a.research }
18616
19009
  : { ...base, codename: a.codename, fromStage: a.fromStage, ...(a.job ? {} : { jobRebuiltFromStatus: true }) };
18617
19010
  const script = resolve(fileURLToPath(import.meta.url));
18618
19011
  // `jobPath` is null on a rebuilt resume, so the advice composes a command that does not name a file