@tokenfactory/acc-runner 0.43.0 → 0.44.1

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 (110) hide show
  1. package/README.md +75 -3
  2. package/dist/companion-run.d.ts.map +1 -1
  3. package/dist/companion-run.js +20 -0
  4. package/dist/companion-run.js.map +1 -1
  5. package/dist/config.d.ts +72 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +117 -3
  8. package/dist/config.js.map +1 -1
  9. package/dist/cost-pricing.d.ts +45 -0
  10. package/dist/cost-pricing.d.ts.map +1 -1
  11. package/dist/cost-pricing.js +57 -0
  12. package/dist/cost-pricing.js.map +1 -1
  13. package/dist/doctor.d.ts +4 -0
  14. package/dist/doctor.d.ts.map +1 -1
  15. package/dist/doctor.js +32 -3
  16. package/dist/doctor.js.map +1 -1
  17. package/dist/engines/registry.d.ts +17 -6
  18. package/dist/engines/registry.d.ts.map +1 -1
  19. package/dist/engines/registry.js +38 -13
  20. package/dist/engines/registry.js.map +1 -1
  21. package/dist/git-auth/github-token-source.d.ts +54 -0
  22. package/dist/git-auth/github-token-source.d.ts.map +1 -0
  23. package/dist/git-auth/github-token-source.js +44 -0
  24. package/dist/git-auth/github-token-source.js.map +1 -0
  25. package/dist/git-auth/index.d.ts +4 -0
  26. package/dist/git-auth/index.d.ts.map +1 -1
  27. package/dist/git-auth/index.js +4 -0
  28. package/dist/git-auth/index.js.map +1 -1
  29. package/dist/git-auth/installation-token.d.ts +95 -0
  30. package/dist/git-auth/installation-token.d.ts.map +1 -0
  31. package/dist/git-auth/installation-token.js +192 -0
  32. package/dist/git-auth/installation-token.js.map +1 -0
  33. package/dist/instance.d.ts +30 -0
  34. package/dist/instance.d.ts.map +1 -0
  35. package/dist/instance.js +51 -0
  36. package/dist/instance.js.map +1 -0
  37. package/dist/keychain.d.ts +4 -8
  38. package/dist/keychain.d.ts.map +1 -1
  39. package/dist/keychain.js +10 -4
  40. package/dist/keychain.js.map +1 -1
  41. package/dist/local-repo/command.d.ts +28 -0
  42. package/dist/local-repo/command.d.ts.map +1 -0
  43. package/dist/local-repo/command.js +178 -0
  44. package/dist/local-repo/command.js.map +1 -0
  45. package/dist/local-repo/index.d.ts +119 -0
  46. package/dist/local-repo/index.d.ts.map +1 -0
  47. package/dist/local-repo/index.js +141 -0
  48. package/dist/local-repo/index.js.map +1 -0
  49. package/dist/login.d.ts +4 -0
  50. package/dist/login.d.ts.map +1 -1
  51. package/dist/login.js +6 -1
  52. package/dist/login.js.map +1 -1
  53. package/dist/mcp-spawn.d.ts +19 -0
  54. package/dist/mcp-spawn.d.ts.map +1 -1
  55. package/dist/mcp-spawn.js +52 -26
  56. package/dist/mcp-spawn.js.map +1 -1
  57. package/dist/memory-context.d.ts +106 -0
  58. package/dist/memory-context.d.ts.map +1 -0
  59. package/dist/memory-context.js +220 -0
  60. package/dist/memory-context.js.map +1 -0
  61. package/dist/memory-curation.d.ts +172 -0
  62. package/dist/memory-curation.d.ts.map +1 -0
  63. package/dist/memory-curation.js +277 -0
  64. package/dist/memory-curation.js.map +1 -0
  65. package/dist/program.d.ts.map +1 -1
  66. package/dist/program.js +34 -0
  67. package/dist/program.js.map +1 -1
  68. package/dist/provider-auth.d.ts +70 -1
  69. package/dist/provider-auth.d.ts.map +1 -1
  70. package/dist/provider-auth.js +106 -2
  71. package/dist/provider-auth.js.map +1 -1
  72. package/dist/runtime/cache-dir.d.ts.map +1 -1
  73. package/dist/runtime/cache-dir.js +8 -3
  74. package/dist/runtime/cache-dir.js.map +1 -1
  75. package/dist/runtime/decision-provenance.d.ts +63 -0
  76. package/dist/runtime/decision-provenance.d.ts.map +1 -0
  77. package/dist/runtime/decision-provenance.js +107 -0
  78. package/dist/runtime/decision-provenance.js.map +1 -0
  79. package/dist/runtime/prompt-substitute.d.ts +100 -0
  80. package/dist/runtime/prompt-substitute.d.ts.map +1 -0
  81. package/dist/runtime/prompt-substitute.js +124 -0
  82. package/dist/runtime/prompt-substitute.js.map +1 -0
  83. package/dist/runtime/review-cost-cap.d.ts +167 -0
  84. package/dist/runtime/review-cost-cap.d.ts.map +1 -0
  85. package/dist/runtime/review-cost-cap.js +182 -0
  86. package/dist/runtime/review-cost-cap.js.map +1 -0
  87. package/dist/runtime/reviewer-abstain.d.ts +101 -0
  88. package/dist/runtime/reviewer-abstain.d.ts.map +1 -0
  89. package/dist/runtime/reviewer-abstain.js +138 -0
  90. package/dist/runtime/reviewer-abstain.js.map +1 -0
  91. package/dist/runtime/reviewer.d.ts +75 -0
  92. package/dist/runtime/reviewer.d.ts.map +1 -1
  93. package/dist/runtime/reviewer.js +235 -81
  94. package/dist/runtime/reviewer.js.map +1 -1
  95. package/dist/runtime/singleton.d.ts.map +1 -1
  96. package/dist/runtime/singleton.js +9 -1
  97. package/dist/runtime/singleton.js.map +1 -1
  98. package/dist/serving-auth/index.d.ts +79 -0
  99. package/dist/serving-auth/index.d.ts.map +1 -0
  100. package/dist/serving-auth/index.js +169 -0
  101. package/dist/serving-auth/index.js.map +1 -0
  102. package/dist/task-runner.d.ts +19 -2
  103. package/dist/task-runner.d.ts.map +1 -1
  104. package/dist/task-runner.js +110 -28
  105. package/dist/task-runner.js.map +1 -1
  106. package/dist/watch.d.ts +13 -0
  107. package/dist/watch.d.ts.map +1 -1
  108. package/dist/watch.js +128 -20
  109. package/dist/watch.js.map +1 -1
  110. package/package.json +3 -2
@@ -24,6 +24,10 @@ import { prepareReviewWorktree, ReviewFetchError } from "./worktree.js";
24
24
  import { isEmptyReviewDiff } from "../review-diff-guard.js";
25
25
  import { diffFilePaths, splitDiffByFile } from "./review-diff.js";
26
26
  import { planReviewChunks, partitionAbsenceClaims, MAX_REVIEW_CHUNKS, REVIEWER_ABSENCE_ESCALATION_REASON, DISCARDED_CLAIM_PREFIX, } from "./review-chunking.js";
27
+ import { decisionProvenanceReason, reconcileReviewDecision, PROSE_KEYWORD_OVERRIDE_REASON, } from "./decision-provenance.js";
28
+ import { describePromptRenderBleed, findPromptRenderBleed, substitutePlaceholders, } from "./prompt-substitute.js";
29
+ import { abstainReasons, describeAbstainForLog, } from "./reviewer-abstain.js";
30
+ import { chargeReviewAttempt, classifyReviewCost, openReviewCostContext, reviewCostLedger, reviewCostOverrunReason, reviewCostRunawayReason, } from "./review-cost-cap.js";
27
31
  import { classifyTeardownFailure } from "../review-worktree-cleanup.js";
28
32
  // Byte-identical to prompts/reviewer-agent.md. The runner does not have
29
33
  // the markdown file at runtime (the package ships without prompts/) so
@@ -516,7 +520,7 @@ export function isRetriableError(stderr) {
516
520
  function defaultSleep(ms) {
517
521
  return new Promise((resolve) => setTimeout(resolve, ms));
518
522
  }
519
- export function renderReviewerPrompt(args) {
523
+ export function renderReviewerPromptChecked(args) {
520
524
  // RF-3: ONE truncation policy — float the AC-named proof files to the front,
521
525
  // cut at a whole-file boundary, and name what was withheld. Under the cap
522
526
  // nothing reorders and no notice renders, so the prompt is byte-identical to
@@ -538,14 +542,41 @@ export function renderReviewerPrompt(args) {
538
542
  const acceptance = (args.acceptance_criteria ?? [])
539
543
  .map((a, i) => `${i + 1}. ${a}`)
540
544
  .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);
545
+ // S15-FU/REVIEW-1 (AC1): substitute VERBATIM. `String.replace` with a string
546
+ // replacement expands `$$`/`$&`/`` $` ``/`$'`/`$n`, so a diff containing `$'`
547
+ // — e.g. the clean literal `... where seq ~ '^[0-9]+$' order by 1` — used to
548
+ // splice the template text FOLLOWING `{{pr_diff}}` ("Now answer in this exact
549
+ // JSON shape…") into the diff, and the reviewer then reported that prose as a
550
+ // corruption defect in the file (#1933). Never re-introduce a string
551
+ // replacement here; see ./prompt-substitute.ts.
552
+ const pairs = reviewerPlaceholderPairs(args, diff, acceptance);
553
+ const rendered = substitutePlaceholders(REVIEWER_PROMPT_TEMPLATE, pairs);
547
554
  const note = truncationInfraNote(omitted, partialFile, prioritized.reordered);
548
- return note.length > 0 ? `${rendered}\n\n${note}\n` : rendered;
555
+ const prompt = note.length > 0 ? `${rendered}\n\n${note}\n` : rendered;
556
+ return { prompt, bleed: findPromptRenderBleed(REVIEWER_PROMPT_TEMPLATE, prompt, pairs) };
557
+ }
558
+ /**
559
+ * S15-FU/REVIEW-1 (AC1): the ORDERED placeholder/value pairs a reviewer prompt
560
+ * substitutes, so the render-integrity check (and its test) can assert that every
561
+ * one of these values survived into the prompt VERBATIM.
562
+ * // keep in sync with reviewerPlaceholderPairs in api/_lib/reviewer-agent.ts
563
+ */
564
+ export function reviewerPlaceholderPairs(args, diff, acceptance) {
565
+ return [
566
+ ["{{task_description}}", args.task_description ?? ""],
567
+ ["{{acceptance_criteria}}", acceptance],
568
+ ["{{pr_title}}", args.pr_title ?? ""],
569
+ ["{{pr_body}}", args.pr_body ?? ""],
570
+ ["{{pr_diff}}", diff],
571
+ ];
572
+ }
573
+ /**
574
+ * String façade over `renderReviewerPromptChecked` — the signature every existing
575
+ * caller and the RF-3 parity tests use. Integrity is enforced at the review seam,
576
+ * which needs the `bleed` list to build its abstain.
577
+ */
578
+ export function renderReviewerPrompt(args) {
579
+ return renderReviewerPromptChecked(args).prompt;
549
580
  }
550
581
  /* ─────────── REVIEWER-DIFF-CAP: chunked review of an over-cap diff ───────────
551
582
  *
@@ -740,7 +771,18 @@ export function extractReviewerDecision(text) {
740
771
  "parsed from prose reviewer output (no JSON found)",
741
772
  `decision_keyword: ${decision}`,
742
773
  ];
743
- return { decision, reasons, confidence };
774
+ // REVIEW-DECISION-PROVENANCE: this verdict was INFERRED from a keyword, not
775
+ // asserted. PR #1913 persisted a reject this way over an all-"Met." analysis
776
+ // with no trace of the inference; runReview stamps the marker at submit time.
777
+ return {
778
+ decision,
779
+ reasons,
780
+ confidence,
781
+ decision_provenance: {
782
+ decision_source: "prose_keyword",
783
+ override_reason: PROSE_KEYWORD_OVERRIDE_REASON,
784
+ },
785
+ };
744
786
  }
745
787
  async function defaultFetchPRMeta(repo, prNumber) {
746
788
  const { stdout } = await execa("gh", ["pr", "view", String(prNumber), "-R", repo, "--json", "title,body"], { env: buildEnv() });
@@ -877,16 +919,22 @@ async function cleanupReviewTree(tree, supabase, assignment) {
877
919
  await emitReviewWorktreeOrphaned(supabase, assignment, tree.path ?? "", result);
878
920
  }
879
921
  }
880
- // M2 (ENGINE-EXTRACT): the coding engine the reviewer drives the default
881
- // `claude-code` engine. spawn/argv/result-parse/failure-class behavior is
882
- // byte-identical to the pre-refactor inline logic now owned by
883
- // src/engines/claude-code.ts. buildEnv() is the reviewer's base env (mirrors
884
- // v0.74-B): the engine forwards ANTHROPIC_API_KEY over it when set.
885
- const engine = getEngine();
886
- function defaultSpawnClaude(modelId) {
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.
928
+ //
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.
934
+ function defaultSpawnClaude(modelId, engine) {
887
935
  return engine.spawn({ modelId, baseEnv: buildEnv() });
888
936
  }
889
- function parseEnvelope(stdout, fallbackModel) {
937
+ function parseEnvelope(stdout, fallbackModel, engine) {
890
938
  const parsed = engine.parseResult(stdout);
891
939
  if (!parsed) {
892
940
  return { result: stdout.trim(), session_id: null, cost_usd: 0, model: fallbackModel };
@@ -914,10 +962,12 @@ function parseEnvelope(stdout, fallbackModel) {
914
962
  export async function runReview(assignment, deps) {
915
963
  const fetchMeta = deps.fetchPRMeta ?? defaultFetchPRMeta;
916
964
  const prepareTree = deps.prepareReviewTree ?? defaultPrepareReviewTree;
917
- const spawnFn = deps.spawnClaude ?? defaultSpawnClaude;
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.
967
+ // `deps.spawnClaude` (test override) is unaffected either way.
918
968
  let outcome;
919
969
  try {
920
- outcome = await driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn);
970
+ outcome = await driveReview(assignment, deps, fetchMeta, prepareTree);
921
971
  }
922
972
  catch (err) {
923
973
  // V96 (ADDED SCOPE): a worktree teardown crash that escaped driveReview
@@ -951,18 +1001,34 @@ export async function runReview(assignment, deps) {
951
1001
  cost_usd: 0,
952
1002
  };
953
1003
  }
1004
+ // REVIEW-DECISION-PROVENANCE — the persistence seam. Two stamps, both purely
1005
+ // additive to `reasons` and neither touching the verdict:
1006
+ // 1. a SYNTHESIZED decision (prose fallback) renders its marker, so a
1007
+ // keyword-inferred verdict is never stored as if the analysis argued it;
1008
+ // 2. reconcile catches a reject whose own reasons record a clean full pass
1009
+ // and no override rationale (the PR #1913 shape) and flags it.
1010
+ // Applied here, immediately before the RPC, so EVERY path that reaches
1011
+ // submit_review — chunked, single-pass, capacity fallback — is covered.
1012
+ const provenanceNote = outcome.decision_provenance
1013
+ ? [decisionProvenanceReason(outcome.decision_provenance)]
1014
+ : [];
1015
+ const reconciled = reconcileReviewDecision({
1016
+ decision: outcome.decision,
1017
+ reasons: [...outcome.reasons, ...provenanceNote],
1018
+ });
1019
+ const submitted = { ...outcome, reasons: reconciled.reasons };
954
1020
  const { error } = await deps.supabase.rpc("submit_review", {
955
1021
  p_review_id: assignment.review_id,
956
- p_decision: outcome.decision,
957
- p_reasons: outcome.reasons,
958
- p_confidence: outcome.confidence,
959
- p_session_id: outcome.session_id,
960
- p_cost_usd: outcome.cost_usd,
1022
+ p_decision: submitted.decision,
1023
+ p_reasons: submitted.reasons,
1024
+ p_confidence: submitted.confidence,
1025
+ p_session_id: submitted.session_id,
1026
+ p_cost_usd: submitted.cost_usd,
961
1027
  });
962
1028
  if (error) {
963
1029
  process.stderr.write(`[acc-runner] submit_review(${assignment.review_id}) failed: ${error.message}\n`);
964
1030
  }
965
- return outcome;
1031
+ return submitted;
966
1032
  }
967
1033
  async function invokeClaude(spawnFn, modelId, prompt) {
968
1034
  const spawnedAt = Date.now();
@@ -978,7 +1044,7 @@ async function invokeClaude(spawnFn, modelId, prompt) {
978
1044
  ]);
979
1045
  return { stdout, stderr, exitCode: exit.exitCode ?? 1, durationMs: Date.now() - spawnedAt };
980
1046
  }
981
- async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
1047
+ async function driveReview(assignment, deps, fetchMeta, prepareTree) {
982
1048
  const { data, error } = await deps.supabase.rpc("fetch_task_for_runner", {
983
1049
  p_task_id: assignment.task_id,
984
1050
  });
@@ -1003,6 +1069,12 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
1003
1069
  cost_usd: 0,
1004
1070
  };
1005
1071
  }
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));
1006
1078
  // Look up the org's reviewer policy. The runner-side reviewer needs
1007
1079
  // confidence_threshold + max_cost_usd_per_review to stay consistent
1008
1080
  // with the cron-side gate, since the cron now just lifts the decision
@@ -1075,7 +1147,7 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
1075
1147
  pr_title: meta.title,
1076
1148
  pr_body: meta.body,
1077
1149
  pr_diff: tree.diff,
1078
- }, policy, deps, spawnFn);
1150
+ }, policy, deps, spawnFn, engine);
1079
1151
  }
1080
1152
  finally {
1081
1153
  // Remove the dedicated review worktree so it can never become a stale
@@ -1096,10 +1168,35 @@ async function driveReview(assignment, deps, fetchMeta, prepareTree, spawnFn) {
1096
1168
  * Past `MAX_REVIEW_CHUNKS` even chunking is not a sane spend, so the review
1097
1169
  * settles as an INFRA outcome the escalation path owns — never a reject.
1098
1170
  */
1099
- async function produceVerdict(input, policy, deps, spawnFn) {
1171
+ async function produceVerdict(input, policy, deps, spawnFn, engine) {
1172
+ // REV-COST: ONE cost context per review — the SIZE-SCALED cap plus the
1173
+ // attempt ledger every pass charges into. Derived from the ASSEMBLED diff this
1174
+ // function already holds, so no caller has to change. At or under
1175
+ // REVIEW_CAP_REFERENCE_DIFF_CHARS the cap is EXACTLY the org's flat
1176
+ // `max_cost_usd_per_review` (an ordinary PR is judged against today's number);
1177
+ // above it the cap ramps, bounded by REVIEW_CAP_MAX_MULTIPLE.
1178
+ const costCtx = openReviewCostContext(policy?.max_cost_usd_per_review ?? 0, input.pr_diff.length);
1100
1179
  const chunks = planReviewChunks(input.pr_diff, MAX_DIFF_CHARS);
1101
1180
  if (chunks.length <= 1) {
1102
- return runClaudeAndDecide(renderReviewerPrompt(input), policy, deps, spawnFn);
1181
+ // S15-FU/REVIEW-1 (AC1 + AC2): render, then PROVE the prompt carries the PR
1182
+ // content verbatim. If any value did not survive substitution, the reviewer
1183
+ // would be judging text the repo does not contain (the #1933 corruption
1184
+ // class) — so ABSTAIN before spawning anything. Never a reject, never a cost.
1185
+ const render = renderReviewerPromptChecked(input);
1186
+ if (render.bleed.length > 0) {
1187
+ const detail = describePromptRenderBleed(render.bleed);
1188
+ process.stderr.write(describeAbstainForLog("prompt_render", detail));
1189
+ return {
1190
+ decision: "reviewer_error",
1191
+ reasons: abstainReasons("prompt_render", [
1192
+ `rendered reviewer prompt did not carry PR content verbatim: ${detail}`,
1193
+ ]),
1194
+ confidence: 0,
1195
+ session_id: null,
1196
+ cost_usd: 0,
1197
+ };
1198
+ }
1199
+ return stampReviewCost(await runClaudeAndDecide(render.prompt, policy, deps, spawnFn, engine, costCtx), costCtx);
1103
1200
  }
1104
1201
  if (chunks.length > MAX_REVIEW_CHUNKS) {
1105
1202
  return {
@@ -1116,7 +1213,7 @@ async function produceVerdict(input, policy, deps, spawnFn) {
1116
1213
  cost_usd: 0,
1117
1214
  };
1118
1215
  }
1119
- return runChunkedReview(input, chunks, policy, deps, spawnFn);
1216
+ return runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx);
1120
1217
  }
1121
1218
  /**
1122
1219
  * REVIEWER-DIFF-CAP — read an over-cap diff in `chunks.length` part passes, then
@@ -1129,13 +1226,13 @@ async function produceVerdict(input, policy, deps, spawnFn) {
1129
1226
  * env-broken — short-circuits the whole review with the spend accumulated so far,
1130
1227
  * so an infra fault never degrades into a partial-evidence verdict.
1131
1228
  */
1132
- async function runChunkedReview(input, chunks, policy, deps, spawnFn) {
1133
- // The cap is checked ONCE against the whole chunked spend below; per-pass it is
1134
- // disabled so a single part is never judged against the whole-review budget.
1135
- const passPolicy = policy ? { ...policy, max_cost_usd_per_review: 0 } : null;
1136
- const cap = policy?.max_cost_usd_per_review ?? 0;
1229
+ async function runChunkedReview(input, chunks, policy, deps, spawnFn, engine, costCtx) {
1230
+ // REV-COST: the cap is judged ONCE against the WHOLE chunked spend, because
1231
+ // the shared ledger in `costCtx` accumulates across every part pass. That
1232
+ // replaces the old cap-0 `passPolicy` clone: a single part is still never
1233
+ // judged against the whole-review budget, and the whole review is no longer
1234
+ // judged against a FLAT cap.
1137
1235
  const passes = [];
1138
- let cost = 0;
1139
1236
  let degraded = false;
1140
1237
  let modelUsed;
1141
1238
  for (const chunk of chunks) {
@@ -1143,53 +1240,80 @@ async function runChunkedReview(input, chunks, policy, deps, spawnFn) {
1143
1240
  // a probe for every chunk. Only the first pass carries it.
1144
1241
  const passDeps = chunk.index === 1 ? deps : { ...deps, authCanary: undefined };
1145
1242
  const prompt = `${renderReviewerPrompt({ ...input, pr_diff: chunk.text })}\n\n${chunkScopeNotice(chunk)}\n`;
1146
- const out = await runClaudeAndDecide(prompt, passPolicy, passDeps, spawnFn);
1147
- cost += out.cost_usd;
1243
+ // The RUNAWAY brake lives in finalizeReviewerSuccess, right where the spend
1244
+ // is charged, so a part pass that pushes the WHOLE-review total past the
1245
+ // tolerance short-circuits here as a non-verdict outcome and the remaining
1246
+ // passes never run. An overrun INSIDE the band deliberately does not stop the
1247
+ // review: it finishes and the paid verdict is kept (see the tail).
1248
+ const out = await runClaudeAndDecide(prompt, policy, passDeps, spawnFn, engine, costCtx);
1249
+ const partLedger = reviewCostLedger(costCtx);
1148
1250
  modelUsed = out.model_used ?? modelUsed;
1149
1251
  degraded = degraded || out.capacity_degraded === true;
1150
1252
  if (out.decision !== "approve" && out.decision !== "reject") {
1151
- return { ...out, cost_usd: cost, capacity_degraded: degraded ? true : out.capacity_degraded };
1253
+ return {
1254
+ ...out,
1255
+ cost_usd: partLedger.total_usd,
1256
+ cost_cap: costCtx.cap,
1257
+ cost_breakdown: partLedger,
1258
+ capacity_degraded: degraded ? true : out.capacity_degraded,
1259
+ };
1152
1260
  }
1153
- if (cap > 0 && cost > cap)
1154
- return overChunkedCostCap(cost, cap, modelUsed);
1155
1261
  passes.push({ decision: out.decision, reasons: out.reasons, confidence: out.confidence });
1156
1262
  }
1157
1263
  const allPaths = diffFilePaths(input.pr_diff);
1158
1264
  const evidence = renderSynthesisEvidence(chunks, passes, allPaths);
1159
1265
  const synthPrompt = `${renderReviewerPrompt({ ...input, pr_diff: evidence })}\n\n${synthesisScopeNotice(chunks.length)}\n`;
1160
- const final = await runClaudeAndDecide(synthPrompt, passPolicy, { ...deps, authCanary: undefined }, spawnFn);
1161
- cost += final.cost_usd;
1266
+ const final = await runClaudeAndDecide(synthPrompt, policy, { ...deps, authCanary: undefined }, spawnFn, engine, costCtx);
1267
+ const ledger = reviewCostLedger(costCtx);
1162
1268
  modelUsed = final.model_used ?? modelUsed;
1163
1269
  degraded = degraded || final.capacity_degraded === true;
1164
1270
  if (final.decision !== "approve" && final.decision !== "reject") {
1165
- return { ...final, cost_usd: cost, capacity_degraded: degraded ? true : final.capacity_degraded };
1271
+ return {
1272
+ ...final,
1273
+ cost_usd: ledger.total_usd,
1274
+ cost_cap: costCtx.cap,
1275
+ cost_breakdown: ledger,
1276
+ capacity_degraded: degraded ? true : final.capacity_degraded,
1277
+ };
1166
1278
  }
1167
- if (cap > 0 && cost > cap)
1168
- return overChunkedCostCap(cost, cap, modelUsed);
1169
1279
  // Guard FIRST (so a coverage claim can never survive as a verdict), then stamp
1170
1280
  // the coverage telemetry — which must never itself be partitioned as a claim.
1171
1281
  const guarded = guardCoverageClaims({
1172
1282
  ...final,
1173
- cost_usd: cost,
1283
+ cost_usd: ledger.total_usd,
1174
1284
  model_used: modelUsed,
1175
1285
  capacity_degraded: degraded ? true : undefined,
1176
1286
  });
1287
+ // REV-COST: the overrun note (when the paid spend landed in the tolerance
1288
+ // band) sits between the findings and the coverage telemetry — a real reason,
1289
+ // so it must precede chunkedCoverageReason to keep THAT last.
1290
+ const stamped = stampReviewCost(guarded, costCtx);
1177
1291
  return {
1178
- ...guarded,
1179
- reasons: [...guarded.reasons, chunkedCoverageReason(chunks.length, allPaths.length, input.pr_diff.length)],
1292
+ ...stamped,
1293
+ reasons: [...stamped.reasons, chunkedCoverageReason(chunks.length, allPaths.length, input.pr_diff.length)],
1180
1294
  };
1181
1295
  }
1182
- /** Cost-cap parity for the chunked path: the cap applies to the WHOLE review. */
1183
- function overChunkedCostCap(cost, cap, modelUsed) {
1296
+ /**
1297
+ * REV-COST — stamp the cost ledger onto a FINISHED review outcome and, when
1298
+ * the chargeable spend landed in the OVERRUN band (over the effective cap, under
1299
+ * the runaway threshold), keep the verdict and add the advisory note.
1300
+ *
1301
+ * Called exactly once per review, after the verdict is assembled — never on a
1302
+ * chunked PART pass, so a budget note can never leak into the synthesis evidence
1303
+ * and be re-read as a finding.
1304
+ */
1305
+ function stampReviewCost(out, costCtx) {
1306
+ const ledger = reviewCostLedger(costCtx);
1307
+ const isVerdict = out.decision === "approve" || out.decision === "reject";
1308
+ const overrun = isVerdict && classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "overrun";
1184
1309
  return {
1185
- decision: "cost_cap_exceeded",
1186
- reasons: [
1187
- `reviewer cost $${cost.toFixed(4)} exceeded cap $${cap.toFixed(2)} across the chunked review`,
1188
- ],
1189
- confidence: 0,
1190
- session_id: null,
1191
- cost_usd: cost,
1192
- model_used: modelUsed,
1310
+ ...out,
1311
+ reasons: overrun
1312
+ ? [...out.reasons, reviewCostOverrunReason(costCtx.cap, ledger)]
1313
+ : out.reasons,
1314
+ cost_cap: costCtx.cap,
1315
+ cost_breakdown: ledger,
1316
+ ...(overrun ? { cost_overrun: true } : {}),
1193
1317
  };
1194
1318
  }
1195
1319
  /**
@@ -1200,7 +1324,7 @@ function overChunkedCostCap(cost, cap, modelUsed) {
1200
1324
  * caller (which owns the capacity-fallback re-route). Behaviour of this loop is
1201
1325
  * byte-identical to the pre-RVU-2 inline loop.
1202
1326
  */
1203
- async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries) {
1327
+ async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries, engine) {
1204
1328
  let invokeResult;
1205
1329
  let infraRetries = 0;
1206
1330
  let capacityRetries = 0;
@@ -1247,48 +1371,67 @@ async function invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacit
1247
1371
  /** RVU-2: a budget-exempt env-broken (auth) reviewer_error. Decision stays
1248
1372
  * `reviewer_error` (frozen); the marker reason makes the shared cron treat it
1249
1373
  * as budget-exempt and alert once. */
1250
- function envBrokenOutcome(modelId, detail) {
1374
+ function envBrokenOutcome(modelId, detail, ledger) {
1251
1375
  return {
1252
1376
  decision: "reviewer_error",
1253
1377
  reasons: [REVIEWER_ENV_BROKEN_REASON, `detail: ${(detail ?? "").slice(0, 160)}`],
1254
1378
  confidence: 0,
1379
+ // REV-COST: an auth fault costs nothing and is charged to INFRA, never to
1380
+ // the per-review budget — the review still needs its productive attempt.
1381
+ cost_usd: ledger?.total_usd ?? 0,
1255
1382
  session_id: null,
1256
- cost_usd: 0,
1257
1383
  model_used: modelId,
1258
1384
  env_broken: true,
1385
+ ...(ledger ? { cost_breakdown: ledger } : {}),
1259
1386
  };
1260
1387
  }
1261
1388
  /** RVU-2: assemble the outcome from a clean (exit 0) reviewer envelope. Cost-cap
1262
- * parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run. */
1263
- function finalizeReviewerSuccess(stdout, modelId, degraded, policy) {
1264
- const envelope = parseEnvelope(stdout, modelId);
1265
- // Cost cap parity with api/_lib/reviewer-agent.ts. A cap of 0 disables it.
1266
- if (policy &&
1267
- policy.max_cost_usd_per_review > 0 &&
1268
- envelope.cost_usd > policy.max_cost_usd_per_review) {
1389
+ * parity + parse are unchanged; `degraded` marks a re-routed (lower-tier) run.
1390
+ * REV-COST: the cap is now the size-scaled decision carried in `costCtx`, and
1391
+ * only the RUNAWAY band refuses the verdict here. */
1392
+ function finalizeReviewerSuccess(stdout, modelId, degraded, costCtx, engine) {
1393
+ const envelope = parseEnvelope(stdout, modelId, engine);
1394
+ // REV-COST: the spawn returned a readable envelope, so this is the review's
1395
+ // PRODUCTIVE attempt — the only class charged to the per-review cap. Parity
1396
+ // with api/_lib/reviewer-agent.ts, which charges at exactly this point.
1397
+ const ledger = chargeReviewAttempt(costCtx, modelId, "productive", envelope.cost_usd);
1398
+ // REV-COST RUNAWAY BACKSTOP. Past `cap × REVIEW_COST_OVERRUN_TOLERANCE`
1399
+ // something is pathological, so the verdict is still refused. Inside the band
1400
+ // the spend is judged AFTER the verdict is assembled (stampReviewCost): the
1401
+ // money is already gone, and discarding a paid verdict only stranded the PR
1402
+ // (#1937 — $18 spent, $8 flat cap, NO verdict) and re-charged the same review
1403
+ // on the next tick. A cap of 0 disables the whole check, as before.
1404
+ if (classifyReviewCost(ledger.chargeable_usd, costCtx.cap) === "runaway") {
1269
1405
  return {
1270
1406
  decision: "cost_cap_exceeded",
1271
- reasons: [
1272
- `reviewer cost $${envelope.cost_usd.toFixed(4)} exceeded cap $${policy.max_cost_usd_per_review.toFixed(2)}`,
1273
- ],
1407
+ reasons: [reviewCostRunawayReason(costCtx.cap, ledger)],
1274
1408
  confidence: 0,
1275
1409
  session_id: envelope.session_id,
1276
- cost_usd: envelope.cost_usd,
1410
+ cost_usd: ledger.total_usd,
1277
1411
  model_used: modelId,
1412
+ cost_cap: costCtx.cap,
1413
+ cost_breakdown: ledger,
1278
1414
  };
1279
1415
  }
1280
1416
  const decision = extractReviewerDecision(envelope.result);
1281
1417
  if (!decision) {
1418
+ // S15-FU/REVIEW-1 (AC2): a PARSE FAULT is an explicit ABSTAIN. The unparsed
1419
+ // output head is carried as QUARANTINED context (never a finding) so an
1420
+ // operator can diagnose the format drift without any of that text being
1421
+ // scored as a defect.
1422
+ process.stderr.write(describeAbstainForLog("verdict_parse"));
1282
1423
  return {
1283
1424
  decision: "reviewer_error",
1284
- reasons: [
1425
+ reasons: abstainReasons("verdict_parse", [
1285
1426
  "could not parse reviewer JSON from claude output",
1286
1427
  `output_head: ${envelope.result.slice(0, 200)}`,
1287
- ],
1428
+ ]),
1288
1429
  confidence: 0,
1289
1430
  session_id: envelope.session_id,
1290
- cost_usd: envelope.cost_usd,
1431
+ cost_usd: ledger.total_usd,
1291
1432
  model_used: modelId,
1433
+ cost_cap: costCtx.cap,
1434
+ cost_breakdown: ledger,
1292
1435
  };
1293
1436
  }
1294
1437
  return {
@@ -1298,6 +1441,11 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, policy) {
1298
1441
  session_id: envelope.session_id,
1299
1442
  cost_usd: envelope.cost_usd,
1300
1443
  model_used: modelId,
1444
+ // REVIEW-DECISION-PROVENANCE: carried as a field (never a reason) so it
1445
+ // survives the coverage/absence guards without being read as a finding.
1446
+ ...(decision.decision_provenance
1447
+ ? { decision_provenance: decision.decision_provenance }
1448
+ : {}),
1301
1449
  // Signal capacity pressure to watch.ts only when the review actually
1302
1450
  // re-routed to a lower tier (leave undefined on the primary-model path).
1303
1451
  capacity_degraded: degraded ? true : undefined,
@@ -1312,7 +1460,7 @@ function finalizeReviewerSuccess(stdout, modelId, degraded, policy) {
1312
1460
  * explicit broken CLI-auth signal surfaces as budget-exempt env_broken rather
1313
1461
  * than looping. Cost-cap / parse behaviour is unchanged.
1314
1462
  */
1315
- async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
1463
+ async function runClaudeAndDecide(prompt, policy, deps, spawnFn, engine, costCtx) {
1316
1464
  const sleepFn = deps.sleep ?? defaultSleep;
1317
1465
  const baseModel = policy?.model_id ?? "claude-sonnet-4-6";
1318
1466
  const maxCapacityRetries = resolveCapacityRetries(process.env[CAPACITY_RETRIES_ENV]);
@@ -1330,7 +1478,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
1330
1478
  probes = [];
1331
1479
  }
1332
1480
  if (isEnvBrokenReviewerProbe(probes)) {
1333
- return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)");
1481
+ return envBrokenOutcome(primaryModel, "preflight auth canary: repeated silent CLI exit (broken login / rejected key)", chargeReviewAttempt(costCtx, primaryModel, "infra_auth", 0));
1334
1482
  }
1335
1483
  }
1336
1484
  let lastCapacityDetail = "reviewer capacity exhausted across all fallback tiers";
@@ -1338,10 +1486,10 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
1338
1486
  for (let i = 0; i < chain.length; i++) {
1339
1487
  const modelId = chain[i];
1340
1488
  const isLast = i === chain.length - 1;
1341
- const invokeResult = await invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries);
1489
+ const invokeResult = await invokeReviewerModel(spawnFn, modelId, prompt, sleepFn, maxCapacityRetries, engine);
1342
1490
  const { stdout, exitCode, stderr: claudeStderr, durationMs } = invokeResult;
1343
1491
  if (exitCode === 0) {
1344
- return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, policy);
1492
+ return finalizeReviewerSuccess(stdout, modelId, modelId !== primaryModel, costCtx, engine);
1345
1493
  }
1346
1494
  const classified = engine.classifyFailure(exitCode, claudeStderr, stdout, durationMs);
1347
1495
  const combined = `${claudeStderr}\n${stdout}`;
@@ -1353,7 +1501,7 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
1353
1501
  if (classified.class === "auth_expired" ||
1354
1502
  classified.class === "env_broken" ||
1355
1503
  isReviewerAuthFailure(combined)) {
1356
- return envBrokenOutcome(modelId, classified.detail);
1504
+ return envBrokenOutcome(modelId, classified.detail, chargeReviewAttempt(costCtx, modelId, "infra_auth", 0));
1357
1505
  }
1358
1506
  const isCapacity = classified.class === "capacity_exhausted" ||
1359
1507
  classified.class === "claude_unavailable";
@@ -1362,6 +1510,10 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
1362
1510
  const resumeMs = extractResetTime(combined);
1363
1511
  if (resumeMs !== null)
1364
1512
  lastResumeMs = resumeMs;
1513
+ // REV-COST: a 429/overload brownout is INFRA. Recording it makes "N
1514
+ // attempts on one review" visible WITHOUT any of those attempts consuming
1515
+ // the per-review budget the productive attempt needs.
1516
+ const capacityLedger = chargeReviewAttempt(costCtx, modelId, "infra_capacity", 0);
1365
1517
  // Re-route to the next lower catalog tier instead of stalling the lane.
1366
1518
  if (!isLast)
1367
1519
  continue;
@@ -1377,6 +1529,8 @@ async function runClaudeAndDecide(prompt, policy, deps, spawnFn) {
1377
1529
  resume_at: lastResumeMs !== null ? new Date(lastResumeMs).toISOString() : null,
1378
1530
  model_used: modelId,
1379
1531
  capacity_degraded: true,
1532
+ cost_cap: costCtx.cap,
1533
+ cost_breakdown: capacityLedger,
1380
1534
  };
1381
1535
  }
1382
1536
  // Genuine error (task_error / usage_limit) — legacy reviewer_error, no