@tokenfactory/acc-runner 0.44.0 → 0.44.2

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 (197) hide show
  1. package/README.md +43 -1
  2. package/dist/bin-resolve.d.ts +39 -3
  3. package/dist/bin-resolve.d.ts.map +1 -1
  4. package/dist/bin-resolve.js +176 -12
  5. package/dist/bin-resolve.js.map +1 -1
  6. package/dist/build-fingerprint.d.ts +66 -0
  7. package/dist/build-fingerprint.d.ts.map +1 -0
  8. package/dist/build-fingerprint.js +70 -0
  9. package/dist/build-fingerprint.js.map +1 -0
  10. package/dist/build-info.generated.d.ts +3 -0
  11. package/dist/build-info.generated.d.ts.map +1 -0
  12. package/dist/build-info.generated.js +3 -0
  13. package/dist/build-info.generated.js.map +1 -0
  14. package/dist/capacity/resume-controller.d.ts +5 -3
  15. package/dist/capacity/resume-controller.d.ts.map +1 -1
  16. package/dist/capacity/resume-controller.js +9 -0
  17. package/dist/capacity/resume-controller.js.map +1 -1
  18. package/dist/chat-delegation-guard.d.ts +23 -0
  19. package/dist/chat-delegation-guard.d.ts.map +1 -0
  20. package/dist/chat-delegation-guard.js +62 -0
  21. package/dist/chat-delegation-guard.js.map +1 -0
  22. package/dist/chat-lane.d.ts +17 -6
  23. package/dist/chat-lane.d.ts.map +1 -1
  24. package/dist/chat-lane.js +29 -8
  25. package/dist/chat-lane.js.map +1 -1
  26. package/dist/companion-identity.d.ts +37 -0
  27. package/dist/companion-identity.d.ts.map +1 -1
  28. package/dist/companion-identity.js +98 -29
  29. package/dist/companion-identity.js.map +1 -1
  30. package/dist/companion-run.d.ts +1 -50
  31. package/dist/companion-run.d.ts.map +1 -1
  32. package/dist/companion-run.js +48 -105
  33. package/dist/companion-run.js.map +1 -1
  34. package/dist/config.d.ts +1 -0
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +14 -3
  37. package/dist/config.js.map +1 -1
  38. package/dist/conformance.d.ts +49 -0
  39. package/dist/conformance.d.ts.map +1 -0
  40. package/dist/conformance.js +121 -0
  41. package/dist/conformance.js.map +1 -0
  42. package/dist/cost-pricing.d.ts +91 -0
  43. package/dist/cost-pricing.d.ts.map +1 -1
  44. package/dist/cost-pricing.js +115 -1
  45. package/dist/cost-pricing.js.map +1 -1
  46. package/dist/credential-identity.d.ts +70 -0
  47. package/dist/credential-identity.d.ts.map +1 -0
  48. package/dist/credential-identity.js +128 -0
  49. package/dist/credential-identity.js.map +1 -0
  50. package/dist/doctor.d.ts +37 -1
  51. package/dist/doctor.d.ts.map +1 -1
  52. package/dist/doctor.js +300 -41
  53. package/dist/doctor.js.map +1 -1
  54. package/dist/engines/chat-stream.d.ts +71 -1
  55. package/dist/engines/chat-stream.d.ts.map +1 -1
  56. package/dist/engines/chat-stream.js +126 -8
  57. package/dist/engines/chat-stream.js.map +1 -1
  58. package/dist/engines/claude-code-chat.d.ts +11 -1
  59. package/dist/engines/claude-code-chat.d.ts.map +1 -1
  60. package/dist/engines/claude-code-chat.js +69 -5
  61. package/dist/engines/claude-code-chat.js.map +1 -1
  62. package/dist/engines/claude-code.d.ts.map +1 -1
  63. package/dist/engines/claude-code.js +30 -6
  64. package/dist/engines/claude-code.js.map +1 -1
  65. package/dist/engines/codex.d.ts.map +1 -1
  66. package/dist/engines/codex.js +18 -5
  67. package/dist/engines/codex.js.map +1 -1
  68. package/dist/engines/registry.d.ts +26 -0
  69. package/dist/engines/registry.d.ts.map +1 -1
  70. package/dist/engines/registry.js +37 -0
  71. package/dist/engines/registry.js.map +1 -1
  72. package/dist/engines/types.d.ts +5 -0
  73. package/dist/engines/types.d.ts.map +1 -1
  74. package/dist/failure-classifier.d.ts +1 -1
  75. package/dist/failure-classifier.d.ts.map +1 -1
  76. package/dist/failure-classifier.js +65 -12
  77. package/dist/failure-classifier.js.map +1 -1
  78. package/dist/gh.d.ts +15 -0
  79. package/dist/gh.d.ts.map +1 -1
  80. package/dist/gh.js +18 -0
  81. package/dist/gh.js.map +1 -1
  82. package/dist/git.d.ts +6 -0
  83. package/dist/git.d.ts.map +1 -1
  84. package/dist/git.js +27 -1
  85. package/dist/git.js.map +1 -1
  86. package/dist/instance.d.ts +30 -0
  87. package/dist/instance.d.ts.map +1 -0
  88. package/dist/instance.js +51 -0
  89. package/dist/instance.js.map +1 -0
  90. package/dist/keychain.d.ts +20 -7
  91. package/dist/keychain.d.ts.map +1 -1
  92. package/dist/keychain.js +40 -4
  93. package/dist/keychain.js.map +1 -1
  94. package/dist/loc-advisory.d.ts +70 -0
  95. package/dist/loc-advisory.d.ts.map +1 -0
  96. package/dist/loc-advisory.js +196 -0
  97. package/dist/loc-advisory.js.map +1 -0
  98. package/dist/locks/claim-lock-policy.d.ts +67 -0
  99. package/dist/locks/claim-lock-policy.d.ts.map +1 -0
  100. package/dist/locks/claim-lock-policy.js +132 -0
  101. package/dist/locks/claim-lock-policy.js.map +1 -0
  102. package/dist/login.d.ts +63 -2
  103. package/dist/login.d.ts.map +1 -1
  104. package/dist/login.js +93 -8
  105. package/dist/login.js.map +1 -1
  106. package/dist/mcp-spawn.d.ts +54 -0
  107. package/dist/mcp-spawn.d.ts.map +1 -1
  108. package/dist/mcp-spawn.js +88 -4
  109. package/dist/mcp-spawn.js.map +1 -1
  110. package/dist/memory-context.d.ts +106 -0
  111. package/dist/memory-context.d.ts.map +1 -0
  112. package/dist/memory-context.js +220 -0
  113. package/dist/memory-context.js.map +1 -0
  114. package/dist/memory-curation.d.ts +172 -0
  115. package/dist/memory-curation.d.ts.map +1 -0
  116. package/dist/memory-curation.js +277 -0
  117. package/dist/memory-curation.js.map +1 -0
  118. package/dist/migration-seq-guard.d.ts +28 -1
  119. package/dist/migration-seq-guard.d.ts.map +1 -1
  120. package/dist/migration-seq-guard.js +111 -6
  121. package/dist/migration-seq-guard.js.map +1 -1
  122. package/dist/output-diagnostics.d.ts +15 -0
  123. package/dist/output-diagnostics.d.ts.map +1 -0
  124. package/dist/output-diagnostics.js +95 -0
  125. package/dist/output-diagnostics.js.map +1 -0
  126. package/dist/pr-open-guard.d.ts +67 -0
  127. package/dist/pr-open-guard.d.ts.map +1 -0
  128. package/dist/pr-open-guard.js +79 -0
  129. package/dist/pr-open-guard.js.map +1 -0
  130. package/dist/prepr-gate.d.ts +229 -1
  131. package/dist/prepr-gate.d.ts.map +1 -1
  132. package/dist/prepr-gate.js +406 -3
  133. package/dist/prepr-gate.js.map +1 -1
  134. package/dist/program.d.ts.map +1 -1
  135. package/dist/program.js +43 -5
  136. package/dist/program.js.map +1 -1
  137. package/dist/provider-auth.d.ts +27 -0
  138. package/dist/provider-auth.d.ts.map +1 -1
  139. package/dist/provider-auth.js +33 -0
  140. package/dist/provider-auth.js.map +1 -1
  141. package/dist/runtime/cache-dir.d.ts.map +1 -1
  142. package/dist/runtime/cache-dir.js +8 -3
  143. package/dist/runtime/cache-dir.js.map +1 -1
  144. package/dist/runtime/decision-provenance.d.ts +63 -0
  145. package/dist/runtime/decision-provenance.d.ts.map +1 -0
  146. package/dist/runtime/decision-provenance.js +107 -0
  147. package/dist/runtime/decision-provenance.js.map +1 -0
  148. package/dist/runtime/prompt-substitute.d.ts +100 -0
  149. package/dist/runtime/prompt-substitute.d.ts.map +1 -0
  150. package/dist/runtime/prompt-substitute.js +124 -0
  151. package/dist/runtime/prompt-substitute.js.map +1 -0
  152. package/dist/runtime/quarantine.d.ts +11 -0
  153. package/dist/runtime/quarantine.d.ts.map +1 -1
  154. package/dist/runtime/quarantine.js +16 -1
  155. package/dist/runtime/quarantine.js.map +1 -1
  156. package/dist/runtime/review-cost-cap.d.ts +167 -0
  157. package/dist/runtime/review-cost-cap.d.ts.map +1 -0
  158. package/dist/runtime/review-cost-cap.js +182 -0
  159. package/dist/runtime/review-cost-cap.js.map +1 -0
  160. package/dist/runtime/reviewer-abstain.d.ts +101 -0
  161. package/dist/runtime/reviewer-abstain.d.ts.map +1 -0
  162. package/dist/runtime/reviewer-abstain.js +138 -0
  163. package/dist/runtime/reviewer-abstain.js.map +1 -0
  164. package/dist/runtime/reviewer.d.ts +165 -2
  165. package/dist/runtime/reviewer.d.ts.map +1 -1
  166. package/dist/runtime/reviewer.js +752 -111
  167. package/dist/runtime/reviewer.js.map +1 -1
  168. package/dist/runtime/running-build.d.ts +24 -0
  169. package/dist/runtime/running-build.d.ts.map +1 -0
  170. package/dist/runtime/running-build.js +62 -0
  171. package/dist/runtime/running-build.js.map +1 -0
  172. package/dist/runtime/singleton.d.ts.map +1 -1
  173. package/dist/runtime/singleton.js +9 -1
  174. package/dist/runtime/singleton.js.map +1 -1
  175. package/dist/runtime/worktree.d.ts +45 -0
  176. package/dist/runtime/worktree.d.ts.map +1 -1
  177. package/dist/runtime/worktree.js +174 -5
  178. package/dist/runtime/worktree.js.map +1 -1
  179. package/dist/serving-auth/index.d.ts +79 -0
  180. package/dist/serving-auth/index.d.ts.map +1 -0
  181. package/dist/serving-auth/index.js +169 -0
  182. package/dist/serving-auth/index.js.map +1 -0
  183. package/dist/task-runner.d.ts +134 -5
  184. package/dist/task-runner.d.ts.map +1 -1
  185. package/dist/task-runner.js +1255 -129
  186. package/dist/task-runner.js.map +1 -1
  187. package/dist/types.d.ts +8 -0
  188. package/dist/types.d.ts.map +1 -1
  189. package/dist/watch-chat/wire.d.ts +80 -0
  190. package/dist/watch-chat/wire.d.ts.map +1 -1
  191. package/dist/watch-chat/wire.js +203 -43
  192. package/dist/watch-chat/wire.js.map +1 -1
  193. package/dist/watch.d.ts +70 -16
  194. package/dist/watch.d.ts.map +1 -1
  195. package/dist/watch.js +548 -108
  196. package/dist/watch.js.map +1 -1
  197. package/package.json +4 -3
@@ -15,16 +15,43 @@
15
15
  * cron's poll-merge loop can advance.
16
16
  */
17
17
  import { execa } from "execa";
18
- import { priceUsdCents, normalizeUsage, modelFallbackChain, } from "../cost-pricing.js";
18
+ import { priceUsdCents, normalizeUsage, modelFallbackChain, toReviewerApiId, toReviewerCliAlias, } from "../cost-pricing.js";
19
19
  import { extractResetTime, isReviewerAuthFailure, isEnvBrokenReviewerProbe, } from "../failure-classifier.js";
20
20
  import { buildEnv } from "../bin-resolve.js";
21
- import { getEngine } from "../engines/registry.js";
21
+ import { engineIds, getEngine } from "../engines/registry.js";
22
+ import { resolveCodexSpawnMode } from "../engines/codex.js";
22
23
  import { ensureRepoClone } from "./clone-cache.js";
23
24
  import { prepareReviewWorktree, ReviewFetchError } from "./worktree.js";
24
25
  import { isEmptyReviewDiff } from "../review-diff-guard.js";
25
26
  import { diffFilePaths, splitDiffByFile } from "./review-diff.js";
26
27
  import { planReviewChunks, partitionAbsenceClaims, MAX_REVIEW_CHUNKS, REVIEWER_ABSENCE_ESCALATION_REASON, DISCARDED_CLAIM_PREFIX, } from "./review-chunking.js";
28
+ import { decisionProvenanceReason, reconcileReviewDecision, PROSE_KEYWORD_OVERRIDE_REASON, } from "./decision-provenance.js";
29
+ import { describePromptRenderBleed, findPromptRenderBleed, substitutePlaceholders, } from "./prompt-substitute.js";
30
+ import { abstainReasons, describeAbstainForLog, } from "./reviewer-abstain.js";
31
+ import { chargeReviewAttempt, classifyReviewCost, openReviewCostContext, reviewCostLedger, reviewCostOverrunReason, reviewCostRunawayReason, } from "./review-cost-cap.js";
27
32
  import { classifyTeardownFailure } from "../review-worktree-cleanup.js";
33
+ import { failureDiagnostics } from "../output-diagnostics.js";
34
+ /** Stable marker keying every reviewer tooling-failure reason (mirrors
35
+ * TOOLING_FAILURE_MARKER in api/_lib/review-retry.ts — the runner is a
36
+ * standalone package and cannot import from api/_lib). // keep in sync */
37
+ const REVIEWER_TOOLING_FAILURE_MARKER = "reviewer tooling failure:";
38
+ const REVIEWER_UNRESOLVABLE_ENGINE_REASON = `${REVIEWER_TOOLING_FAILURE_MARKER} assigned runner could not resolve the ` +
39
+ "task's agent engine. This is fleet configuration infra, not a review " +
40
+ "verdict; review budget untouched.";
41
+ /* The stamp above is for OPERATOR legibility, not for correctness. The server's
42
+ * MARKER classifier (isUnresolvableEngineReviewerOutcome in api/_lib/review-retry.ts)
43
+ * matches ONLY this marker — it deliberately does NOT free-text-match the raw
44
+ * `runtime_threw: unknown agent engine:` text, so a row from an un-upgraded runner
45
+ * is not exempted there. Instead the capability-reset in countReviewerErrors (keyed
46
+ * on LEGACY_UNRESOLVABLE_ENGINE_RAW) amnesties BOTH stamped and legacy rows — but
47
+ * only once the fleet regains the engine (engineServedByFleet). So an
48
+ * already-stranded PR recovers when a codex-capable runner comes online: recovery
49
+ * does not wait on THIS package being republished across the fleet, but it IS
50
+ * conditional on the engine being served again (while unserved the rows still
51
+ * count and strand — AC3). */
52
+ function isUnresolvableEngineError(err) {
53
+ return err?.message?.startsWith("unknown agent engine:") === true;
54
+ }
28
55
  // Byte-identical to prompts/reviewer-agent.md. The runner does not have
29
56
  // the markdown file at runtime (the package ships without prompts/) so
30
57
  // the prompt is embedded. Keep this string in sync with the markdown
@@ -74,6 +101,18 @@ export const REVIEWER_PROMPT_TEMPLATE = [
74
101
  " (security/tenant-isolation, build/typecheck, unsafe migration, secret,",
75
102
  " forbidden file, out-of-scope edits present in the diff).",
76
103
  "- Only approve if EVERY acceptance criterion is clearly met by the diff.",
104
+ "- **A LOC / line-count cap acceptance criterion is ADVISORY, not blocking (operator",
105
+ " decision D-7, 2026-09-05).** An acceptance criterion whose ONLY content is a numeric",
106
+ ' line-count / LOC / added-lines BUDGET (e.g. "implementation additions <= 200 LOC") is',
107
+ " a budget, not a behaviour. When the diff exceeds it, emit that overrun as its OWN",
108
+ " reason in the EXACT machine form `loc_cap: measured=<N> limit=<M>` (nothing else in",
109
+ " that reason), and approve when every OTHER acceptance criterion is met. Put every",
110
+ " OTHER finding in its OWN separate reason — a forbidden file in the diff, a named",
111
+ " acceptance criterion whose mechanism the diff does not implement, a defect the diff",
112
+ " introduces, scope creep, a PR body whose numbers contradict the diff — those ALL",
113
+ " still reject. NEVER fold a real finding into the `loc_cap:` reason: a reason that",
114
+ " mixes any other clause with the overrun is NOT the cap tag and still rejects. Only a",
115
+ " reason that is EXACTLY the `loc_cap:` tag is advisory.",
77
116
  "- Reject if the diff adds files outside the task's stated scope.",
78
117
  "- Reject if the diff makes changes beyond what the task asked for",
79
118
  " (refactors, drive-by cleanups, dependency bumps that weren't requested).",
@@ -156,6 +195,46 @@ export const REVIEWER_PROMPT_TEMPLATE = [
156
195
  " pointing at a specific part of the diff or the task spec. No prose",
157
196
  " paragraphs. No re-stating the task.",
158
197
  ].join("\n");
198
+ /**
199
+ * D-7 (operator decision, 2026-09-05): the reviewer emits a LOC/line-count cap overrun
200
+ * as a STRUCTURED, machine-tagged finding — a reason that is EXACTLY
201
+ * `loc_cap: measured=<N> limit=<M>` and nothing else — so a cap overrun is a typed
202
+ * object, not English the seam has to interpret. This matcher is a WHITELIST, not a
203
+ * blacklist: it flips only on that exact tag, anchored END TO END (^…$), so a reason
204
+ * that mixes any other clause — a defect, a forbidden file, a scope note, a body-vs-diff
205
+ * contradiction — cannot be the tag and stays a reject. Fail-CLOSED by construction:
206
+ * anything not positively proven the pure cap tag keeps the REJECT. A mixed finding is
207
+ * structurally impossible because the defect half is its OWN separate, untagged reason.
208
+ */
209
+ const LOC_CAP_TAG_RE = /^\s*loc_cap:\s*measured=(\d+)\s+limit=(\d+)[.\s]*$/i;
210
+ export function isLocCapAdvisoryReason(reason) {
211
+ return LOC_CAP_TAG_RE.test(reason ?? "");
212
+ }
213
+ /** Render the machine tag into an operator-legible advisory line (measured/limit kept
214
+ * visible), or return the reason unchanged when it is not a cap tag. */
215
+ export function renderLocCapAdvisory(reason) {
216
+ const m = LOC_CAP_TAG_RE.exec(reason ?? "");
217
+ return m
218
+ ? `LOC-cap advisory (D-7): implementation additions ${m[1]} exceed the ${m[2]}-line ` +
219
+ `budget — recorded, not blocking.`
220
+ : reason;
221
+ }
222
+ /**
223
+ * D-7: reduce a reviewer verdict under the cap-advisory rule. A `reject` whose EVERY
224
+ * substantive reason is the pure `loc_cap:` tag is not a rejection — it becomes an
225
+ * `approve` that STILL carries the measured overrun (rendered) in `reasons`. Any reason
226
+ * that is not exactly the tag — a defect, forbidden file, unmet AC, body mismatch, scope
227
+ * creep, or even a cap stated in free prose — keeps the reject (fail-closed). A
228
+ * non-reject verdict is returned untouched.
229
+ */
230
+ export function applyLocCapAdvisory(verdict) {
231
+ if (verdict.decision !== "reject")
232
+ return verdict;
233
+ const substantive = (verdict.reasons ?? []).filter((r) => r && r.trim());
234
+ if (substantive.length === 0 || !substantive.every(isLocCapAdvisoryReason))
235
+ return verdict;
236
+ return { ...verdict, decision: "approve", reasons: verdict.reasons.map(renderLocCapAdvisory) };
237
+ }
159
238
  /* ──────── RF-1 (NOT-IMPLEMENTED-CARVE-OUT): completion-signal scoping ────────
160
239
  *
161
240
  * The reviewer prompt's completion-negative rule used to fire on the WHOLE PR
@@ -437,9 +516,9 @@ export function truncationInfraNote(omitted, partialFile = null, reordered = fal
437
516
  const RETRY_DELAYS_MS = [2000, 4000, 8000];
438
517
  /* ───────────────── REVFB (G1): in-process capacity retry/backoff ────────────
439
518
  *
440
- * A `reviewer_capacity` outcome (Claude-subscription 429 / momentary brownout)
519
+ * A `reviewer_capacity` outcome (engine subscription 429 / momentary brownout)
441
520
  * is often transient. Rather than immediately yielding reviewer_capacity — which
442
- * pauses the WHOLE runner via watch.ts until a resume timer fires — we retry the
521
+ * pauses review claims via watch.ts until a resume timer fires — we retry the
443
522
  * claude spawn IN-PROCESS a bounded number of times with exponential backoff,
444
523
  * but ONLY for a brownout with no stated reset time. A hard session cap (which
445
524
  * carries a "resets <time>" reset instant) is yielded immediately, since there
@@ -516,7 +595,7 @@ export function isRetriableError(stderr) {
516
595
  function defaultSleep(ms) {
517
596
  return new Promise((resolve) => setTimeout(resolve, ms));
518
597
  }
519
- export function renderReviewerPrompt(args) {
598
+ export function renderReviewerPromptChecked(args) {
520
599
  // RF-3: ONE truncation policy — float the AC-named proof files to the front,
521
600
  // cut at a whole-file boundary, and name what was withheld. Under the cap
522
601
  // nothing reorders and no notice renders, so the prompt is byte-identical to
@@ -538,14 +617,41 @@ export function renderReviewerPrompt(args) {
538
617
  const acceptance = (args.acceptance_criteria ?? [])
539
618
  .map((a, i) => `${i + 1}. ${a}`)
540
619
  .join("\n");
541
- const rendered = REVIEWER_PROMPT_TEMPLATE
542
- .replace("{{task_description}}", args.task_description ?? "")
543
- .replace("{{acceptance_criteria}}", acceptance)
544
- .replace("{{pr_title}}", args.pr_title ?? "")
545
- .replace("{{pr_body}}", args.pr_body ?? "")
546
- .replace("{{pr_diff}}", diff);
620
+ // S15-FU/REVIEW-1 (AC1): substitute VERBATIM. `String.replace` with a string
621
+ // replacement expands `$$`/`$&`/`` $` ``/`$'`/`$n`, so a diff containing `$'`
622
+ // — e.g. the clean literal `... where seq ~ '^[0-9]+$' order by 1` — used to
623
+ // splice the template text FOLLOWING `{{pr_diff}}` ("Now answer in this exact
624
+ // JSON shape…") into the diff, and the reviewer then reported that prose as a
625
+ // corruption defect in the file (#1933). Never re-introduce a string
626
+ // replacement here; see ./prompt-substitute.ts.
627
+ const pairs = reviewerPlaceholderPairs(args, diff, acceptance);
628
+ const rendered = substitutePlaceholders(REVIEWER_PROMPT_TEMPLATE, pairs);
547
629
  const note = truncationInfraNote(omitted, partialFile, prioritized.reordered);
548
- return note.length > 0 ? `${rendered}\n\n${note}\n` : rendered;
630
+ const prompt = note.length > 0 ? `${rendered}\n\n${note}\n` : rendered;
631
+ return { prompt, bleed: findPromptRenderBleed(REVIEWER_PROMPT_TEMPLATE, prompt, pairs) };
632
+ }
633
+ /**
634
+ * S15-FU/REVIEW-1 (AC1): the ORDERED placeholder/value pairs a reviewer prompt
635
+ * substitutes, so the render-integrity check (and its test) can assert that every
636
+ * one of these values survived into the prompt VERBATIM.
637
+ * // keep in sync with reviewerPlaceholderPairs in api/_lib/reviewer-agent.ts
638
+ */
639
+ export function reviewerPlaceholderPairs(args, diff, acceptance) {
640
+ return [
641
+ ["{{task_description}}", args.task_description ?? ""],
642
+ ["{{acceptance_criteria}}", acceptance],
643
+ ["{{pr_title}}", args.pr_title ?? ""],
644
+ ["{{pr_body}}", args.pr_body ?? ""],
645
+ ["{{pr_diff}}", diff],
646
+ ];
647
+ }
648
+ /**
649
+ * String façade over `renderReviewerPromptChecked` — the signature every existing
650
+ * caller and the RF-3 parity tests use. Integrity is enforced at the review seam,
651
+ * which needs the `bleed` list to build its abstain.
652
+ */
653
+ export function renderReviewerPrompt(args) {
654
+ return renderReviewerPromptChecked(args).prompt;
549
655
  }
550
656
  /* ─────────── REVIEWER-DIFF-CAP: chunked review of an over-cap diff ───────────
551
657
  *
@@ -740,7 +846,18 @@ export function extractReviewerDecision(text) {
740
846
  "parsed from prose reviewer output (no JSON found)",
741
847
  `decision_keyword: ${decision}`,
742
848
  ];
743
- return { decision, reasons, confidence };
849
+ // REVIEW-DECISION-PROVENANCE: this verdict was INFERRED from a keyword, not
850
+ // asserted. PR #1913 persisted a reject this way over an all-"Met." analysis
851
+ // with no trace of the inference; runReview stamps the marker at submit time.
852
+ return {
853
+ decision,
854
+ reasons,
855
+ confidence,
856
+ decision_provenance: {
857
+ decision_source: "prose_keyword",
858
+ override_reason: PROSE_KEYWORD_OVERRIDE_REASON,
859
+ },
860
+ };
744
861
  }
745
862
  async function defaultFetchPRMeta(repo, prNumber) {
746
863
  const { stdout } = await execa("gh", ["pr", "view", String(prNumber), "-R", repo, "--json", "title,body"], { env: buildEnv() });
@@ -877,21 +994,126 @@ async function cleanupReviewTree(tree, supabase, assignment) {
877
994
  await emitReviewWorktreeOrphaned(supabase, assignment, tree.path ?? "", result);
878
995
  }
879
996
  }
880
- // M2 (ENGINE-EXTRACT): the coding engine the reviewer drives. Wave 1 fixed
881
- // this to a single module-level `claude-code` constant spawn/argv/result-
882
- // parse/failure-class behavior was byte-identical to the pre-refactor inline
883
- // logic now owned by src/engines/claude-code.ts. buildEnv() is the reviewer's
884
- // base env (mirrors v0.74-B): the engine forwards ANTHROPIC_API_KEY over it
885
- // when set.
997
+ // M2 (ENGINE-EXTRACT): the coding engine the reviewer drives. buildEnv() is the
998
+ // reviewer's base env (mirrors v0.74-B): the engine forwards provider auth over
999
+ // it when set.
886
1000
  //
887
- // NS MM-2 (BATCH-ENGINE-DISPATCH): the reviewer now resolves the engine PER
888
- // REVIEW from the reviewed task's `engine` column (`driveReview` below), so it
889
- // drives the same CLI the runner built the PR with instead of always assuming
890
- // claude-code. `getEngine(id)` defaults to claude-code on a null/blank id —
891
- // unstamped/pre-MM-1 rows are byte-identical to Wave 1.
1001
+ // CROSS-ENGINE-REVIEW-1: the reviewed task's engine is now only the AUTHORING
1002
+ // engine. The reviewer engine is selected from an org policy per review, with a
1003
+ // default that prefers a different provider family but advances to the next
1004
+ // local engine when the preferred provider is capacity-blocked.
892
1005
  function defaultSpawnClaude(modelId, engine) {
893
1006
  return engine.spawn({ modelId, baseEnv: buildEnv() });
894
1007
  }
1008
+ export const DEFAULT_REVIEWER_ENGINE_SELECTION_POLICY = "prefer_different_family";
1009
+ function engineFamily(engineId) {
1010
+ const id = engineId.trim().toLowerCase();
1011
+ if (id.startsWith("claude"))
1012
+ return "anthropic";
1013
+ if (id.startsWith("codex"))
1014
+ return "openai";
1015
+ if (id.startsWith("gemini"))
1016
+ return "google";
1017
+ return id;
1018
+ }
1019
+ export function resolveReviewerEngineSelectionPolicy(policy) {
1020
+ const raw = policy && typeof policy === "object"
1021
+ ? policy.reviewer_engine_policy
1022
+ : null;
1023
+ return raw === "prefer_same_family" || raw === "capacity_first" || raw === "prefer_different_family"
1024
+ ? raw
1025
+ : DEFAULT_REVIEWER_ENGINE_SELECTION_POLICY;
1026
+ }
1027
+ function uniqueEngineIds(ids) {
1028
+ const out = [];
1029
+ for (const id of ids) {
1030
+ const v = id.trim().toLowerCase();
1031
+ if (v && !out.includes(v))
1032
+ out.push(v);
1033
+ }
1034
+ return out;
1035
+ }
1036
+ export function selectReviewerEngineIds(authorEngine, policy, availableEngineIds = engineIds()) {
1037
+ const resolvedPolicy = resolveReviewerEngineSelectionPolicy(policy);
1038
+ const authorId = (authorEngine ?? "").trim().toLowerCase() || "claude-code";
1039
+ const authorFamily = engineFamily(authorId);
1040
+ const available = uniqueEngineIds(availableEngineIds);
1041
+ // FAIL CLOSED: an empty available set is a FAILURE (the runner advertised no
1042
+ // engine, or the capability probe failed), not a licence to route to the author
1043
+ // engine anyway — that would review on an engine the assigned runner may not be
1044
+ // able to spawn. Yield NO candidate; runReview then surfaces a no-route infra
1045
+ // error instead of dispatching blind.
1046
+ if (available.length === 0)
1047
+ return { engineIds: [], policy: resolvedPolicy };
1048
+ const same = available.filter((id) => engineFamily(id) === authorFamily);
1049
+ const different = available.filter((id) => engineFamily(id) !== authorFamily);
1050
+ const ordered = resolvedPolicy === "prefer_same_family"
1051
+ ? [...same, ...different]
1052
+ : resolvedPolicy === "capacity_first"
1053
+ ? available
1054
+ : [...different, ...same];
1055
+ return { engineIds: ordered.length > 0 ? ordered : available, policy: resolvedPolicy };
1056
+ }
1057
+ /**
1058
+ * CROSS-ENGINE-REVIEW-1 (AC10): map the org policy's single reviewer `model_id`
1059
+ * onto the engine actually chosen for THIS review. The policy historically
1060
+ * carries a Claude id (default 'claude-sonnet-4-6'). Cross-engine selection now
1061
+ * routes a review to codex even when the PR was authored by Claude — so that
1062
+ * Claude model id is meaningless to the reviewer engine and MUST NOT be pinned:
1063
+ *
1064
+ * - codex → run UNPINNED (model_id → `null`) unless an OpenAI-valid id is
1065
+ * explicitly configured on an api-key channel (a Claude id is never valid
1066
+ * for codex and is always dropped to `null`).
1067
+ * - claude-family → keep the existing catalog fallback chain unchanged.
1068
+ *
1069
+ * Returning `null` tells `engine.spawn` to omit `--model`, so codex never sees
1070
+ * `claude-sonnet-4-6` on argv.
1071
+ */
1072
+ export function reviewerModelChain(engine, policyModelId, modelChainMax) {
1073
+ const baseModel = policyModelId ?? "claude-sonnet-4-6";
1074
+ if (engine.id === "codex") {
1075
+ // ChatGPT-account Codex rejects every explicit --model, including valid
1076
+ // OpenAI ids; and a Claude model id can never drive codex. Both collapse to
1077
+ // a single unpinned attempt. An OpenAI-valid id on an api-key channel is the
1078
+ // only case that pins.
1079
+ const codexAuthMode = resolveCodexSpawnMode(buildEnv());
1080
+ return codexAuthMode === "api-key" && !baseModel.startsWith("claude-")
1081
+ ? [baseModel]
1082
+ : [null];
1083
+ }
1084
+ // #2169 namespace split: build the fallback chain from the CANONICAL API id so
1085
+ // a configured CLI alias ('sonnet') never lands in the recorded chain; the
1086
+ // spawn call CLI-alias-translates each entry with toReviewerCliAlias.
1087
+ return modelFallbackChain(toReviewerApiId(baseModel), modelChainMax);
1088
+ }
1089
+ /**
1090
+ * CROSS-ENGINE-REVIEW-1 (AC3): resolve the assigned runner's advertised
1091
+ * caps.engines. Prefers the explicitly-injected list (watch.ts threads the
1092
+ * runner's ALREADY-PUBLISHED `acc.runners.caps.engines` — the value the
1093
+ * assignment was made against), then an async `detectEngines` override, and only
1094
+ * as a last resort falls back to the local registry (`engineIds()`). The
1095
+ * production path always injects, so the reviewer only ever selects an engine
1096
+ * THIS runner can spawn, never a registry entry it cannot.
1097
+ *
1098
+ * FAIL CLOSED: if the `detectEngines` probe THROWS we return an EMPTY set, never
1099
+ * the full local registry. A failed capability probe is not licence to route to
1100
+ * an engine the assigned runner never advertised; the empty set makes
1101
+ * `selectReviewerEngineIds` yield no candidate, so the review surfaces a no-route
1102
+ * infra error rather than silently reviewing on an unadvertised engine.
1103
+ */
1104
+ async function resolveAdvertisedEngineIds(deps) {
1105
+ if (deps.advertisedEngineIds)
1106
+ return deps.advertisedEngineIds;
1107
+ if (deps.detectEngines) {
1108
+ try {
1109
+ return await deps.detectEngines();
1110
+ }
1111
+ catch {
1112
+ return [];
1113
+ }
1114
+ }
1115
+ return engineIds();
1116
+ }
895
1117
  function parseEnvelope(stdout, fallbackModel, engine) {
896
1118
  const parsed = engine.parseResult(stdout);
897
1119
  if (!parsed) {
@@ -920,8 +1142,8 @@ function parseEnvelope(stdout, fallbackModel, engine) {
920
1142
  export async function runReview(assignment, deps) {
921
1143
  const fetchMeta = deps.fetchPRMeta ?? defaultFetchPRMeta;
922
1144
  const prepareTree = deps.prepareReviewTree ?? defaultPrepareReviewTree;
923
- // NS MM-2: `spawnFn`'s default needs the REVIEWED task's `engine` column,
924
- // which driveReview fetches so its binding moves there instead of here.
1145
+ // CROSS-ENGINE-REVIEW-1: `spawnFn`'s default needs the selected REVIEWER
1146
+ // engine, which driveReview resolves from policy after it fetches the task.
925
1147
  // `deps.spawnClaude` (test override) is unaffected either way.
926
1148
  let outcome;
927
1149
  try {
@@ -937,9 +1159,12 @@ export async function runReview(assignment, deps) {
937
1159
  outcome = null;
938
1160
  }
939
1161
  else {
1162
+ const runtimeReason = `runtime_threw: ${err.message?.slice(0, 200)}`;
940
1163
  outcome = {
941
1164
  decision: "reviewer_error",
942
- reasons: [`runtime_threw: ${err.message?.slice(0, 200)}`],
1165
+ reasons: isUnresolvableEngineError(err)
1166
+ ? [REVIEWER_UNRESOLVABLE_ENGINE_REASON, runtimeReason]
1167
+ : [runtimeReason],
943
1168
  confidence: 0,
944
1169
  session_id: null,
945
1170
  cost_usd: 0,
@@ -959,32 +1184,258 @@ export async function runReview(assignment, deps) {
959
1184
  cost_usd: 0,
960
1185
  };
961
1186
  }
1187
+ // REVIEW-DECISION-PROVENANCE — the persistence seam. Two stamps, both purely
1188
+ // additive to `reasons` and neither touching the verdict:
1189
+ // 1. a SYNTHESIZED decision (prose fallback) renders its marker, so a
1190
+ // keyword-inferred verdict is never stored as if the analysis argued it;
1191
+ // 2. reconcile catches a reject whose own reasons record a clean full pass
1192
+ // and no override rationale (the PR #1913 shape) and flags it.
1193
+ // Applied here, immediately before the RPC, so EVERY path that reaches
1194
+ // submit_review — chunked, single-pass, capacity fallback — is covered.
1195
+ const provenanceNote = outcome.decision_provenance
1196
+ ? [decisionProvenanceReason(outcome.decision_provenance)]
1197
+ : [];
1198
+ const reconciled = reconcileReviewDecision({
1199
+ decision: outcome.decision,
1200
+ reasons: [...outcome.reasons, ...provenanceNote],
1201
+ });
1202
+ const submitted = { ...outcome, reasons: reconciled.reasons };
962
1203
  const { error } = await deps.supabase.rpc("submit_review", {
963
1204
  p_review_id: assignment.review_id,
964
- p_decision: outcome.decision,
965
- p_reasons: outcome.reasons,
966
- p_confidence: outcome.confidence,
967
- p_session_id: outcome.session_id,
968
- p_cost_usd: outcome.cost_usd,
1205
+ p_decision: submitted.decision,
1206
+ p_reasons: submitted.reasons,
1207
+ p_confidence: submitted.confidence,
1208
+ p_session_id: submitted.session_id,
1209
+ p_cost_usd: submitted.cost_usd,
969
1210
  });
970
1211
  if (error) {
971
1212
  process.stderr.write(`[acc-runner] submit_review(${assignment.review_id}) failed: ${error.message}\n`);
972
1213
  }
973
- return outcome;
1214
+ // REVIEWER-SPEND-TO-COST-PIPE: emit this completed review's spend into
1215
+ // acc.cost_events AT COMPLETION (best-effort), only when the review actually
1216
+ // completed (submit_review did not error). See recordReviewCost.
1217
+ if (!error) {
1218
+ await recordReviewCost(deps.supabase, assignment.task_id, submitted.cost_usd);
1219
+ }
1220
+ // RUNNER-DISCARDS-STDOUT-ON-FAILURE-1: submit_review is reasons-only (frozen
1221
+ // RPC), so preserve the captured child stdout/stderr tails of a genuine
1222
+ // reviewer_error through the additive log_activity seam — otherwise the real
1223
+ // failure is discarded and only the reason banner survives.
1224
+ if (submitted.decision === "reviewer_error" &&
1225
+ (submitted.stdout_tail || submitted.stderr_tail)) {
1226
+ await emitReviewerErrorDiagnostics(deps.supabase, assignment, submitted);
1227
+ }
1228
+ return submitted;
1229
+ }
1230
+ /**
1231
+ * RUNNER-DISCARDS-STDOUT-ON-FAILURE-1 — record the credential-scrubbed,
1232
+ * end-truncated child stdout/stderr tails of a reviewer_error on the additive
1233
+ * `task.reviewer_error` `log_activity` verb (no RPC-signature or protocol-shape
1234
+ * change). Best-effort: a logging failure must never turn a settled review row
1235
+ * into a crash.
1236
+ */
1237
+ async function emitReviewerErrorDiagnostics(supabase, assignment, outcome) {
1238
+ try {
1239
+ const { error } = await supabase.rpc("log_activity", {
1240
+ p_verb: "task.reviewer_error",
1241
+ p_target_id: assignment.task_id,
1242
+ p_target_type: "task",
1243
+ p_payload: {
1244
+ review_id: assignment.review_id,
1245
+ pr_number: assignment.pr_number,
1246
+ model_used: outcome.model_used ?? null,
1247
+ reasons: outcome.reasons,
1248
+ stdout_tail: outcome.stdout_tail ?? null,
1249
+ stderr_tail: outcome.stderr_tail ?? null,
1250
+ },
1251
+ });
1252
+ if (error) {
1253
+ process.stderr.write(`[acc-runner] task.reviewer_error log_activity(${assignment.task_id}) failed: ${error.message}\n`);
1254
+ }
1255
+ }
1256
+ catch (logErr) {
1257
+ process.stderr.write(`[acc-runner] task.reviewer_error emit failed: ${logErr.message}\n`);
1258
+ }
1259
+ }
1260
+ /** REVIEWER-SPEND-TO-COST-PIPE: the runner's thin mirror of the api-side
1261
+ * emitReviewCostEvent — a call site of the shared acc.record_cost_event RPC
1262
+ * (model='reviewer', zero-cost skipped, best-effort), NOT shared logic (the
1263
+ * runner cannot import api/_lib). Exactly-once HALTed per AC7. Returns the
1264
+ * outcome for the runner-package test (which root CI does NOT run — see PR body). */
1265
+ export async function recordReviewCost(supabase, taskId, costUsd) {
1266
+ const cents = Math.max(Math.round((costUsd ?? 0) * 100), 0);
1267
+ if (cents <= 0)
1268
+ return "skipped_zero";
1269
+ let res;
1270
+ try {
1271
+ res = await supabase.rpc("record_cost_event", {
1272
+ p_task_id: taskId,
1273
+ p_model: "reviewer",
1274
+ p_input_tokens: 0,
1275
+ p_output_tokens: 0,
1276
+ p_cache_read_tokens: 0,
1277
+ p_cache_write_tokens: 0,
1278
+ p_usd_cents: cents,
1279
+ p_runner_id: null,
1280
+ });
1281
+ }
1282
+ catch (err) {
1283
+ res = { error: { message: err.message } };
1284
+ }
1285
+ if (res?.error) {
1286
+ process.stderr.write(`[acc-runner] review cost-event(${taskId}) failed: ${res.error.message}\n`);
1287
+ return "error";
1288
+ }
1289
+ return "emitted";
974
1290
  }
975
1291
  async function invokeClaude(spawnFn, modelId, prompt) {
976
1292
  const spawnedAt = Date.now();
977
1293
  const child = spawnFn(modelId);
978
- if (child.stdin) {
979
- child.stdin.write(prompt);
980
- child.stdin.end();
1294
+ const stdoutP = collectStream(child.stdout);
1295
+ const stderrP = collectStream(child.stderr);
1296
+ const exitP = child;
1297
+ const delivery = await deliverReviewerPrompt(child.stdin, prompt);
1298
+ const [stdout, stderr, exit] = await Promise.all([stdoutP, stderrP, exitP]);
1299
+ const durationMs = Date.now() - spawnedAt;
1300
+ const exitCode = exit.exitCode ?? null;
1301
+ const signal = exit.signal ?? null;
1302
+ // Clean delivery — the child's own exit is authoritative.
1303
+ if (!delivery) {
1304
+ return { stdout, stderr, exitCode: exitCode ?? 1, durationMs };
1305
+ }
1306
+ // REVIEWER-EPIPE-MASKS-THE-REAL-CHILD-EXIT — STARTUP DEATH: the child never
1307
+ // accepted a byte (`bytesWritten === 0`) AND exited abnormally (non-zero or by
1308
+ // signal). Only bytes_written distinguishes this from a genuine delivery break:
1309
+ // a write that failed with NOTHING delivered means the child was already dead
1310
+ // before it read input, so its OWN exit code / signal / stderr are the
1311
+ // diagnosable cause and pass through here VERBATIM. Everything downstream that
1312
+ // reads them — engine.classifyFailure, isRetriableError, extractResetTime AND
1313
+ // the stderr_tail recorded by emitReviewerErrorDiagnostics — sees the real child
1314
+ // signal, so the capacity class and the infra-retry ladder are decided by the
1315
+ // child, never by a reason string (AC1/AC4). The failed write is the parent's
1316
+ // SYMPTOM only: recorded SEPARATELY in `deliverySymptom` (appended after the
1317
+ // child cause, never fed back into classification).
1318
+ //
1319
+ // A MID-WRITE break (`bytesWritten > 0`) is NOT a startup death — the child WAS
1320
+ // reading and the pipe broke mid-stream, so the review ran on partial input and
1321
+ // the DELIVERY failure is the reported cause (falls through below). Branching on
1322
+ // exitCode/signal ALONE mislabelled that shape as a startup death — the exact
1323
+ // misattribution this card removes.
1324
+ if (delivery.bytesWritten === 0 && (exitCode !== 0 || signal !== null)) {
1325
+ // AC4 INVARIANT: the prompt was never delivered (bytesWritten === 0), so this
1326
+ // result MUST reach runClaudeAndDecide as a FAILURE whatever the child's exit
1327
+ // code says. A child killed by a signal can still report exitCode 0; passing
1328
+ // that 0 through (`?? 1` did) makes runClaudeAndDecide take the exitCode === 0
1329
+ // path and finalizeReviewerSuccess on an undelivered prompt. `|| 1` forces a
1330
+ // non-zero code (0/null → 1) while preserving a real non-zero exit for the
1331
+ // retry ladder; the child's TRUE exit/signal are named in deliverySymptom.
1332
+ return {
1333
+ stdout,
1334
+ stderr,
1335
+ exitCode: exitCode || 1,
1336
+ durationMs,
1337
+ deliverySymptom: reviewerStartupDeathSymptom(delivery, { exitCode, signal }),
1338
+ };
1339
+ }
1340
+ // The prompt never fully landed while the child was NOT dead-before-reading —
1341
+ // it exited 0 on no/partial input, or a MID-WRITE break (bytesWritten > 0) gave
1342
+ // it partial input. Its "success" is not a real verdict, so the delivery write
1343
+ // is the reported cause; exitCode is forced non-zero so an exit-0 partial run is
1344
+ // not mistaken for a verdict. The marker is PREPENDED (not appended) so it is the
1345
+ // first-useful stderr line failureDiagnostics reports — surfacing in `reasons`
1346
+ // (budget-exempt) even when the child had its OWN stderr, while that child stderr
1347
+ // is still carried through for stderr_tail + classification. Prepending also keeps
1348
+ // the stderr non-empty, so an exit-0 empty child is never misread as a silent
1349
+ // capacity exit.
1350
+ return {
1351
+ stdout,
1352
+ stderr: [reviewerPromptDeliveryFailure(delivery), stderr].filter(Boolean).join("\n"),
1353
+ exitCode: 1,
1354
+ durationMs,
1355
+ };
1356
+ }
1357
+ /**
1358
+ * REVIEWER-EPIPE — a prompt-delivery write failure when the child itself exited
1359
+ * CLEANLY (exit 0). The review ran on no / partial input, so its "success" is not a
1360
+ * real verdict and THIS is the one shape where the delivery write is the reported
1361
+ * cause. Worded off bytes_written so a 0-byte failure never contradicts itself by
1362
+ * claiming a "mid-write" break (the stdin-unavailable and bytes_written=0 cases now
1363
+ * read "before any byte reached the child", not "mid-write ... bytes_written=0").
1364
+ */
1365
+ function reviewerPromptDeliveryFailure(f) {
1366
+ const phase = f.bytesWritten > 0
1367
+ ? `mid-write (${f.bytesWritten} of ${f.promptBytes} bytes delivered)`
1368
+ : "before any byte reached the child";
1369
+ return (`${REVIEWER_TOOLING_FAILURE_MARKER} reviewer prompt delivery failed ${phase} ` +
1370
+ `(mechanism=stdin_pipe, prompt_bytes=${f.promptBytes}, bytes_written=${f.bytesWritten}). ` +
1371
+ `${f.detail.slice(0, 160)}. Review budget untouched (not a verdict).`);
1372
+ }
1373
+ /**
1374
+ * REVIEWER-EPIPE-MASKS-THE-REAL-CHILD-EXIT — the child EXITED ABNORMALLY before it
1375
+ * finished reading the prompt. Its OWN exit code / signal / stderr are the
1376
+ * diagnosable cause and are passed through UNTOUCHED by invokeClaude (so the
1377
+ * classifier, the retry ladder and the verbatim stderr_tail all see the real child
1378
+ * signal). This line records the parent's failed stdin write SEPARATELY and
1379
+ * explicitly as the SYMPTOM that first surfaced the death — never in place of the
1380
+ * child stderr. It is appended to the outcome reasons AFTER the child cause and
1381
+ * carries the tooling-failure marker so the row stays budget-exempt (a startup
1382
+ * death is infra, not a verdict); it is NEVER fed back into classification.
1383
+ *
1384
+ * Scope of the class this names: the subset of "exited 1" reviewer_errors that are
1385
+ * EPIPE-masked startup deaths. Rows that already carry the child's own stderr are
1386
+ * NOT touched here. How many rows this newly names is a POST-DEPLOY measurement,
1387
+ * carried by the follow-up card (D-1) — no count is asserted here.
1388
+ */
1389
+ function reviewerStartupDeathSymptom(f, exit) {
1390
+ return (`${REVIEWER_TOOLING_FAILURE_MARKER} reviewer child died at startup ` +
1391
+ `(exit=${exit.exitCode ?? "null"}, signal=${exit.signal ?? "none"}) — the cause is the ` +
1392
+ `child's own exit/stderr recorded above (verbatim in stderr_tail); the parent stdin write ` +
1393
+ `is only the symptom (bytes_written=${f.bytesWritten}/${f.promptBytes}: ${f.detail.slice(0, 160)}). ` +
1394
+ `Review budget untouched (not a verdict).`);
1395
+ }
1396
+ async function deliverReviewerPrompt(stdin, prompt) {
1397
+ const promptBytes = Buffer.byteLength(prompt);
1398
+ if (!stdin) {
1399
+ return { detail: "child stdin pipe was unavailable", promptBytes, bytesWritten: 0 };
1400
+ }
1401
+ let bytesWritten = 0;
1402
+ try {
1403
+ await new Promise((resolve, reject) => {
1404
+ const onError = (err) => {
1405
+ stdin.off("error", onError);
1406
+ reject(err);
1407
+ };
1408
+ stdin.once("error", onError);
1409
+ stdin.write(prompt, (err) => {
1410
+ stdin.off("error", onError);
1411
+ if (err)
1412
+ reject(err);
1413
+ else {
1414
+ bytesWritten = promptBytes;
1415
+ resolve();
1416
+ }
1417
+ });
1418
+ });
1419
+ await new Promise((resolve, reject) => {
1420
+ const onError = (err) => {
1421
+ stdin.off("error", onError);
1422
+ reject(err);
1423
+ };
1424
+ stdin.once("error", onError);
1425
+ stdin.end(() => {
1426
+ stdin.off("error", onError);
1427
+ resolve();
1428
+ });
1429
+ });
1430
+ return null;
1431
+ }
1432
+ catch (err) {
1433
+ return {
1434
+ detail: err.message || String(err),
1435
+ promptBytes,
1436
+ bytesWritten,
1437
+ };
981
1438
  }
982
- const [stdout, stderr, exit] = await Promise.all([
983
- collectStream(child.stdout),
984
- collectStream(child.stderr),
985
- child,
986
- ]);
987
- return { stdout, stderr, exitCode: exit.exitCode ?? 1, durationMs: Date.now() - spawnedAt };
988
1439
  }
989
1440
  async function driveReview(assignment, deps, fetchMeta, prepareTree) {
990
1441
  const { data, error } = await deps.supabase.rpc("fetch_task_for_runner", {
@@ -1011,12 +1462,6 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1011
1462
  cost_usd: 0,
1012
1463
  };
1013
1464
  }
1014
- // NS MM-2 (BATCH-ENGINE-DISPATCH): resolve the reviewed task's engine ONCE,
1015
- // right after the task itself is known — mirrors task-runner.ts's
1016
- // per-task resolution. `getEngine(null)` (an unstamped/pre-MM-1 row)
1017
- // resolves the same claude-code default Wave 1 always used.
1018
- const engine = getEngine(task.engine ?? null);
1019
- const spawnFn = deps.spawnClaude ?? ((modelId) => defaultSpawnClaude(modelId, engine));
1020
1465
  // Look up the org's reviewer policy. The runner-side reviewer needs
1021
1466
  // confidence_threshold + max_cost_usd_per_review to stay consistent
1022
1467
  // with the cron-side gate, since the cron now just lifts the decision
@@ -1024,6 +1469,13 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1024
1469
  const policy = deps.loadPolicy
1025
1470
  ? await deps.loadPolicy("")
1026
1471
  : await loadReviewerPolicy(deps.supabase);
1472
+ // CROSS-ENGINE-REVIEW-1 (AC3): constrain reviewer-engine selection to the
1473
+ // engines THIS (the assigned) runner actually advertises in caps.engines —
1474
+ // NOT the full local registry. `resolveAdvertisedEngineIds` prefers an
1475
+ // explicitly-injected list and otherwise re-runs the same capability probe
1476
+ // the runner published at register time.
1477
+ const advertisedEngineIds = await resolveAdvertisedEngineIds(deps);
1478
+ const reviewerEngineSelection = selectReviewerEngineIds(task.engine ?? null, policy, advertisedEngineIds);
1027
1479
  const meta = await fetchMeta(repo, assignment.pr_number);
1028
1480
  // R0-D-REVIEWER-WORKSPACE-INTEGRITY: materialize the EXACT PR head before
1029
1481
  // forming any verdict, or fail closed. On failure we post NO verdict and emit
@@ -1083,13 +1535,86 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1083
1535
  throw err;
1084
1536
  }
1085
1537
  try {
1086
- return await produceVerdict({
1538
+ let lastCapacity = null;
1539
+ // The engine that ACTUALLY produced the returned outcome. After a capacity
1540
+ // fallback this is NOT engineIds[0] — recording the first candidate would make
1541
+ // every same-vs-cross-family row in the AC4 measurement wrong whenever a
1542
+ // fallback fired (and fallbacks are common). Set to each engine as it is tried,
1543
+ // so it names the winning engine on success and the last-tried on a whole-chain
1544
+ // capacity cap.
1545
+ let reviewerEngineId = null;
1546
+ let outcome = null;
1547
+ const input = {
1087
1548
  task_description: task.description ?? "",
1088
1549
  acceptance_criteria: task.acceptance ?? [],
1089
1550
  pr_title: meta.title,
1090
1551
  pr_body: meta.body,
1091
1552
  pr_diff: tree.diff,
1092
- }, policy, deps, spawnFn, engine);
1553
+ };
1554
+ for (let i = 0; i < reviewerEngineSelection.engineIds.length; i++) {
1555
+ const engineId = reviewerEngineSelection.engineIds[i];
1556
+ // CAPS-ENGINES-STALE-KEYCHAIN-1 (AC12): the reviewer only selects an
1557
+ // ADVERTISED engine, so a getEngine throw here is a caps-vs-registry SELF-
1558
+ // INCONSISTENCY. Drop it from caps (onUnknownAdvertisedEngine) — AC12's
1559
+ // "dropped from caps immediately" — THEN re-throw, so upstream's #2129
1560
+ // (REVIEWER-INFRA-ERROR-BURNS-BUDGET) still stamps it as a budget-exempt
1561
+ // tooling-failure `reviewer_error` that recovers (ns04-mm2 / riebb). Mirrors
1562
+ // the task-runner guard: AC12 side-effect + upstream's outcome, both kept.
1563
+ let engine;
1564
+ try {
1565
+ engine = getEngine(engineId);
1566
+ }
1567
+ catch (err) {
1568
+ await deps.onUnknownAdvertisedEngine?.(engineId, err);
1569
+ throw err;
1570
+ }
1571
+ const spawnFn = deps.spawnClaude
1572
+ ? ((modelId) => deps.spawnClaude(modelId))
1573
+ : ((modelId) => defaultSpawnClaude(modelId, engine));
1574
+ const out = await produceVerdict(input, policy, deps, spawnFn, engine);
1575
+ reviewerEngineId = engineId; // the engine this outcome actually came from
1576
+ if (out.decision !== "reviewer_capacity") {
1577
+ outcome = lastCapacity ? { ...out, capacity_degraded: true } : out;
1578
+ break;
1579
+ }
1580
+ lastCapacity = out;
1581
+ }
1582
+ if (outcome === null) {
1583
+ outcome = lastCapacity ?? {
1584
+ decision: "reviewer_error",
1585
+ reasons: ["no reviewer engine resolved from policy/runner caps.engines"],
1586
+ confidence: 0,
1587
+ session_id: null,
1588
+ cost_usd: 0,
1589
+ };
1590
+ }
1591
+ // AC4 + AC8: record WHICH engine reviewed (submit_review is frozen reasons-only;
1592
+ // task.review_submitted carries no engine), so same-vs-cross-family outcomes are
1593
+ // computable. Emitted AFTER the decision comes back, keyed on the engine that
1594
+ // ACTUALLY produced it (`reviewerEngineId`) — the routing OUTCOME, never the
1595
+ // first candidate at selection time. Additive log_activity seam, fire-and-
1596
+ // forget. docs/acc/CROSS_ENGINE_REVIEW_MEASUREMENT.md.
1597
+ const authorEngineId = (task.engine ?? "claude-code").trim().toLowerCase();
1598
+ void deps.supabase
1599
+ .rpc("log_activity", {
1600
+ p_verb: "review.engine_selected",
1601
+ p_target_id: assignment.task_id,
1602
+ p_target_type: "task",
1603
+ p_payload: {
1604
+ pr_number: assignment.pr_number,
1605
+ review_id: assignment.review_id,
1606
+ policy: reviewerEngineSelection.policy,
1607
+ author_engine: authorEngineId,
1608
+ author_family: engineFamily(authorEngineId),
1609
+ reviewer_engine: reviewerEngineId,
1610
+ reviewer_family: engineFamily(reviewerEngineId ?? ""),
1611
+ cross_family: engineFamily(reviewerEngineId ?? "") !== engineFamily(authorEngineId),
1612
+ candidate_engines: reviewerEngineSelection.engineIds,
1613
+ advertised_engines: advertisedEngineIds,
1614
+ },
1615
+ })
1616
+ .then?.(undefined, () => undefined);
1617
+ return outcome;
1093
1618
  }
1094
1619
  finally {
1095
1620
  // Remove the dedicated review worktree so it can never become a stale
@@ -1111,9 +1636,34 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1111
1636
  * settles as an INFRA outcome the escalation path owns — never a reject.
1112
1637
  */
1113
1638
  async function produceVerdict(input, policy, deps, spawnFn, engine) {
1639
+ // REV-COST: ONE cost context per review — the SIZE-SCALED cap plus the
1640
+ // attempt ledger every pass charges into. Derived from the ASSEMBLED diff this
1641
+ // function already holds, so no caller has to change. At or under
1642
+ // REVIEW_CAP_REFERENCE_DIFF_CHARS the cap is EXACTLY the org's flat
1643
+ // `max_cost_usd_per_review` (an ordinary PR is judged against today's number);
1644
+ // above it the cap ramps, bounded by REVIEW_CAP_MAX_MULTIPLE.
1645
+ const costCtx = openReviewCostContext(policy?.max_cost_usd_per_review ?? 0, input.pr_diff.length);
1114
1646
  const chunks = planReviewChunks(input.pr_diff, MAX_DIFF_CHARS);
1115
1647
  if (chunks.length <= 1) {
1116
- return runClaudeAndDecide(renderReviewerPrompt(input), policy, deps, spawnFn, engine);
1648
+ // S15-FU/REVIEW-1 (AC1 + AC2): render, then PROVE the prompt carries the PR
1649
+ // content verbatim. If any value did not survive substitution, the reviewer
1650
+ // would be judging text the repo does not contain (the #1933 corruption
1651
+ // class) — so ABSTAIN before spawning anything. Never a reject, never a cost.
1652
+ const render = renderReviewerPromptChecked(input);
1653
+ if (render.bleed.length > 0) {
1654
+ const detail = describePromptRenderBleed(render.bleed);
1655
+ process.stderr.write(describeAbstainForLog("prompt_render", detail));
1656
+ return {
1657
+ decision: "reviewer_error",
1658
+ reasons: abstainReasons("prompt_render", [
1659
+ `rendered reviewer prompt did not carry PR content verbatim: ${detail}`,
1660
+ ]),
1661
+ confidence: 0,
1662
+ session_id: null,
1663
+ cost_usd: 0,
1664
+ };
1665
+ }
1666
+ return stampReviewCost(await runClaudeAndDecide(render.prompt, policy, deps, spawnFn, engine, costCtx), costCtx);
1117
1667
  }
1118
1668
  if (chunks.length > MAX_REVIEW_CHUNKS) {
1119
1669
  return {
@@ -1130,7 +1680,7 @@ async function produceVerdict(input, policy, deps, spawnFn, engine) {
1130
1680
  cost_usd: 0,
1131
1681
  };
1132
1682
  }
1133
- return runChunkedReview(input, chunks, policy, deps, spawnFn, engine);
1683
+ return runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx);
1134
1684
  }
1135
1685
  /**
1136
1686
  * REVIEWER-DIFF-CAP — read an over-cap diff in `chunks.length` part passes, then
@@ -1143,13 +1693,13 @@ async function produceVerdict(input, policy, deps, spawnFn, engine) {
1143
1693
  * env-broken — short-circuits the whole review with the spend accumulated so far,
1144
1694
  * so an infra fault never degrades into a partial-evidence verdict.
1145
1695
  */
1146
- async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine) {
1147
- // The cap is checked ONCE against the whole chunked spend below; per-pass it is
1148
- // disabled so a single part is never judged against the whole-review budget.
1149
- const passPolicy = policy ? { ...policy, max_cost_usd_per_review: 0 } : null;
1150
- const cap = policy?.max_cost_usd_per_review ?? 0;
1696
+ async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx) {
1697
+ // REV-COST: the cap is judged ONCE against the WHOLE chunked spend, because
1698
+ // the shared ledger in `costCtx` accumulates across every part pass. That
1699
+ // replaces the old cap-0 `passPolicy` clone: a single part is still never
1700
+ // judged against the whole-review budget, and the whole review is no longer
1701
+ // judged against a FLAT cap.
1151
1702
  const passes = [];
1152
- let cost = 0;
1153
1703
  let degraded = false;
1154
1704
  let modelUsed;
1155
1705
  for (const chunk of chunks) {
@@ -1157,53 +1707,80 @@ async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine) {
1157
1707
  // a probe for every chunk. Only the first pass carries it.
1158
1708
  const passDeps = chunk.index === 1 ? deps : { ...deps, authCanary: undefined };
1159
1709
  const prompt = `${renderReviewerPrompt({ ...input, pr_diff: chunk.text })}\n\n${chunkScopeNotice(chunk)}\n`;
1160
- const out = await runClaudeAndDecide(prompt, passPolicy, passDeps, spawnFn, engine);
1161
- cost += out.cost_usd;
1710
+ // The RUNAWAY brake lives in finalizeReviewerSuccess, right where the spend
1711
+ // is charged, so a part pass that pushes the WHOLE-review total past the
1712
+ // tolerance short-circuits here as a non-verdict outcome and the remaining
1713
+ // passes never run. An overrun INSIDE the band deliberately does not stop the
1714
+ // review: it finishes and the paid verdict is kept (see the tail).
1715
+ const out = await runClaudeAndDecide(prompt, policy, passDeps, spawnFn, engine, costCtx);
1716
+ const partLedger = reviewCostLedger(costCtx);
1162
1717
  modelUsed = out.model_used ?? modelUsed;
1163
1718
  degraded = degraded || out.capacity_degraded === true;
1164
1719
  if (out.decision !== "approve" && out.decision !== "reject") {
1165
- return { ...out, cost_usd: cost, capacity_degraded: degraded ? true : out.capacity_degraded };
1720
+ return {
1721
+ ...out,
1722
+ cost_usd: partLedger.total_usd,
1723
+ cost_cap: costCtx.cap,
1724
+ cost_breakdown: partLedger,
1725
+ capacity_degraded: degraded ? true : out.capacity_degraded,
1726
+ };
1166
1727
  }
1167
- if (cap > 0 && cost > cap)
1168
- return overChunkedCostCap(cost, cap, modelUsed);
1169
1728
  passes.push({ decision: out.decision, reasons: out.reasons, confidence: out.confidence });
1170
1729
  }
1171
1730
  const allPaths = diffFilePaths(input.pr_diff);
1172
1731
  const evidence = renderSynthesisEvidence(chunks, passes, allPaths);
1173
1732
  const synthPrompt = `${renderReviewerPrompt({ ...input, pr_diff: evidence })}\n\n${synthesisScopeNotice(chunks.length)}\n`;
1174
- const final = await runClaudeAndDecide(synthPrompt, passPolicy, { ...deps, authCanary: undefined }, spawnFn, engine);
1175
- cost += final.cost_usd;
1733
+ const final = await runClaudeAndDecide(synthPrompt, policy, { ...deps, authCanary: undefined }, spawnFn, engine, costCtx);
1734
+ const ledger = reviewCostLedger(costCtx);
1176
1735
  modelUsed = final.model_used ?? modelUsed;
1177
1736
  degraded = degraded || final.capacity_degraded === true;
1178
1737
  if (final.decision !== "approve" && final.decision !== "reject") {
1179
- return { ...final, cost_usd: cost, capacity_degraded: degraded ? true : final.capacity_degraded };
1738
+ return {
1739
+ ...final,
1740
+ cost_usd: ledger.total_usd,
1741
+ cost_cap: costCtx.cap,
1742
+ cost_breakdown: ledger,
1743
+ capacity_degraded: degraded ? true : final.capacity_degraded,
1744
+ };
1180
1745
  }
1181
- if (cap > 0 && cost > cap)
1182
- return overChunkedCostCap(cost, cap, modelUsed);
1183
1746
  // Guard FIRST (so a coverage claim can never survive as a verdict), then stamp
1184
1747
  // the coverage telemetry — which must never itself be partitioned as a claim.
1185
1748
  const guarded = guardCoverageClaims({
1186
1749
  ...final,
1187
- cost_usd: cost,
1750
+ cost_usd: ledger.total_usd,
1188
1751
  model_used: modelUsed,
1189
1752
  capacity_degraded: degraded ? true : undefined,
1190
1753
  });
1754
+ // REV-COST: the overrun note (when the paid spend landed in the tolerance
1755
+ // band) sits between the findings and the coverage telemetry — a real reason,
1756
+ // so it must precede chunkedCoverageReason to keep THAT last.
1757
+ const stamped = stampReviewCost(guarded, costCtx);
1191
1758
  return {
1192
- ...guarded,
1193
- reasons: [...guarded.reasons, chunkedCoverageReason(chunks.length, allPaths.length, input.pr_diff.length)],
1759
+ ...stamped,
1760
+ reasons: [...stamped.reasons, chunkedCoverageReason(chunks.length, allPaths.length, input.pr_diff.length)],
1194
1761
  };
1195
1762
  }
1196
- /** Cost-cap parity for the chunked path: the cap applies to the WHOLE review. */
1197
- function overChunkedCostCap(cost, cap, modelUsed) {
1763
+ /**
1764
+ * REV-COST — stamp the cost ledger onto a FINISHED review outcome and, when
1765
+ * the chargeable spend landed in the OVERRUN band (over the effective cap, under
1766
+ * the runaway threshold), keep the verdict and add the advisory note.
1767
+ *
1768
+ * Called exactly once per review, after the verdict is assembled — never on a
1769
+ * chunked PART pass, so a budget note can never leak into the synthesis evidence
1770
+ * and be re-read as a finding.
1771
+ */
1772
+ function stampReviewCost(out, costCtx) {
1773
+ const ledger = reviewCostLedger(costCtx);
1774
+ const isVerdict = out.decision === "approve" || out.decision === "reject";
1775
+ const overrun = isVerdict && classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "overrun";
1198
1776
  return {
1199
- decision: "cost_cap_exceeded",
1200
- reasons: [
1201
- `reviewer cost $${cost.toFixed(4)} exceeded cap $${cap.toFixed(2)} across the chunked review`,
1202
- ],
1203
- confidence: 0,
1204
- session_id: null,
1205
- cost_usd: cost,
1206
- model_used: modelUsed,
1777
+ ...out,
1778
+ reasons: overrun
1779
+ ? [...out.reasons, reviewCostOverrunReason(costCtx.cap, ledger)]
1780
+ : out.reasons,
1781
+ cost_cap: costCtx.cap,
1782
+ cost_breakdown: ledger,
1783
+ ...(overrun ? { cost_overrun: true } : {}),
1207
1784
  };
1208
1785
  }
1209
1786
  /**
@@ -1261,57 +1838,90 @@ async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacit
1261
1838
  /** RVU-2: a budget-exempt env-broken (auth) reviewer_error. Decision stays
1262
1839
  * `reviewer_error` (frozen); the marker reason makes the shared cron treat it
1263
1840
  * as budget-exempt and alert once. */
1264
- function envBrokenOutcome(modelId, detail) {
1841
+ function envBrokenOutcome(modelId, detail, ledger) {
1265
1842
  return {
1266
1843
  decision: "reviewer_error",
1267
1844
  reasons: [REVIEWER_ENV_BROKEN_REASON, `detail: ${(detail ?? "").slice(0, 160)}`],
1268
1845
  confidence: 0,
1846
+ // REV-COST: an auth fault costs nothing and is charged to INFRA, never to
1847
+ // the per-review budget — the review still needs its productive attempt.
1848
+ cost_usd: ledger?.total_usd ?? 0,
1269
1849
  session_id: null,
1270
- cost_usd: 0,
1271
1850
  model_used: modelId,
1272
1851
  env_broken: true,
1852
+ ...(ledger ? { cost_breakdown: ledger } : {}),
1273
1853
  };
1274
1854
  }
1275
1855
  /** RVU-2: assemble the outcome from a clean (exit 0) reviewer envelope. Cost-cap
1276
- * parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run. */
1277
- function finalizeReviewerSuccess(stdout, modelId, degraded, policy, engine) {
1856
+ * parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run.
1857
+ * REV-COST: the cap is now the size-scaled decision carried in `costCtx`, and
1858
+ * only the RUNAWAY band refuses the verdict here. */
1859
+ function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
1278
1860
  const envelope = parseEnvelope(stdout, modelId, engine);
1279
- // Cost cap parity with api/_lib/reviewer-agent.ts. A cap of 0 disables it.
1280
- if (policy &&
1281
- policy.max_cost_usd_per_review > 0 &&
1282
- envelope.cost_usd > policy.max_cost_usd_per_review) {
1861
+ // REV-COST: the spawn returned a readable envelope, so this is the review's
1862
+ // PRODUCTIVE attempt — the only class charged to the per-review cap. Parity
1863
+ // with api/_lib/reviewer-agent.ts, which charges at exactly this point.
1864
+ const ledger = chargeReviewAttempt(costCtx, modelId, "productive", envelope.cost_usd);
1865
+ // REV-COST RUNAWAY BACKSTOP. Past `cap × REVIEW_COST_OVERRUN_TOLERANCE`
1866
+ // something is pathological, so the verdict is still refused. Inside the band
1867
+ // the spend is judged AFTER the verdict is assembled (stampReviewCost): the
1868
+ // money is already gone, and discarding a paid verdict only stranded the PR
1869
+ // (#1937 — $18 spent, $8 flat cap, NO verdict) and re-charged the same review
1870
+ // on the next tick. A cap of 0 disables the whole check, as before.
1871
+ if (classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "runaway") {
1283
1872
  return {
1284
1873
  decision: "cost_cap_exceeded",
1285
- reasons: [
1286
- `reviewer cost $${envelope.cost_usd.toFixed(4)} exceeded cap $${policy.max_cost_usd_per_review.toFixed(2)}`,
1287
- ],
1874
+ reasons: [reviewCostRunawayReason(costCtx.cap, ledger)],
1288
1875
  confidence: 0,
1289
1876
  session_id: envelope.session_id,
1290
- cost_usd: envelope.cost_usd,
1877
+ cost_usd: ledger.total_usd,
1291
1878
  model_used: modelId,
1879
+ cost_cap: costCtx.cap,
1880
+ cost_breakdown: ledger,
1292
1881
  };
1293
1882
  }
1294
1883
  const decision = extractReviewerDecision(envelope.result);
1295
1884
  if (!decision) {
1885
+ // S15-FU/REVIEW-1 (AC2): a PARSE FAULT is an explicit ABSTAIN. The unparsed
1886
+ // output head is carried as QUARANTINED context (never a finding) so an
1887
+ // operator can diagnose the format drift without any of that text being
1888
+ // scored as a defect.
1889
+ process.stderr.write(describeAbstainForLog("verdict_parse"));
1296
1890
  return {
1297
1891
  decision: "reviewer_error",
1298
- reasons: [
1892
+ reasons: abstainReasons("verdict_parse", [
1299
1893
  "could not parse reviewer JSON from claude output",
1300
1894
  `output_head: ${envelope.result.slice(0, 200)}`,
1301
- ],
1895
+ ]),
1302
1896
  confidence: 0,
1303
1897
  session_id: envelope.session_id,
1304
- cost_usd: envelope.cost_usd,
1898
+ cost_usd: ledger.total_usd,
1305
1899
  model_used: modelId,
1900
+ cost_cap: costCtx.cap,
1901
+ cost_breakdown: ledger,
1306
1902
  };
1307
1903
  }
1308
- return {
1904
+ // D-7 (2026-09-05): a reject whose ONLY reasons are pure LOC-cap advisories is
1905
+ // not a rejection — flip to approve, keeping the measured overrun in `reasons`.
1906
+ // The prompt already instructs the model to do this; the guard makes the operator
1907
+ // decision deterministic. Conservative: a single non-cap reason keeps the reject,
1908
+ // so the reviewer's bias-to-reject is untouched for every other finding.
1909
+ const adjudicated = applyLocCapAdvisory({
1309
1910
  decision: decision.decision,
1310
1911
  reasons: decision.reasons,
1912
+ });
1913
+ return {
1914
+ decision: adjudicated.decision,
1915
+ reasons: adjudicated.reasons,
1311
1916
  confidence: decision.confidence,
1312
1917
  session_id: envelope.session_id,
1313
1918
  cost_usd: envelope.cost_usd,
1314
1919
  model_used: modelId,
1920
+ // REVIEW-DECISION-PROVENANCE: carried as a field (never a reason) so it
1921
+ // survives the coverage/absence guards without being read as a finding.
1922
+ ...(decision.decision_provenance
1923
+ ? { decision_provenance: decision.decision_provenance }
1924
+ : {}),
1315
1925
  // Signal capacity pressure to watch.ts only when the review actually
1316
1926
  // re-routed to a lower tier (leave undefined on the primary-model path).
1317
1927
  capacity_degraded: degraded ? true : undefined,
@@ -1326,12 +1936,17 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, policy, engine) {
1326
1936
  * explicit broken CLI-auth signal surfaces as budget-exempt env_broken rather
1327
1937
  * than looping. Cost-cap / parse behaviour is unchanged.
1328
1938
  */
1329
- async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
1939
+ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx) {
1330
1940
  const sleepFn = deps.sleep ?? defaultSleep;
1331
- const baseModel = policy?.model_id ?? "claude-sonnet-4-6";
1332
1941
  const maxCapacityRetries = resolveCapacityRetries(process.env[CAPACITY_RETRIES_ENV]);
1333
- const chain = modelFallbackChain(baseModel, deps.modelChainMax ?? DEFAULT_MODEL_CHAIN_MAX);
1334
- const primaryModel = chain[0] ?? baseModel;
1942
+ // CROSS-ENGINE-REVIEW-1 (AC10): map policy.model_id onto the SELECTED reviewer
1943
+ // engine. For codex this drops a Claude id (e.g. 'claude-sonnet-4-6') to an
1944
+ // unpinned run so the reviewer engine never receives a foreign model id;
1945
+ // Claude keeps its catalog fallback chain (built from the CANONICAL API id via
1946
+ // toReviewerApiId — the #2169 namespace split — and CLI-alias-translated only
1947
+ // at the spawn call inside the loop). See `reviewerModelChain`.
1948
+ const chain = reviewerModelChain(engine, policy?.model_id, deps.modelChainMax ?? DEFAULT_MODEL_CHAIN_MAX);
1949
+ const primaryModel = chain[0] ?? `${engine.id}-default`;
1335
1950
  // RVU-2: optional env-broken CLI-auth PREFLIGHT canary. A broken login /
1336
1951
  // rejected key is caught BEFORE spawning the reviewer against a dead
1337
1952
  // credential, so it never loops as a spurious reviewer_error.
@@ -1344,18 +1959,23 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
1344
1959
  probes = [];
1345
1960
  }
1346
1961
  if (isEnvBrokenReviewerProbe(probes)) {
1347
- return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)");
1962
+ return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)", chargeReviewAttempt(costCtx, primaryModel, "infra_auth", 0));
1348
1963
  }
1349
1964
  }
1350
1965
  let lastCapacityDetail = "reviewer capacity exhausted across all fallback tiers";
1351
1966
  let lastResumeMs = null;
1352
1967
  for (let i = 0; i < chain.length; i++) {
1353
- const modelId = chain[i];
1968
+ // `modelId` is the CANONICAL id recorded on every outcome / cost row below.
1969
+ const modelId = chain[i] ?? `${engine.id}-default`;
1970
+ // Alias→CLI translation is one-way, for the spawn call ONLY — never recorded.
1971
+ // Codex passes its own id (or null, its unpinned default) straight through;
1972
+ // Claude spawns the CLI alias derived from the canonical id.
1973
+ const spawnModelId = chain[i] === null || engine.id === "codex" ? chain[i] : toReviewerCliAlias(modelId);
1354
1974
  const isLast = i === chain.length - 1;
1355
- const invokeResult = await invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries, engine);
1356
- const { stdout, exitCode, stderr: claudeStderr, durationMs } = invokeResult;
1975
+ const invokeResult = await invokeReviewerModel(spawnFn, spawnModelId, prompt, sleepFn, maxCapacityRetries, engine);
1976
+ const { stdout, exitCode, stderr: claudeStderr, durationMs, deliverySymptom } = invokeResult;
1357
1977
  if (exitCode === 0) {
1358
- return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, policy, engine);
1978
+ return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, costCtx, engine);
1359
1979
  }
1360
1980
  const classified = engine.classifyFailure(exitCode, claudeStderr, stdout, durationMs);
1361
1981
  const combined = `${claudeStderr}\n${stdout}`;
@@ -1367,7 +1987,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
1367
1987
  if (classified.class === "auth_expired" ||
1368
1988
  classified.class === "env_broken" ||
1369
1989
  isReviewerAuthFailure(combined)) {
1370
- return envBrokenOutcome(modelId, classified.detail);
1990
+ return envBrokenOutcome(modelId, classified.detail, chargeReviewAttempt(costCtx, modelId, "infra_auth", 0));
1371
1991
  }
1372
1992
  const isCapacity = classified.class === "capacity_exhausted" ||
1373
1993
  classified.class === "claude_unavailable";
@@ -1376,6 +1996,10 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
1376
1996
  const resumeMs = extractResetTime(combined);
1377
1997
  if (resumeMs !== null)
1378
1998
  lastResumeMs = resumeMs;
1999
+ // REV-COST: a 429/overload brownout is INFRA. Recording it makes "N
2000
+ // attempts on one review" visible WITHOUT any of those attempts consuming
2001
+ // the per-review budget the productive attempt needs.
2002
+ const capacityLedger = chargeReviewAttempt(costCtx, modelId, "infra_capacity", 0);
1379
2003
  // Re-route to the next lower catalog tier instead of stalling the lane.
1380
2004
  if (!isLast)
1381
2005
  continue;
@@ -1389,22 +2013,39 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine) {
1389
2013
  session_id: null,
1390
2014
  cost_usd: 0,
1391
2015
  resume_at: lastResumeMs !== null ? new Date(lastResumeMs).toISOString() : null,
2016
+ engine: engine.id,
1392
2017
  model_used: modelId,
1393
2018
  capacity_degraded: true,
2019
+ cost_cap: costCtx.cap,
2020
+ cost_breakdown: capacityLedger,
1394
2021
  };
1395
2022
  }
1396
2023
  // Genuine error (task_error / usage_limit) — legacy reviewer_error, no
1397
2024
  // re-route (a lower tier would just repeat the failure/cost).
2025
+ const diagnostics = failureDiagnostics({
2026
+ exitCode,
2027
+ stdout,
2028
+ stderr: claudeStderr,
2029
+ detail: classified.detail,
2030
+ label: engine.id,
2031
+ });
2032
+ // REVIEWER-EPIPE-MASKS-THE-REAL-CHILD-EXIT — the child cause is already named by
2033
+ // `diagnostics` (its first-useful stderr line) and its FULL stderr is preserved
2034
+ // verbatim in stderr_tail. When a startup-death write symptom was recorded, append
2035
+ // it AFTER the child cause: it names the masked exit/signal, labels the parent
2036
+ // write a symptom, and carries the tooling marker so the row is budget-exempt.
2037
+ const reasons = deliverySymptom
2038
+ ? [...diagnostics.reasons, deliverySymptom]
2039
+ : diagnostics.reasons;
1398
2040
  return {
1399
2041
  decision: "reviewer_error",
1400
- reasons: [
1401
- `claude exited ${exitCode}`,
1402
- ...(claudeStderr ? [`stderr: ${claudeStderr.slice(0, 200)}`] : []),
1403
- ],
2042
+ reasons,
1404
2043
  confidence: 0,
1405
2044
  session_id: null,
1406
2045
  cost_usd: 0,
1407
2046
  model_used: modelId,
2047
+ stdout_tail: diagnostics.stdoutTail,
2048
+ stderr_tail: diagnostics.stderrTail,
1408
2049
  };
1409
2050
  }
1410
2051
  // Unreachable in practice (chain always has ≥1 entry): a blank policy model