audit-tools 0.34.5 → 0.34.7

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 (106) hide show
  1. package/LICENSE +1 -1
  2. package/dist/audit/cli/auditStep.d.ts +7 -1
  3. package/dist/audit/cli/auditStep.d.ts.map +1 -1
  4. package/dist/audit/cli/auditStep.js +1 -4
  5. package/dist/audit/cli/auditStep.js.map +1 -1
  6. package/dist/audit/cli/dispatch/hostFanoutGate.d.ts.map +1 -1
  7. package/dist/audit/cli/dispatch/hostFanoutGate.js +6 -0
  8. package/dist/audit/cli/dispatch/hostFanoutGate.js.map +1 -1
  9. package/dist/audit/cli/dispatch/quotaPool.d.ts +6 -0
  10. package/dist/audit/cli/dispatch/quotaPool.d.ts.map +1 -1
  11. package/dist/audit/cli/dispatch/quotaPool.js +7 -8
  12. package/dist/audit/cli/dispatch/quotaPool.js.map +1 -1
  13. package/dist/audit/cli/dispatch.d.ts.map +1 -1
  14. package/dist/audit/cli/dispatch.js +6 -1
  15. package/dist/audit/cli/dispatch.js.map +1 -1
  16. package/dist/audit/cli/hybridDispatch.d.ts +11 -1
  17. package/dist/audit/cli/hybridDispatch.d.ts.map +1 -1
  18. package/dist/audit/cli/hybridDispatch.js +1 -0
  19. package/dist/audit/cli/hybridDispatch.js.map +1 -1
  20. package/dist/audit/cli/nextStepCommand.d.ts +1 -1
  21. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  22. package/dist/audit/cli/nextStepCommand.js +76 -7
  23. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  24. package/dist/audit/cli/nextStepHelpers.d.ts +60 -0
  25. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  26. package/dist/audit/cli/nextStepHelpers.js +316 -40
  27. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  28. package/dist/audit/cli/prompts.d.ts +2 -0
  29. package/dist/audit/cli/prompts.d.ts.map +1 -1
  30. package/dist/audit/cli/prompts.js +1 -0
  31. package/dist/audit/cli/prompts.js.map +1 -1
  32. package/dist/audit/cli/providerConfirmationStep.d.ts +52 -0
  33. package/dist/audit/cli/providerConfirmationStep.d.ts.map +1 -1
  34. package/dist/audit/cli/providerConfirmationStep.js +224 -55
  35. package/dist/audit/cli/providerConfirmationStep.js.map +1 -1
  36. package/dist/audit/cli/quotaCommand.d.ts.map +1 -1
  37. package/dist/audit/cli/quotaCommand.js +5 -2
  38. package/dist/audit/cli/quotaCommand.js.map +1 -1
  39. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  40. package/dist/audit/orchestrator/advance.js +13 -0
  41. package/dist/audit/orchestrator/advance.js.map +1 -1
  42. package/dist/audit/orchestrator/advanceTypes.d.ts +12 -0
  43. package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
  44. package/dist/audit/orchestrator/intakeExecutors.d.ts +14 -8
  45. package/dist/audit/orchestrator/intakeExecutors.d.ts.map +1 -1
  46. package/dist/audit/orchestrator/intakeExecutors.js +215 -25
  47. package/dist/audit/orchestrator/intakeExecutors.js.map +1 -1
  48. package/dist/audit/orchestrator/nextStep.d.ts +6 -0
  49. package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
  50. package/dist/audit/orchestrator/nextStep.js +3 -0
  51. package/dist/audit/orchestrator/nextStep.js.map +1 -1
  52. package/dist/audit/orchestrator/state.d.ts +22 -0
  53. package/dist/audit/orchestrator/state.d.ts.map +1 -1
  54. package/dist/audit/orchestrator/state.js +16 -4
  55. package/dist/audit/orchestrator/state.js.map +1 -1
  56. package/dist/audit/types/designAssessment.d.ts +22 -0
  57. package/dist/audit/types/designAssessment.d.ts.map +1 -1
  58. package/dist/audit/types/externalAnalyzer.d.ts +6 -6
  59. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  60. package/dist/remediate/steps/contractPipeline.js +6 -0
  61. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  62. package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -1
  63. package/dist/remediate/steps/dispatch/marshal.js +6 -1
  64. package/dist/remediate/steps/dispatch/marshal.js.map +1 -1
  65. package/dist/remediate/steps/dispatch/waveScheduling.d.ts +31 -0
  66. package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -1
  67. package/dist/remediate/steps/dispatch/waveScheduling.js +13 -2
  68. package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -1
  69. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  70. package/dist/remediate/steps/nextStep.js +3 -1
  71. package/dist/remediate/steps/nextStep.js.map +1 -1
  72. package/dist/shared/dispatch/admissionLoop.d.ts +45 -12
  73. package/dist/shared/dispatch/admissionLoop.d.ts.map +1 -1
  74. package/dist/shared/dispatch/admissionLoop.js +41 -20
  75. package/dist/shared/dispatch/admissionLoop.js.map +1 -1
  76. package/dist/shared/dispatch/dispatchQuotaContract.d.ts +7 -7
  77. package/dist/shared/friction/stepBoundaryCapture.d.ts +24 -1
  78. package/dist/shared/friction/stepBoundaryCapture.d.ts.map +1 -1
  79. package/dist/shared/friction/stepBoundaryCapture.js +43 -0
  80. package/dist/shared/friction/stepBoundaryCapture.js.map +1 -1
  81. package/dist/shared/index.d.ts +4 -4
  82. package/dist/shared/index.d.ts.map +1 -1
  83. package/dist/shared/index.js +4 -4
  84. package/dist/shared/index.js.map +1 -1
  85. package/dist/shared/providers/identity.d.ts +18 -7
  86. package/dist/shared/providers/identity.d.ts.map +1 -1
  87. package/dist/shared/providers/identity.js +23 -8
  88. package/dist/shared/providers/identity.js.map +1 -1
  89. package/dist/shared/providers/providerConfirmation.d.ts.map +1 -1
  90. package/dist/shared/providers/providerConfirmation.js +36 -1
  91. package/dist/shared/providers/providerConfirmation.js.map +1 -1
  92. package/dist/shared/providers/sharedProviderConfirmation.d.ts +349 -20
  93. package/dist/shared/providers/sharedProviderConfirmation.d.ts.map +1 -1
  94. package/dist/shared/providers/sharedProviderConfirmation.js +813 -41
  95. package/dist/shared/providers/sharedProviderConfirmation.js.map +1 -1
  96. package/dist/shared/quota/apiPool.d.ts +18 -0
  97. package/dist/shared/quota/apiPool.d.ts.map +1 -1
  98. package/dist/shared/quota/apiPool.js +58 -6
  99. package/dist/shared/quota/apiPool.js.map +1 -1
  100. package/dist/shared/quota/hostPool.d.ts +8 -0
  101. package/dist/shared/quota/hostPool.d.ts.map +1 -1
  102. package/dist/shared/quota/hostPool.js +1 -0
  103. package/dist/shared/quota/hostPool.js.map +1 -1
  104. package/dist/shared/types/providerConfirmation.d.ts +55 -2
  105. package/dist/shared/types/providerConfirmation.d.ts.map +1 -1
  106. package/package.json +6 -6
@@ -5,7 +5,7 @@
5
5
  * top-level cmdNextStep dispatcher, keeping each module focused on a single
6
6
  * concern.
7
7
  */
8
- import { access, unlink } from "node:fs/promises";
8
+ import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
10
  import { advance, isFileMissingError, readJsonFile, writeJsonFile, readProviderConfirmationInput, } from "audit-tools/shared";
11
11
  import { loadArtifactBundle, promoteFinalAuditReport, writeCoreArtifacts, } from "../io/artifacts.js";
@@ -82,7 +82,7 @@ export async function stampSystemicChallengeSkipped(artifactsDir, bundle) {
82
82
  }
83
83
  import { driveRollingAuditDispatch, resolveAuditRollingEngineEnabled, resolveHostDispatchProviderName, } from "./rollingAuditDispatch.js";
84
84
  import { buildAuditSourcePools, isInProcessAuditPool, auditNodeClaimRegistry, auditHybridSettledPath, } from "./hybridDispatch.js";
85
- import { planHybridDispatch, readSettledPools, addSettledPool, readConfirmedDispatchPolicy, resolveDispatchExclusion, captureZeroCapacityFriction, PROVIDER_CONFIRMATION_FRICTION_RUN_KEY, } from "audit-tools/shared";
85
+ import { planHybridDispatch, readSettledPools, addSettledPool, readConfirmedDispatchPolicy, readConfirmedCapabilityRanks, resolveDispatchExclusion, captureZeroCapacityFriction, PROVIDER_CONFIRMATION_FRICTION_RUN_KEY, } from "audit-tools/shared";
86
86
  import { resolveHostDispatchCapability } from "./args.js";
87
87
  /**
88
88
  * Re-drive an in-process rolling dispatch up to `maxRetries` times with exponential
@@ -221,7 +221,8 @@ export async function buildTerminalStep(params, bundle, state, blockedReason) {
221
221
  * - `return` → caller should emit the embedded result to cmdNextStep.
222
222
  * - `fallthrough` → no incoming artifacts; run the deterministic executor.
223
223
  */
224
- export async function handleGraphEnrichmentBranch(params, bundle, state, analyzersRef) {
224
+ export async function handleGraphEnrichmentBranch(params, bundle, state, analyzersRef, deps = {}) {
225
+ const runStep = deps.runStep ?? runAuditStep;
225
226
  // Fold-level pause detection is single-sourced in `hostInputPause` so the drain
226
227
  // stop predicate (`nextStepPausesForHostInput`) and this fold agree EXACTLY on
227
228
  // when the analyzer-install consent / edge-reasoning turns are owed.
@@ -232,8 +233,14 @@ export async function handleGraphEnrichmentBranch(params, bundle, state, analyze
232
233
  };
233
234
  const unresolved = graphEnrichmentUnresolvedAnalyzers(bundle, pauseInputs);
234
235
  if (unresolved.length > 0) {
235
- const incoming = await tryConsumeIncoming(params.artifactsDir, "analyzer-decisions.json");
236
- if (incoming && incoming.value !== null && typeof incoming.value === "object") {
236
+ const incoming = await consumeObjectIncoming(params.artifactsDir, "analyzer-decisions.json");
237
+ if (incoming.status === "quarantined") {
238
+ // A non-object top-level value used to be neither merged, deleted, nor
239
+ // diagnosed — the file lingered in incoming/ and the analyzer_install
240
+ // step re-emitted silently forever. Quarantined + diagnosed instead.
241
+ return { action: "continue" };
242
+ }
243
+ if (incoming.status === "ok") {
237
244
  const settings = {};
238
245
  for (const [id, value] of Object.entries(incoming.value)) {
239
246
  if (value === "ephemeral" ||
@@ -274,15 +281,37 @@ export async function handleGraphEnrichmentBranch(params, bundle, state, analyze
274
281
  if (candidates.length > 0) {
275
282
  const edgeReasoningIncoming = await tryConsumeIncoming(params.artifactsDir, "edge-reasoning.json");
276
283
  if (edgeReasoningIncoming) {
277
- await runAuditStep({
284
+ // Same hazard class as the design-review quarantine fix: a malformed
285
+ // submission used to no-op silently inside applyEdgeReasoning (it never
286
+ // throws), the unconditional unlink then destroyed the file, and the
287
+ // identical edge_reasoning step re-emitted with zero signal. Tolerant-
288
+ // unwrap first (EdgeReasoningResults is a single-array-property object,
289
+ // so the same "exactly one array-valued top-level property" rule
290
+ // applies, and a bare rewrites array is accepted too); anything else is
291
+ // quarantined and named in the re-emitted step's prompt.
292
+ const unwrapped = unwrapIncomingArray(edgeReasoningIncoming.value);
293
+ if (!unwrapped.ok) {
294
+ const quarantinePath = await quarantineIncomingFile(params.artifactsDir, edgeReasoningIncoming.path, "edge-reasoning.json");
295
+ await recordEdgeReasoningRejection(params.artifactsDir, {
296
+ filename: "edge-reasoning.json",
297
+ quarantine_path: quarantinePath,
298
+ reason: unwrapped.reason,
299
+ rejected_at: new Date().toISOString(),
300
+ });
301
+ return { action: "continue" };
302
+ }
303
+ // Apply BEFORE deleting the incoming file: if runStep throws (locks,
304
+ // crash), the submission survives for the retry instead of being lost.
305
+ await runStep({
278
306
  root: params.root,
279
307
  artifactsDir: params.artifactsDir,
280
308
  analyzers: analyzersRef.value,
281
309
  graphLlmEdgeReasoning: true,
282
- edgeReasoningResultsPath: edgeReasoningIncoming.path,
310
+ edgeReasoningResults: { rewrites: unwrapped.array },
283
311
  since: params.since,
284
312
  });
285
313
  await unlink(edgeReasoningIncoming.path).catch(() => { });
314
+ await clearEdgeReasoningRejection(params.artifactsDir);
286
315
  return { action: "continue" };
287
316
  }
288
317
  return { action: "return", result: { kind: "edge_reasoning", state, bundle, candidates } };
@@ -310,45 +339,245 @@ function passIsStale(bundle, pass) {
310
339
  const snapshot = bundle.design_review_snapshots?.[pass];
311
340
  return snapshot ? isDesignReviewStale(snapshot, bundle) : false;
312
341
  }
342
+ /** Human-readable description of why an incoming value is neither an array nor a single-array-wrapped object. */
343
+ function describeIncomingShapeMismatch(value) {
344
+ if (value === null)
345
+ return "null";
346
+ if (Array.isArray(value))
347
+ return "an array"; // reachable from consumeObjectIncoming (an array is not a key→value map)
348
+ const t = typeof value;
349
+ if (t !== "object")
350
+ return `a bare ${t}`;
351
+ const entries = Object.entries(value);
352
+ if (entries.length === 0)
353
+ return "an empty object";
354
+ const arrayKeys = entries.filter(([, v]) => Array.isArray(v)).map(([k]) => k);
355
+ const allKeys = entries.map(([k]) => k).join(", ");
356
+ if (arrayKeys.length === 0) {
357
+ return `an object with no array-valued properties (keys: ${allKeys})`;
358
+ }
359
+ return (`an object with ${arrayKeys.length} array-valued propert${arrayKeys.length === 1 ? "y" : "ies"} ` +
360
+ `out of ${entries.length} total key(s) (${allKeys}) — exactly one top-level array property is ` +
361
+ `required for the tolerant unwrap`);
362
+ }
363
+ /**
364
+ * Move a malformed incoming submission to `<artifactsDir>/quarantine/` rather
365
+ * than deleting it. Falls back to copy+unlink if `rename` fails (e.g. a
366
+ * cross-device incoming/ mount) so the content is never lost.
367
+ */
368
+ async function quarantineIncomingFile(artifactsDir, filePath, originalFilename) {
369
+ const quarantineDir = join(artifactsDir, "quarantine");
370
+ await mkdir(quarantineDir, { recursive: true });
371
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
372
+ const quarantinePath = join(quarantineDir, `${originalFilename}.${timestamp}.json`);
373
+ try {
374
+ await rename(filePath, quarantinePath);
375
+ }
376
+ catch {
377
+ try {
378
+ const content = await readFile(filePath, "utf8");
379
+ await writeFile(quarantinePath, content, "utf8");
380
+ }
381
+ catch {
382
+ // Best-effort: nothing left to quarantine if even the read failed.
383
+ }
384
+ await unlink(filePath).catch(() => { });
385
+ }
386
+ return quarantinePath;
387
+ }
388
+ /**
389
+ * The single tolerant-unwrap rule: a bare array is accepted as-is; a top-level
390
+ * object wrapping exactly one array-valued property is unambiguous and is
391
+ * accepted as that array. Anything else fails with a shape description.
392
+ * Single-sourced so `consumeArrayIncoming` and the edge-reasoning gate cannot
393
+ * drift on what shapes are accepted.
394
+ */
395
+ function unwrapIncomingArray(value) {
396
+ if (Array.isArray(value))
397
+ return { ok: true, array: value };
398
+ if (value !== null && typeof value === "object") {
399
+ const entries = Object.entries(value);
400
+ if (entries.length === 1 && Array.isArray(entries[0][1])) {
401
+ return { ok: true, array: entries[0][1] };
402
+ }
403
+ }
404
+ return { ok: false, reason: describeIncomingShapeMismatch(value) };
405
+ }
406
+ /**
407
+ * Read a JSON `incoming/` file expected to be an array (or a top-level object
408
+ * wrapping exactly one array-valued property, the tolerant unwrap). Accepts
409
+ * either shape and deletes the source file; any other shape is quarantined
410
+ * (never unlinked-and-discarded) and reported with a reason.
411
+ */
412
+ export async function consumeArrayIncoming(artifactsDir, filename) {
413
+ const incoming = await tryConsumeIncoming(artifactsDir, filename);
414
+ if (!incoming)
415
+ return { status: "absent" };
416
+ const { value, path } = incoming;
417
+ const unwrapped = unwrapIncomingArray(value);
418
+ if (unwrapped.ok) {
419
+ await unlink(path).catch(() => { });
420
+ return { status: "ok", value: unwrapped.array, path };
421
+ }
422
+ const quarantinePath = await quarantineIncomingFile(artifactsDir, path, filename);
423
+ return { status: "quarantined", quarantinePath, originalFilename: filename, reason: unwrapped.reason };
424
+ }
425
+ /**
426
+ * Read a JSON `incoming/` file expected to be a plain top-level object (a
427
+ * key → value map, e.g. analyzer-decisions.json). A non-object value — null,
428
+ * an array, a bare primitive — is quarantined with a stderr diagnostic rather
429
+ * than left lingering in `incoming/` (where it used to make the emitting step
430
+ * re-ask silently forever). Unlike `consumeArrayIncoming`, an accepted file is
431
+ * NOT deleted here — the caller unlinks after applying, so a crash mid-apply
432
+ * retains the submission for the retry.
433
+ */
434
+ export async function consumeObjectIncoming(artifactsDir, filename) {
435
+ const incoming = await tryConsumeIncoming(artifactsDir, filename);
436
+ if (!incoming)
437
+ return { status: "absent" };
438
+ const { value, path } = incoming;
439
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
440
+ return { status: "ok", value: value, path };
441
+ }
442
+ const reason = describeIncomingShapeMismatch(value);
443
+ const quarantinePath = await quarantineIncomingFile(artifactsDir, path, filename);
444
+ process.stderr.write(`[audit-code] ${filename} quarantined to ${quarantinePath}: expected a JSON object, got ${reason}. ` +
445
+ `Fix the shape and resubmit.\n`);
446
+ return { status: "quarantined", quarantinePath, reason };
447
+ }
448
+ function edgeReasoningRejectionPath(artifactsDir) {
449
+ return join(artifactsDir, "quarantine", "edge-reasoning.rejection.json");
450
+ }
451
+ async function recordEdgeReasoningRejection(artifactsDir, rejection) {
452
+ await mkdir(join(artifactsDir, "quarantine"), { recursive: true });
453
+ await writeJsonFile(edgeReasoningRejectionPath(artifactsDir), rejection);
454
+ }
455
+ async function clearEdgeReasoningRejection(artifactsDir) {
456
+ await unlink(edgeReasoningRejectionPath(artifactsDir)).catch(() => { });
457
+ }
458
+ /**
459
+ * Render the host-facing notice for a pending quarantined edge-reasoning
460
+ * submission, naming the quarantined file and the shape error — so the
461
+ * re-emitted edge_reasoning step tells the host its prior submission was
462
+ * rejected and why, rather than silently asking again. Returns `undefined`
463
+ * when there is nothing to report.
464
+ */
465
+ export async function renderEdgeReasoningRejectionNotice(artifactsDir) {
466
+ let rejection;
467
+ try {
468
+ rejection = await readJsonFile(edgeReasoningRejectionPath(artifactsDir));
469
+ }
470
+ catch (error) {
471
+ if (isFileMissingError(error))
472
+ return undefined;
473
+ throw error;
474
+ }
475
+ return [
476
+ "## Prior submission rejected",
477
+ "",
478
+ "Your last edge-reasoning submission did not match the expected shape and was " +
479
+ "quarantined (not applied, not silently discarded). Fix the shape and resubmit:",
480
+ "",
481
+ `- \`${rejection.filename}\` quarantined to \`${rejection.quarantine_path}\` (${rejection.rejected_at}): ${rejection.reason}`,
482
+ "",
483
+ 'Expected shape: {"rewrites":[{"from":"...","to":"...","kind":"...","reason":"..."}]} — ' +
484
+ "a bare JSON array of rewrites is also accepted.",
485
+ ].join("\n");
486
+ }
487
+ /**
488
+ * Record a quarantined design-review submission on `design_assessment` (and
489
+ * persist immediately) so the note survives the same-call `continue`
490
+ * re-derivation. A no-op when `design_assessment` doesn't exist yet — in
491
+ * practice unreachable, since `design_assessment_current` is a
492
+ * higher-priority obligation than either design-review pass (PRIORITY in
493
+ * nextStep.ts), so `design_assessment` always exists by the time this branch
494
+ * runs.
495
+ */
496
+ async function recordRejectedDesignReviewSubmission(artifactsDir, existing, pass, quarantined) {
497
+ if (!existing)
498
+ return;
499
+ const entry = {
500
+ pass,
501
+ filename: quarantined.originalFilename,
502
+ quarantine_path: quarantined.quarantinePath,
503
+ reason: quarantined.reason,
504
+ rejected_at: new Date().toISOString(),
505
+ };
506
+ existing.rejected_submissions = [
507
+ ...(existing.rejected_submissions ?? []).filter((r) => r.pass !== pass),
508
+ entry,
509
+ ];
510
+ await writeJsonFile(join(artifactsDir, "design_assessment.json"), existing);
511
+ }
512
+ /**
513
+ * Render a host-facing notice for any pending quarantined design-review
514
+ * submissions matching the given passes, naming the quarantined file and the
515
+ * shape error — so a re-emitted design-review step tells the host its prior
516
+ * submission was rejected and why, rather than silently asking again.
517
+ * Returns `undefined` when there is nothing to report.
518
+ */
519
+ export function renderDesignReviewRejectionNotice(bundle, passes) {
520
+ const rejected = (bundle.design_assessment?.rejected_submissions ?? []).filter((r) => passes.includes(r.pass));
521
+ if (rejected.length === 0)
522
+ return undefined;
523
+ const lines = [
524
+ "## Prior submission rejected",
525
+ "",
526
+ "Your last submission for this pass did not match the expected shape and was " +
527
+ "quarantined (not merged, not silently discarded). Fix the shape and resubmit:",
528
+ "",
529
+ ];
530
+ for (const r of rejected) {
531
+ lines.push(`- **${r.pass}** — \`${r.filename}\` quarantined to \`${r.quarantine_path}\` (${r.rejected_at}): ${r.reason}`);
532
+ }
533
+ lines.push("", "Expected shape: a JSON array of findings, or a top-level object wrapping exactly " +
534
+ 'one array-valued property (e.g. `{"findings": [...]}`).');
535
+ return lines.join("\n");
536
+ }
313
537
  export async function handleDesignReviewBranch(params, bundle, state) {
314
538
  const existing = bundle.design_assessment;
315
- // Legacy: consume old combined findings file. Always unlink when present to
316
- // prevent accumulation, even when design_assessment is absent (COR-68f07c3e).
317
- const legacyIncoming = await tryConsumeIncoming(params.artifactsDir, "design-review-findings.json");
318
- if (legacyIncoming) {
319
- await unlink(legacyIncoming.path).catch(() => { });
320
- if (Array.isArray(legacyIncoming.value) && existing) {
321
- existing.review_findings = groundDesignFindings(legacyIncoming.value, bundle.repo_manifest);
539
+ // Legacy: consume old combined findings file. Tolerant-unwrap or quarantine
540
+ // (never a bare unconditional delete) see the quarantine block comment above.
541
+ const legacyResult = await consumeArrayIncoming(params.artifactsDir, "design-review-findings.json");
542
+ if (legacyResult.status === "quarantined") {
543
+ await recordRejectedDesignReviewSubmission(params.artifactsDir, existing, "legacy", legacyResult);
544
+ return { action: "continue" };
545
+ }
546
+ if (legacyResult.status === "ok") {
547
+ if (existing) {
548
+ existing.review_findings = groundDesignFindings(legacyResult.value, bundle.repo_manifest);
322
549
  existing.reviewed = true;
550
+ existing.rejected_submissions = (existing.rejected_submissions ?? []).filter((r) => r.pass !== "legacy");
323
551
  await writeJsonFile(join(params.artifactsDir, "design_assessment.json"), existing);
324
552
  return { action: "continue" };
325
553
  }
326
- // File consumed (deleted) but no target to merge into — keep folding.
554
+ // File consumed but no target to merge into — keep folding.
327
555
  return { action: "continue" };
328
556
  }
557
+ // absent: fall through to the contract/conceptual check.
329
558
  // New: consume contract-findings and/or conceptual-findings independently.
330
- const contractIncoming = await tryConsumeIncoming(params.artifactsDir, "design-review-contract-findings.json");
331
- const conceptualIncoming = await tryConsumeIncoming(params.artifactsDir, "design-review-conceptual-findings.json");
559
+ const contractResult = await consumeArrayIncoming(params.artifactsDir, "design-review-contract-findings.json");
560
+ const conceptualResult = await consumeArrayIncoming(params.artifactsDir, "design-review-conceptual-findings.json");
332
561
  let consumed = false;
333
- // Always delete incoming files when present; only merge data when existing
334
- // design_assessment is present (COR-68f07c3e).
335
- if (contractIncoming) {
336
- await unlink(contractIncoming.path).catch(() => { });
337
- if (Array.isArray(contractIncoming.value) && existing) {
338
- existing.contract_findings = groundDesignFindings(contractIncoming.value, bundle.repo_manifest);
339
- existing.contract_reviewed = true;
340
- consumed = true;
341
- }
562
+ if (contractResult.status === "quarantined") {
563
+ await recordRejectedDesignReviewSubmission(params.artifactsDir, existing, "contract", contractResult);
342
564
  }
343
- if (conceptualIncoming) {
344
- await unlink(conceptualIncoming.path).catch(() => { });
345
- if (Array.isArray(conceptualIncoming.value) && existing) {
346
- existing.conceptual_findings = groundDesignFindings(conceptualIncoming.value, bundle.repo_manifest);
347
- existing.conceptual_reviewed = true;
348
- consumed = true;
349
- }
565
+ else if (contractResult.status === "ok" && existing) {
566
+ existing.contract_findings = groundDesignFindings(contractResult.value, bundle.repo_manifest);
567
+ existing.contract_reviewed = true;
568
+ existing.rejected_submissions = (existing.rejected_submissions ?? []).filter((r) => r.pass !== "contract");
569
+ consumed = true;
570
+ }
571
+ if (conceptualResult.status === "quarantined") {
572
+ await recordRejectedDesignReviewSubmission(params.artifactsDir, existing, "conceptual", conceptualResult);
350
573
  }
351
- if (contractIncoming || conceptualIncoming) {
574
+ else if (conceptualResult.status === "ok" && existing) {
575
+ existing.conceptual_findings = groundDesignFindings(conceptualResult.value, bundle.repo_manifest);
576
+ existing.conceptual_reviewed = true;
577
+ existing.rejected_submissions = (existing.rejected_submissions ?? []).filter((r) => r.pass !== "conceptual");
578
+ consumed = true;
579
+ }
580
+ if (contractResult.status !== "absent" || conceptualResult.status !== "absent") {
352
581
  // Item C: the design-review fan-out results are in — release the host-fan-out
353
582
  // panel's leases now, before the coverage packet dispatch that follows, rather
354
583
  // than letting them linger to the 20-min TTL and depress its headroom.
@@ -362,10 +591,10 @@ export async function handleDesignReviewBranch(params, bundle, state) {
362
591
  // rather than a blind full re-run. Capture after the design_assessment write
363
592
  // so the projection reflects the persisted findings.
364
593
  const reviewedAt = new Date().toISOString();
365
- if (contractIncoming) {
594
+ if (contractResult.status === "ok") {
366
595
  await captureDesignReviewSnapshot(params.artifactsDir, "contract", existing.contract_findings ?? [], bundle, reviewedAt);
367
596
  }
368
- if (conceptualIncoming) {
597
+ if (conceptualResult.status === "ok") {
369
598
  await captureDesignReviewSnapshot(params.artifactsDir, "conceptual", existing.conceptual_findings ?? [], bundle, reviewedAt);
370
599
  }
371
600
  return { action: "continue" };
@@ -819,6 +1048,14 @@ export async function checkFinalizationCycle(ctx) {
819
1048
  function gateKeys(gate) {
820
1049
  return (gate?.newlyReachable ?? []).map((b) => b.key);
821
1050
  }
1051
+ /**
1052
+ * The capability-evidence delta as bare model ids. Read through the shared gate object
1053
+ * on every call for exactly the same reason as {@link gateKeys} — the executor clears
1054
+ * it on promotion, and a stale read re-selects `PRIORITY[0]` forever.
1055
+ */
1056
+ function gateUnevidencedCapability(gate) {
1057
+ return gate?.unevidencedCapability ?? [];
1058
+ }
822
1059
  /**
823
1060
  * A deterministic-executor `emit` of a blocked step — the `!progress_made`
824
1061
  * dead-end the hand loop returned directly from `executeAndRecord`.
@@ -853,6 +1090,7 @@ async function runDeterministicExecutor(bundle, ctx) {
853
1090
  // executor for the one the engine actually picked.
854
1091
  const decision = decideNextStep(bundle, {
855
1092
  newlyReachableBackends: gateKeys(ctx.params.providerConfirmationGate),
1093
+ unevidencedCapabilityPools: gateUnevidencedCapability(ctx.params.providerConfirmationGate),
856
1094
  });
857
1095
  const noProgress = await checkNoProgressBeforeDispatch({
858
1096
  index: ctx.iterationRef.value,
@@ -902,6 +1140,7 @@ function deriveObligationState(id, gate) {
902
1140
  return "satisfied";
903
1141
  const state = deriveAuditState(bundle, {
904
1142
  newlyReachableBackends: gateKeys(gate),
1143
+ unevidencedCapabilityPools: gateUnevidencedCapability(gate),
905
1144
  });
906
1145
  const found = state.obligations.find((o) => o.id === id);
907
1146
  if (!found)
@@ -945,10 +1184,42 @@ export function buildAuditObligations(gate) {
945
1184
  // Operator has submitted → consume it (writes both canonical artifacts).
946
1185
  return runDeterministicExecutor(bundle, ctx);
947
1186
  }
948
- if ((gate?.newlyReachable.length ?? 0) > 0 && gate?.autonomous) {
949
- // G3, autonomous: a backend the operator never confirmed became reachable
950
- // and there is nobody to ask. Fold to the executor, which fail-closed-
951
- // excludes it + records the friction never emit a prompt no one will read.
1187
+ // `?.` on the property too, not just `gate` itself: unlike the old single
1188
+ // `||` expression (whose short-circuit meant a fixture setting only one of
1189
+ // the two fields never evaluated the other), both are now read
1190
+ // unconditionally below, so an omitted field must degrade to `0`, not throw.
1191
+ const capabilityDelta = (gate?.unevidencedCapability?.length ?? 0) > 0;
1192
+ const reachDelta = (gate?.newlyReachable?.length ?? 0) > 0;
1193
+ if (gate?.autonomous && capabilityDelta) {
1194
+ // R3-3: autonomous ≠ "no LLM" — it means no HUMAN operator. `next-step`
1195
+ // still returns a prompt contract to the host agent driving the run, so a
1196
+ // capability-evidence delta on an autonomous run EMITS the ranker prompt
1197
+ // addressed to that host LLM instead of folding to the executor (which
1198
+ // today can only promote the pools unranked — a loud fail-open, not a
1199
+ // fix). The reach delta, if also present, does NOT get asked here: reach
1200
+ // is never an LLM's call (the executor fails it closed regardless of what
1201
+ // this submission says — see `intakeExecutors.ts`'s `authoredByLlm`), so
1202
+ // the prompt omits that section entirely and the reach delta is resolved
1203
+ // on a LATER attended re-confirmation, not this one.
1204
+ return {
1205
+ kind: "emit",
1206
+ step: {
1207
+ kind: "provider_confirmation",
1208
+ state: deriveAuditState(bundle, {
1209
+ newlyReachableBackends: gateKeys(gate),
1210
+ unevidencedCapabilityPools: gateUnevidencedCapability(gate),
1211
+ }),
1212
+ bundle,
1213
+ },
1214
+ };
1215
+ }
1216
+ if (gate?.autonomous && reachDelta) {
1217
+ // G3, autonomous, reach-only (no capability delta): a backend the operator
1218
+ // never confirmed became reachable and there is nobody to ask — and unlike
1219
+ // capability, reach is not something an LLM may answer on the operator's
1220
+ // behalf either (see the executor's sharp edge). Fold to the executor,
1221
+ // which fail-closed-excludes it + records the friction — never emit a
1222
+ // prompt no one will read.
952
1223
  //
953
1224
  // Scoped to the DELTA case deliberately: a first-time confirmation (no
954
1225
  // artifact at all) still pauses even under `autonomous_mode`, exactly as
@@ -966,6 +1237,7 @@ export function buildAuditObligations(gate) {
966
1237
  kind: "provider_confirmation",
967
1238
  state: deriveAuditState(bundle, {
968
1239
  newlyReachableBackends: gateKeys(gate),
1240
+ unevidencedCapabilityPools: gateUnevidencedCapability(gate),
969
1241
  }),
970
1242
  bundle,
971
1243
  },
@@ -1206,6 +1478,10 @@ async function runHostDelegationObligation(bundle, ctx) {
1206
1478
  const auditExcludedBackends = resolveDispatchExclusion(await readConfirmedDispatchPolicy(ctx.params.root));
1207
1479
  const { pools: auditSourcePools, zeroedByExclusion } = await buildAuditSourcePools(hybridCfg, {
1208
1480
  excludedBackends: auditExcludedBackends,
1481
+ // These pools ARE the headless rolling drive's `poolsOverride`, not a preview —
1482
+ // omitting the ranks here would leave every headless audit run fail-opening at the
1483
+ // capability floor, the exact behavior this obligation exists to remove.
1484
+ capabilityRanks: await readConfirmedCapabilityRanks(ctx.params.root),
1209
1485
  // D1 cross-class collision rule: an attended host identity colliding with a
1210
1486
  // folded source keeps exactly one pool (the in-process source survives — the
1211
1487
  // engine drives that one account; a non-in-process collider is dropped).