@tokenfactory/acc-runner 0.44.1 → 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 (159) hide show
  1. package/README.md +20 -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 +46 -0
  43. package/dist/cost-pricing.d.ts.map +1 -1
  44. package/dist/cost-pricing.js +58 -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 +33 -1
  51. package/dist/doctor.d.ts.map +1 -1
  52. package/dist/doctor.js +284 -44
  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/keychain.d.ts +17 -0
  87. package/dist/keychain.d.ts.map +1 -1
  88. package/dist/keychain.js +30 -0
  89. package/dist/keychain.js.map +1 -1
  90. package/dist/loc-advisory.d.ts +70 -0
  91. package/dist/loc-advisory.d.ts.map +1 -0
  92. package/dist/loc-advisory.js +196 -0
  93. package/dist/loc-advisory.js.map +1 -0
  94. package/dist/locks/claim-lock-policy.d.ts +67 -0
  95. package/dist/locks/claim-lock-policy.d.ts.map +1 -0
  96. package/dist/locks/claim-lock-policy.js +132 -0
  97. package/dist/locks/claim-lock-policy.js.map +1 -0
  98. package/dist/login.d.ts +59 -2
  99. package/dist/login.d.ts.map +1 -1
  100. package/dist/login.js +87 -7
  101. package/dist/login.js.map +1 -1
  102. package/dist/mcp-spawn.d.ts +54 -0
  103. package/dist/mcp-spawn.d.ts.map +1 -1
  104. package/dist/mcp-spawn.js +88 -4
  105. package/dist/mcp-spawn.js.map +1 -1
  106. package/dist/migration-seq-guard.d.ts +28 -1
  107. package/dist/migration-seq-guard.d.ts.map +1 -1
  108. package/dist/migration-seq-guard.js +111 -6
  109. package/dist/migration-seq-guard.js.map +1 -1
  110. package/dist/output-diagnostics.d.ts +15 -0
  111. package/dist/output-diagnostics.d.ts.map +1 -0
  112. package/dist/output-diagnostics.js +95 -0
  113. package/dist/output-diagnostics.js.map +1 -0
  114. package/dist/pr-open-guard.d.ts +67 -0
  115. package/dist/pr-open-guard.d.ts.map +1 -0
  116. package/dist/pr-open-guard.js +79 -0
  117. package/dist/pr-open-guard.js.map +1 -0
  118. package/dist/prepr-gate.d.ts +229 -1
  119. package/dist/prepr-gate.d.ts.map +1 -1
  120. package/dist/prepr-gate.js +406 -3
  121. package/dist/prepr-gate.js.map +1 -1
  122. package/dist/program.d.ts.map +1 -1
  123. package/dist/program.js +30 -5
  124. package/dist/program.js.map +1 -1
  125. package/dist/provider-auth.d.ts +8 -0
  126. package/dist/provider-auth.d.ts.map +1 -1
  127. package/dist/provider-auth.js +10 -0
  128. package/dist/provider-auth.js.map +1 -1
  129. package/dist/runtime/quarantine.d.ts +11 -0
  130. package/dist/runtime/quarantine.d.ts.map +1 -1
  131. package/dist/runtime/quarantine.js +16 -1
  132. package/dist/runtime/quarantine.js.map +1 -1
  133. package/dist/runtime/reviewer.d.ts +90 -2
  134. package/dist/runtime/reviewer.d.ts.map +1 -1
  135. package/dist/runtime/reviewer.js +547 -46
  136. package/dist/runtime/reviewer.js.map +1 -1
  137. package/dist/runtime/running-build.d.ts +24 -0
  138. package/dist/runtime/running-build.d.ts.map +1 -0
  139. package/dist/runtime/running-build.js +62 -0
  140. package/dist/runtime/running-build.js.map +1 -0
  141. package/dist/runtime/worktree.d.ts +45 -0
  142. package/dist/runtime/worktree.d.ts.map +1 -1
  143. package/dist/runtime/worktree.js +174 -5
  144. package/dist/runtime/worktree.js.map +1 -1
  145. package/dist/task-runner.d.ts +126 -5
  146. package/dist/task-runner.d.ts.map +1 -1
  147. package/dist/task-runner.js +1219 -127
  148. package/dist/task-runner.js.map +1 -1
  149. package/dist/types.d.ts +8 -0
  150. package/dist/types.d.ts.map +1 -1
  151. package/dist/watch-chat/wire.d.ts +80 -0
  152. package/dist/watch-chat/wire.d.ts.map +1 -1
  153. package/dist/watch-chat/wire.js +203 -43
  154. package/dist/watch-chat/wire.js.map +1 -1
  155. package/dist/watch.d.ts +70 -16
  156. package/dist/watch.d.ts.map +1 -1
  157. package/dist/watch.js +501 -89
  158. package/dist/watch.js.map +1 -1
  159. package/package.json +2 -2
@@ -15,10 +15,11 @@
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";
@@ -29,6 +30,28 @@ import { describePromptRenderBleed, findPromptRenderBleed, substitutePlaceholder
29
30
  import { abstainReasons, describeAbstainForLog, } from "./reviewer-abstain.js";
30
31
  import { chargeReviewAttempt, classifyReviewCost, openReviewCostContext, reviewCostLedger, reviewCostOverrunReason, reviewCostRunawayReason, } from "./review-cost-cap.js";
31
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
+ }
32
55
  // Byte-identical to prompts/reviewer-agent.md. The runner does not have
33
56
  // the markdown file at runtime (the package ships without prompts/) so
34
57
  // the prompt is embedded. Keep this string in sync with the markdown
@@ -78,6 +101,18 @@ export const REVIEWER_PROMPT_TEMPLATE = [
78
101
  " (security/tenant-isolation, build/typecheck, unsafe migration, secret,",
79
102
  " forbidden file, out-of-scope edits present in the diff).",
80
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.",
81
116
  "- Reject if the diff adds files outside the task's stated scope.",
82
117
  "- Reject if the diff makes changes beyond what the task asked for",
83
118
  " (refactors, drive-by cleanups, dependency bumps that weren't requested).",
@@ -160,6 +195,46 @@ export const REVIEWER_PROMPT_TEMPLATE = [
160
195
  " pointing at a specific part of the diff or the task spec. No prose",
161
196
  " paragraphs. No re-stating the task.",
162
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
+ }
163
238
  /* ──────── RF-1 (NOT-IMPLEMENTED-CARVE-OUT): completion-signal scoping ────────
164
239
  *
165
240
  * The reviewer prompt's completion-negative rule used to fire on the WHOLE PR
@@ -441,9 +516,9 @@ export function truncationInfraNote(omitted, partialFile = null, reordered = fal
441
516
  const RETRY_DELAYS_MS = [2000, 4000, 8000];
442
517
  /* ───────────────── REVFB (G1): in-process capacity retry/backoff ────────────
443
518
  *
444
- * A `reviewer_capacity` outcome (Claude-subscription 429 / momentary brownout)
519
+ * A `reviewer_capacity` outcome (engine subscription 429 / momentary brownout)
445
520
  * is often transient. Rather than immediately yielding reviewer_capacity — which
446
- * 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
447
522
  * claude spawn IN-PROCESS a bounded number of times with exponential backoff,
448
523
  * but ONLY for a brownout with no stated reset time. A hard session cap (which
449
524
  * carries a "resets <time>" reset instant) is yielded immediately, since there
@@ -919,21 +994,126 @@ async function cleanupReviewTree(tree, supabase, assignment) {
919
994
  await emitReviewWorktreeOrphaned(supabase, assignment, tree.path ?? "", result);
920
995
  }
921
996
  }
922
- // M2 (ENGINE-EXTRACT): the coding engine the reviewer drives. Wave 1 fixed
923
- // this to a single module-level `claude-code` constant spawn/argv/result-
924
- // parse/failure-class behavior was byte-identical to the pre-refactor inline
925
- // logic now owned by src/engines/claude-code.ts. buildEnv() is the reviewer's
926
- // base env (mirrors v0.74-B): the engine forwards ANTHROPIC_API_KEY over it
927
- // 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.
928
1000
  //
929
- // NS MM-2 (BATCH-ENGINE-DISPATCH): the reviewer now resolves the engine PER
930
- // REVIEW from the reviewed task's `engine` column (`driveReview` below), so it
931
- // drives the same CLI the runner built the PR with instead of always assuming
932
- // claude-code. `getEngine(id)` defaults to claude-code on a null/blank id —
933
- // 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.
934
1005
  function defaultSpawnClaude(modelId, engine) {
935
1006
  return engine.spawn({ modelId, baseEnv: buildEnv() });
936
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
+ }
937
1117
  function parseEnvelope(stdout, fallbackModel, engine) {
938
1118
  const parsed = engine.parseResult(stdout);
939
1119
  if (!parsed) {
@@ -962,8 +1142,8 @@ function parseEnvelope(stdout, fallbackModel, engine) {
962
1142
  export async function runReview(assignment, deps) {
963
1143
  const fetchMeta = deps.fetchPRMeta ?? defaultFetchPRMeta;
964
1144
  const prepareTree = deps.prepareReviewTree ?? defaultPrepareReviewTree;
965
- // NS MM-2: `spawnFn`'s default needs the REVIEWED task's `engine` column,
966
- // 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.
967
1147
  // `deps.spawnClaude` (test override) is unaffected either way.
968
1148
  let outcome;
969
1149
  try {
@@ -979,9 +1159,12 @@ export async function runReview(assignment, deps) {
979
1159
  outcome = null;
980
1160
  }
981
1161
  else {
1162
+ const runtimeReason = `runtime_threw: ${err.message?.slice(0, 200)}`;
982
1163
  outcome = {
983
1164
  decision: "reviewer_error",
984
- reasons: [`runtime_threw: ${err.message?.slice(0, 200)}`],
1165
+ reasons: isUnresolvableEngineError(err)
1166
+ ? [REVIEWER_UNRESOLVABLE_ENGINE_REASON, runtimeReason]
1167
+ : [runtimeReason],
985
1168
  confidence: 0,
986
1169
  session_id: null,
987
1170
  cost_usd: 0,
@@ -1028,21 +1211,231 @@ export async function runReview(assignment, deps) {
1028
1211
  if (error) {
1029
1212
  process.stderr.write(`[acc-runner] submit_review(${assignment.review_id}) failed: ${error.message}\n`);
1030
1213
  }
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
+ }
1031
1228
  return submitted;
1032
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";
1290
+ }
1033
1291
  async function invokeClaude(spawnFn, modelId, prompt) {
1034
1292
  const spawnedAt = Date.now();
1035
1293
  const child = spawnFn(modelId);
1036
- if (child.stdin) {
1037
- child.stdin.write(prompt);
1038
- 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
+ };
1039
1438
  }
1040
- const [stdout, stderr, exit] = await Promise.all([
1041
- collectStream(child.stdout),
1042
- collectStream(child.stderr),
1043
- child,
1044
- ]);
1045
- return { stdout, stderr, exitCode: exit.exitCode ?? 1, durationMs: Date.now() - spawnedAt };
1046
1439
  }
1047
1440
  async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1048
1441
  const { data, error } = await deps.supabase.rpc("fetch_task_for_runner", {
@@ -1069,12 +1462,6 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1069
1462
  cost_usd: 0,
1070
1463
  };
1071
1464
  }
1072
- // NS MM-2 (BATCH-ENGINE-DISPATCH): resolve the reviewed task's engine ONCE,
1073
- // right after the task itself is known — mirrors task-runner.ts's
1074
- // per-task resolution. `getEngine(null)` (an unstamped/pre-MM-1 row)
1075
- // resolves the same claude-code default Wave 1 always used.
1076
- const engine = getEngine(task.engine ?? null);
1077
- const spawnFn = deps.spawnClaude ?? ((modelId) => defaultSpawnClaude(modelId, engine));
1078
1465
  // Look up the org's reviewer policy. The runner-side reviewer needs
1079
1466
  // confidence_threshold + max_cost_usd_per_review to stay consistent
1080
1467
  // with the cron-side gate, since the cron now just lifts the decision
@@ -1082,6 +1469,13 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1082
1469
  const policy = deps.loadPolicy
1083
1470
  ? await deps.loadPolicy("")
1084
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);
1085
1479
  const meta = await fetchMeta(repo, assignment.pr_number);
1086
1480
  // R0-D-REVIEWER-WORKSPACE-INTEGRITY: materialize the EXACT PR head before
1087
1481
  // forming any verdict, or fail closed. On failure we post NO verdict and emit
@@ -1141,13 +1535,86 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
1141
1535
  throw err;
1142
1536
  }
1143
1537
  try {
1144
- 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 = {
1145
1548
  task_description: task.description ?? "",
1146
1549
  acceptance_criteria: task.acceptance ?? [],
1147
1550
  pr_title: meta.title,
1148
1551
  pr_body: meta.body,
1149
1552
  pr_diff: tree.diff,
1150
- }, 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;
1151
1618
  }
1152
1619
  finally {
1153
1620
  // Remove the dedicated review worktree so it can never become a stale
@@ -1434,9 +1901,18 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
1434
1901
  cost_breakdown: ledger,
1435
1902
  };
1436
1903
  }
1437
- 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({
1438
1910
  decision: decision.decision,
1439
1911
  reasons: decision.reasons,
1912
+ });
1913
+ return {
1914
+ decision: adjudicated.decision,
1915
+ reasons: adjudicated.reasons,
1440
1916
  confidence: decision.confidence,
1441
1917
  session_id: envelope.session_id,
1442
1918
  cost_usd: envelope.cost_usd,
@@ -1462,10 +1938,15 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
1462
1938
  */
1463
1939
  async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx) {
1464
1940
  const sleepFn = deps.sleep ?? defaultSleep;
1465
- const baseModel = policy?.model_id ?? "claude-sonnet-4-6";
1466
1941
  const maxCapacityRetries = resolveCapacityRetries(process.env[CAPACITY_RETRIES_ENV]);
1467
- const chain = modelFallbackChain(baseModel, deps.modelChainMax ?? DEFAULT_MODEL_CHAIN_MAX);
1468
- 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`;
1469
1950
  // RVU-2: optional env-broken CLI-auth PREFLIGHT canary. A broken login /
1470
1951
  // rejected key is caught BEFORE spawning the reviewer against a dead
1471
1952
  // credential, so it never loops as a spurious reviewer_error.
@@ -1484,10 +1965,15 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx
1484
1965
  let lastCapacityDetail = "reviewer capacity exhausted across all fallback tiers";
1485
1966
  let lastResumeMs = null;
1486
1967
  for (let i = 0; i < chain.length; i++) {
1487
- 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);
1488
1974
  const isLast = i === chain.length - 1;
1489
- const invokeResult = await invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries, engine);
1490
- 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;
1491
1977
  if (exitCode === 0) {
1492
1978
  return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, costCtx, engine);
1493
1979
  }
@@ -1527,6 +2013,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx
1527
2013
  session_id: null,
1528
2014
  cost_usd: 0,
1529
2015
  resume_at: lastResumeMs !== null ? new Date(lastResumeMs).toISOString() : null,
2016
+ engine: engine.id,
1530
2017
  model_used: modelId,
1531
2018
  capacity_degraded: true,
1532
2019
  cost_cap: costCtx.cap,
@@ -1535,16 +2022,30 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx
1535
2022
  }
1536
2023
  // Genuine error (task_error / usage_limit) — legacy reviewer_error, no
1537
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;
1538
2040
  return {
1539
2041
  decision: "reviewer_error",
1540
- reasons: [
1541
- `claude exited ${exitCode}`,
1542
- ...(claudeStderr ? [`stderr: ${claudeStderr.slice(0, 200)}`] : []),
1543
- ],
2042
+ reasons,
1544
2043
  confidence: 0,
1545
2044
  session_id: null,
1546
2045
  cost_usd: 0,
1547
2046
  model_used: modelId,
2047
+ stdout_tail: diagnostics.stdoutTail,
2048
+ stderr_tail: diagnostics.stderrTail,
1548
2049
  };
1549
2050
  }
1550
2051
  // Unreachable in practice (chain always has ≥1 entry): a blank policy model