@humanbased/crosscheck 0.14.0 → 0.15.0-beta.145

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 (176) hide show
  1. package/ISSUE.md +1 -1
  2. package/LICENSE +1 -1
  3. package/README.md +191 -8
  4. package/README.zh.md +1 -1
  5. package/crosscheck.config.example.yml +4 -2
  6. package/dist/__tests__/backtrace.test.js +1 -1
  7. package/dist/__tests__/backtrace.test.js.map +1 -1
  8. package/dist/__tests__/diagnose.test.js +36 -0
  9. package/dist/__tests__/diagnose.test.js.map +1 -1
  10. package/dist/__tests__/durations.test.js +5 -1
  11. package/dist/__tests__/durations.test.js.map +1 -1
  12. package/dist/__tests__/error-classification.test.d.ts +2 -0
  13. package/dist/__tests__/error-classification.test.d.ts.map +1 -0
  14. package/dist/__tests__/error-classification.test.js +36 -0
  15. package/dist/__tests__/error-classification.test.js.map +1 -0
  16. package/dist/__tests__/fix.test.js +48 -1
  17. package/dist/__tests__/fix.test.js.map +1 -1
  18. package/dist/__tests__/issue.test.js +2 -2
  19. package/dist/__tests__/issue.test.js.map +1 -1
  20. package/dist/__tests__/kickass.test.js +362 -69
  21. package/dist/__tests__/kickass.test.js.map +1 -1
  22. package/dist/__tests__/optimize.test.js +3 -3
  23. package/dist/__tests__/optimize.test.js.map +1 -1
  24. package/dist/__tests__/pr-picker.test.js +8 -7
  25. package/dist/__tests__/pr-picker.test.js.map +1 -1
  26. package/dist/__tests__/pr-status.test.js +41 -20
  27. package/dist/__tests__/pr-status.test.js.map +1 -1
  28. package/dist/__tests__/pr-workflow-state.test.d.ts +2 -0
  29. package/dist/__tests__/pr-workflow-state.test.d.ts.map +1 -0
  30. package/dist/__tests__/pr-workflow-state.test.js +184 -0
  31. package/dist/__tests__/pr-workflow-state.test.js.map +1 -0
  32. package/dist/__tests__/review-models.test.js +18 -0
  33. package/dist/__tests__/review-models.test.js.map +1 -1
  34. package/dist/__tests__/run.test.d.ts +2 -0
  35. package/dist/__tests__/run.test.d.ts.map +1 -0
  36. package/dist/__tests__/run.test.js +81 -0
  37. package/dist/__tests__/run.test.js.map +1 -0
  38. package/dist/__tests__/runner.test.js +117 -1
  39. package/dist/__tests__/runner.test.js.map +1 -1
  40. package/dist/__tests__/scopes.test.js +11 -11
  41. package/dist/__tests__/scopes.test.js.map +1 -1
  42. package/dist/__tests__/smart-switch.test.js +1 -1
  43. package/dist/__tests__/smart-switch.test.js.map +1 -1
  44. package/dist/__tests__/tier-timeouts.test.d.ts +2 -0
  45. package/dist/__tests__/tier-timeouts.test.d.ts.map +1 -0
  46. package/dist/__tests__/tier-timeouts.test.js +23 -0
  47. package/dist/__tests__/tier-timeouts.test.js.map +1 -0
  48. package/dist/__tests__/webhook.test.d.ts +2 -0
  49. package/dist/__tests__/webhook.test.d.ts.map +1 -0
  50. package/dist/__tests__/webhook.test.js +197 -0
  51. package/dist/__tests__/webhook.test.js.map +1 -0
  52. package/dist/cli.js +38 -5
  53. package/dist/cli.js.map +1 -1
  54. package/dist/commands/detect-step.d.ts +5 -0
  55. package/dist/commands/detect-step.d.ts.map +1 -0
  56. package/dist/commands/detect-step.js +124 -0
  57. package/dist/commands/detect-step.js.map +1 -0
  58. package/dist/commands/diagnose.d.ts +1 -1
  59. package/dist/commands/diagnose.d.ts.map +1 -1
  60. package/dist/commands/diagnose.js +30 -1
  61. package/dist/commands/diagnose.js.map +1 -1
  62. package/dist/commands/kickass.d.ts +28 -10
  63. package/dist/commands/kickass.d.ts.map +1 -1
  64. package/dist/commands/kickass.js +295 -68
  65. package/dist/commands/kickass.js.map +1 -1
  66. package/dist/commands/review.d.ts.map +1 -1
  67. package/dist/commands/review.js +14 -5
  68. package/dist/commands/review.js.map +1 -1
  69. package/dist/commands/run.d.ts +16 -1
  70. package/dist/commands/run.d.ts.map +1 -1
  71. package/dist/commands/run.js +347 -44
  72. package/dist/commands/run.js.map +1 -1
  73. package/dist/commands/serve.d.ts.map +1 -1
  74. package/dist/commands/serve.js +41 -3
  75. package/dist/commands/serve.js.map +1 -1
  76. package/dist/commands/status.d.ts.map +1 -1
  77. package/dist/commands/status.js +10 -2
  78. package/dist/commands/status.js.map +1 -1
  79. package/dist/commands/watch.d.ts.map +1 -1
  80. package/dist/commands/watch.js +200 -6
  81. package/dist/commands/watch.js.map +1 -1
  82. package/dist/config/schema.d.ts +52 -0
  83. package/dist/config/schema.d.ts.map +1 -1
  84. package/dist/config/schema.js +24 -1
  85. package/dist/config/schema.js.map +1 -1
  86. package/dist/github/client.d.ts +40 -1
  87. package/dist/github/client.d.ts.map +1 -1
  88. package/dist/github/client.js +69 -9
  89. package/dist/github/client.js.map +1 -1
  90. package/dist/github/review-status.d.ts.map +1 -1
  91. package/dist/github/review-status.js +7 -4
  92. package/dist/github/review-status.js.map +1 -1
  93. package/dist/github/webhook.d.ts +25 -1
  94. package/dist/github/webhook.d.ts.map +1 -1
  95. package/dist/github/webhook.js +37 -1
  96. package/dist/github/webhook.js.map +1 -1
  97. package/dist/lib/annotation.d.ts +4 -0
  98. package/dist/lib/annotation.d.ts.map +1 -1
  99. package/dist/lib/annotation.js +5 -1
  100. package/dist/lib/annotation.js.map +1 -1
  101. package/dist/lib/board.d.ts.map +1 -1
  102. package/dist/lib/board.js +7 -5
  103. package/dist/lib/board.js.map +1 -1
  104. package/dist/lib/comment-bodies.d.ts.map +1 -1
  105. package/dist/lib/comment-bodies.js +3 -2
  106. package/dist/lib/comment-bodies.js.map +1 -1
  107. package/dist/lib/durations.d.ts.map +1 -1
  108. package/dist/lib/durations.js +5 -3
  109. package/dist/lib/durations.js.map +1 -1
  110. package/dist/lib/logger.d.ts +4 -1
  111. package/dist/lib/logger.d.ts.map +1 -1
  112. package/dist/lib/logger.js +41 -5
  113. package/dist/lib/logger.js.map +1 -1
  114. package/dist/lib/pr-picker.d.ts.map +1 -1
  115. package/dist/lib/pr-picker.js +5 -1
  116. package/dist/lib/pr-picker.js.map +1 -1
  117. package/dist/lib/pr-status.d.ts +4 -3
  118. package/dist/lib/pr-status.d.ts.map +1 -1
  119. package/dist/lib/pr-status.js +19 -13
  120. package/dist/lib/pr-status.js.map +1 -1
  121. package/dist/lib/pr-workflow-state.d.ts +68 -0
  122. package/dist/lib/pr-workflow-state.d.ts.map +1 -0
  123. package/dist/lib/pr-workflow-state.js +328 -0
  124. package/dist/lib/pr-workflow-state.js.map +1 -0
  125. package/dist/lib/product.d.ts +3 -0
  126. package/dist/lib/product.d.ts.map +1 -0
  127. package/dist/lib/product.js +5 -0
  128. package/dist/lib/product.js.map +1 -0
  129. package/dist/lib/repo-picker.d.ts +1 -0
  130. package/dist/lib/repo-picker.d.ts.map +1 -1
  131. package/dist/lib/repo-picker.js +50 -33
  132. package/dist/lib/repo-picker.js.map +1 -1
  133. package/dist/lib/review-models.d.ts +2 -2
  134. package/dist/lib/review-models.d.ts.map +1 -1
  135. package/dist/lib/review-models.js +6 -1
  136. package/dist/lib/review-models.js.map +1 -1
  137. package/dist/lib/runner.d.ts +19 -1
  138. package/dist/lib/runner.d.ts.map +1 -1
  139. package/dist/lib/runner.js +338 -55
  140. package/dist/lib/runner.js.map +1 -1
  141. package/dist/lib/scopes.js +1 -1
  142. package/dist/lib/scopes.js.map +1 -1
  143. package/dist/lib/smart-switch.js +1 -1
  144. package/dist/lib/smart-switch.js.map +1 -1
  145. package/dist/lib/vendor.d.ts +4 -0
  146. package/dist/lib/vendor.d.ts.map +1 -0
  147. package/dist/lib/vendor.js +14 -0
  148. package/dist/lib/vendor.js.map +1 -0
  149. package/dist/lib/workflow.d.ts +5 -0
  150. package/dist/lib/workflow.d.ts.map +1 -1
  151. package/dist/lib/workflow.js.map +1 -1
  152. package/dist/reviewers/claude.d.ts +3 -1
  153. package/dist/reviewers/claude.d.ts.map +1 -1
  154. package/dist/reviewers/claude.js +15 -10
  155. package/dist/reviewers/claude.js.map +1 -1
  156. package/dist/reviewers/codex.d.ts +1 -1
  157. package/dist/reviewers/codex.d.ts.map +1 -1
  158. package/dist/reviewers/codex.js +7 -10
  159. package/dist/reviewers/codex.js.map +1 -1
  160. package/dist/reviewers/conflict-resolve.d.ts +1 -1
  161. package/dist/reviewers/conflict-resolve.d.ts.map +1 -1
  162. package/dist/reviewers/conflict-resolve.js +3 -2
  163. package/dist/reviewers/conflict-resolve.js.map +1 -1
  164. package/dist/reviewers/fix.d.ts +5 -1
  165. package/dist/reviewers/fix.d.ts.map +1 -1
  166. package/dist/reviewers/fix.js +68 -2
  167. package/dist/reviewers/fix.js.map +1 -1
  168. package/dist/reviewers/tier-timeouts.d.ts +5 -0
  169. package/dist/reviewers/tier-timeouts.d.ts.map +1 -0
  170. package/dist/reviewers/tier-timeouts.js +14 -0
  171. package/dist/reviewers/tier-timeouts.js.map +1 -0
  172. package/docs/fixture-pr.md +112 -0
  173. package/docs/proof-demo.md +102 -0
  174. package/get-started.md +128 -31
  175. package/get-started.zh.md +7 -1
  176. package/package.json +4 -2
@@ -5,23 +5,32 @@ import { join } from 'path';
5
5
  import chalk from 'chalk';
6
6
  import { runCodexReview } from '../reviewers/codex.js';
7
7
  import { runClaudeReview } from '../reviewers/claude.js';
8
- import { runFixStep } from '../reviewers/fix.js';
8
+ import { runFixStep, runCodexFixStep } from '../reviewers/fix.js';
9
9
  import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
10
10
  import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING } from '../lib/verdict.js';
11
11
  import { createGithubClient, postReviewComment, getLastCrossCheckCommentId, getLastCrossCheckReviewComment } from '../github/client.js';
12
12
  import { acquireRemoteLock, releaseRemoteLock } from '../github/review-status.js';
13
13
  import { log as fileLog, logError } from '../lib/logger.js';
14
14
  import { buildCommitTrailers } from '../lib/annotation.js';
15
- import { resolveClaudeModel } from '../lib/review-models.js';
15
+ import { resolveClaudeModel, resolveCodexModel } from '../lib/review-models.js';
16
16
  import { buildStepIdentityFields } from '../lib/event-fields.js';
17
17
  import { buildFixAppliedCommentBody, buildConflictResolvedCommentBody } from '../lib/comment-bodies.js';
18
18
  import { loadWorkflow, evaluateWhen } from '../lib/workflow.js';
19
+ import { isSubscriptionLimitError } from '../lib/smart-switch.js';
19
20
  const MAX_CROSSCHECK_COMMITS = 5;
20
21
  const FIX_RETRY_DELAY_MS = 2 * 60 * 1000;
21
- // Auth failures are operator issues that won't self-heal everything else is worth a retry.
22
+ // Per-vendor configured timeout (seconds) execa milliseconds, or undefined when
23
+ // unset so the reviewer falls back to its built-in default. A per-run override
24
+ // (ctx.overrideTimeoutMs, set by --timeout / --crazy / --halfcrazy) always wins
25
+ // over this; 0 from that override means "no cap" and is preserved by `??`.
26
+ function vendorTimeoutMs(timeoutSec) {
27
+ return timeoutSec == null ? undefined : timeoutSec * 1000;
28
+ }
29
+ // Auth and quota/credit failures are operator/vendor-capacity issues that won't
30
+ // self-heal through an immediate retry. Transient subprocess failures can retry.
22
31
  export function isRetryableFixError(err) {
23
32
  const msg = err instanceof Error ? err.message : String(err);
24
- return !/auth failure|not logged in|claude auth/i.test(msg);
33
+ return !/auth failure|not logged in|claude auth/i.test(msg) && !isSubscriptionLimitError(err);
25
34
  }
26
35
  // When a PR has already been reviewed, subsequent webhook runs treat every
27
36
  // 'review' step as a 'recheck' so the first review's CR result is preserved.
@@ -56,10 +65,18 @@ export function countCrosscheckCommitsForPR(tmpDir, baseRef) {
56
65
  }
57
66
  }
58
67
  export function buildWorkflowCompleteEvent(inputs) {
59
- const lastVerdict = Object.values(inputs.results).reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
68
+ const stepValues = Object.values(inputs.results);
69
+ const lastVerdict = stepValues.reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
60
70
  const lastStep = inputs.stepsRun.length > 0 ? inputs.stepsRun[inputs.stepsRun.length - 1] : null;
61
71
  const endedReason = inputs.workflowFailed ? 'error' : 'completed';
62
72
  const now = inputs.now ?? Date.now();
73
+ const totalTokens = stepValues.reduce((s, r) => s + (r.tokens_used ?? 0), 0);
74
+ // Only emit split fields when at least one step actually recorded them — avoid
75
+ // emitting 0/0 for codex-only or fix-only workflows where splits are unavailable.
76
+ const hasSplits = stepValues.some(r => r.input_tokens !== undefined || r.output_tokens !== undefined);
77
+ const totalInputTokens = hasSplits ? stepValues.reduce((s, r) => s + (r.input_tokens ?? 0), 0) : undefined;
78
+ const totalOutputTokens = hasSplits ? stepValues.reduce((s, r) => s + (r.output_tokens ?? 0), 0) : undefined;
79
+ const vendorsUsed = [...new Set(stepValues.map(r => r.vendor).filter(Boolean))];
63
80
  return {
64
81
  level: inputs.workflowFailed ? 'warn' : 'info',
65
82
  event: 'workflow_complete',
@@ -71,7 +88,11 @@ export function buildWorkflowCompleteEvent(inputs) {
71
88
  last_verdict: lastVerdict,
72
89
  ended_reason: endedReason,
73
90
  total_duration_ms: now - inputs.workflowStart,
91
+ ...(totalTokens > 0 && { total_tokens: totalTokens, ...(hasSplits && { total_input_tokens: totalInputTokens, total_output_tokens: totalOutputTokens }) }),
92
+ ...(vendorsUsed.length > 0 && { vendors_used: vendorsUsed }),
93
+ ...(inputs.qualityTier !== undefined && { quality_tier: inputs.qualityTier }),
74
94
  ...(inputs.round !== undefined && { round: inputs.round }),
95
+ ...(inputs.trigger !== undefined && { trigger: inputs.trigger }),
75
96
  };
76
97
  }
77
98
  // Returns true when fix/recheck steps should be skipped because the configured
@@ -119,8 +140,122 @@ function resolveReviewer(reviewer, origin, config, fallback) {
119
140
  return config.vendors.codex.enabled ? 'codex' : (fallback && config.vendors[fallback].enabled ? fallback : null);
120
141
  return null;
121
142
  }
143
+ function supportsStep(vendor, stepType) {
144
+ if (stepType === 'review' || stepType === 'recheck' || stepType === 'fix')
145
+ return true;
146
+ // Conflict resolution is Claude-only until Codex conflict resolution exists.
147
+ return vendor === 'claude';
148
+ }
149
+ function resolveLimitFallbackVendor(failedVendor, stepType, config) {
150
+ const fallback = failedVendor === 'claude' ? 'codex' : 'claude';
151
+ return config.vendors[fallback].enabled && supportsStep(fallback, stepType) ? fallback : null;
152
+ }
153
+ // Extends resolveReviewer with a human-origin fallback for the fix step.
154
+ // Scoped to reviewer: 'origin' only — other reviewer types (claude, codex, auto)
155
+ // already encode explicit vendor intent and need no fallback.
156
+ // When origin is 'human' and no vendor resolved, honours routing.fallback_reviewer
157
+ // so the fix step respects the same routing intent as the review step.
158
+ // 'auto' mirrors resolveReviewer's auto path (config-enabled check, codex-first)
159
+ // without async auth calls. null disables the fallback entirely.
160
+ // Exported so callers can detect when the fallback was applied (e.g. for logging).
161
+ export function resolveFixVendor(stepReviewer, origin, config, fallback) {
162
+ const vendor = resolveReviewer(stepReviewer, origin, config, fallback);
163
+ if (vendor !== null || origin !== 'human' || stepReviewer !== 'origin') {
164
+ return { vendor, usedHumanFallback: false };
165
+ }
166
+ const fb = config.routing.fallback_reviewer;
167
+ let humanFallback = null;
168
+ if (fb === 'claude')
169
+ humanFallback = config.vendors.claude.enabled ? 'claude' : null;
170
+ else if (fb === 'codex')
171
+ humanFallback = config.vendors.codex.enabled ? 'codex' : null;
172
+ else if (fb !== null) {
173
+ // 'auto': prefer codex then claude, same as resolveReviewer's auto path
174
+ humanFallback = config.vendors.codex.enabled ? 'codex' : config.vendors.claude.enabled ? 'claude' : null;
175
+ }
176
+ if (!humanFallback)
177
+ return { vendor: null, usedHumanFallback: false };
178
+ return { vendor: humanFallback, usedHumanFallback: true };
179
+ }
180
+ // ─── pr_complexity helpers ────────────────────────────────────────────────────
181
+ const EXT_LANG = {
182
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
183
+ mjs: 'javascript', cjs: 'javascript', py: 'python', go: 'go', java: 'java',
184
+ rb: 'ruby', rs: 'rust', cs: 'csharp', cpp: 'cpp', cc: 'cpp', php: 'php',
185
+ kt: 'kotlin', swift: 'swift',
186
+ };
187
+ function classifyFile(filePath) {
188
+ const lower = filePath.toLowerCase();
189
+ const ext = lower.split('.').pop() ?? '';
190
+ if (/\.(test|spec)\.[jt]sx?$/.test(lower) || /(\/__tests__\/|\/test\/|\/spec\/)/.test(lower))
191
+ return 'test';
192
+ if (/\.(md|mdx|rst|txt)$/.test(lower))
193
+ return 'docs';
194
+ if (/^(dockerfile|tf|hcl)$/.test(ext) || lower === 'dockerfile' || /\/(\.github|infra|k8s|docker|ci\/)/.test(lower))
195
+ return 'infra';
196
+ if (/\.(css|scss|sass|less|html|svelte|vue)$/.test(lower) || /\.(tsx|jsx)$/.test(lower))
197
+ return 'frontend';
198
+ if (/\/(components|pages|views|ui|client|browser|public|assets|styles)/.test(lower))
199
+ return 'frontend';
200
+ if (/^(json|yml|yaml|toml|ini|cfg|env|lock)$/.test(ext))
201
+ return 'config';
202
+ if (['ts', 'js', 'mjs', 'cjs', 'py', 'go', 'java', 'rb', 'rs', 'cs', 'cpp', 'cc', 'php', 'kt', 'swift'].includes(ext))
203
+ return 'backend';
204
+ return 'other';
205
+ }
206
+ function diffBucket(totalLines) {
207
+ if (totalLines < 50)
208
+ return 'tiny';
209
+ if (totalLines < 200)
210
+ return 'small';
211
+ if (totalLines < 500)
212
+ return 'medium';
213
+ if (totalLines < 2000)
214
+ return 'large';
215
+ return 'xlarge';
216
+ }
217
+ function emitPRComplexity(ctx, triggerField) {
218
+ const { owner, repoName, prNumber, tmpDir, pr, config } = ctx;
219
+ try {
220
+ const raw = execSync(`git diff --stat origin/${pr.base.ref}...HEAD`, { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
221
+ if (!raw)
222
+ return;
223
+ const lines = raw.split('\n');
224
+ const summary = lines[lines.length - 1];
225
+ const filesChanged = parseInt(summary.match(/(\d+) files? changed/)?.[1] ?? '0');
226
+ const insertions = parseInt(summary.match(/(\d+) insertion/)?.[1] ?? '0');
227
+ const deletions = parseInt(summary.match(/(\d+) deletion/)?.[1] ?? '0');
228
+ const filePaths = lines.slice(0, -1).map(l => l.trim().split('|')[0]?.trim()).filter(Boolean);
229
+ const mix = { backend: 0, frontend: 0, test: 0, infra: 0, docs: 0, config: 0, other: 0 };
230
+ const langSet = new Set();
231
+ for (const fp of filePaths) {
232
+ const ext = fp.split('.').pop()?.toLowerCase() ?? '';
233
+ if (EXT_LANG[ext])
234
+ langSet.add(EXT_LANG[ext]);
235
+ const cat = classifyFile(fp);
236
+ mix[cat] = (mix[cat] ?? 0) + 1;
237
+ }
238
+ fileLog({
239
+ level: 'info',
240
+ event: 'pr_complexity',
241
+ repo: `${owner}/${repoName}`,
242
+ pr: prNumber,
243
+ files_changed: filesChanged,
244
+ insertions,
245
+ deletions,
246
+ diff_bucket: diffBucket(insertions + deletions),
247
+ file_mix: mix,
248
+ languages: [...langSet],
249
+ quality_tier: config.quality.tier,
250
+ ...triggerField,
251
+ });
252
+ }
253
+ catch { /* best-effort — never fail the workflow for a logging event */ }
254
+ }
255
+ // ─────────────────────────────────────────────────────────────────────────────
122
256
  export async function runWorkflow(ctx) {
123
- const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange } = ctx;
257
+ const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange, trigger } = ctx;
258
+ const triggerField = trigger !== undefined ? { trigger } : {};
124
259
  const steps = ctx.steps ?? loadWorkflow(process.cwd());
125
260
  const results = {};
126
261
  // SHAs the workflow pushed AND set a `crosscheck/review` pending status on.
@@ -134,6 +269,11 @@ export async function runWorkflow(ctx) {
134
269
  // mid-workflow (process.exit there bypasses our finally below).
135
270
  const pushedShasNeedingRelease = ctx.pushedShas ?? [];
136
271
  let workflowFailed = false;
272
+ // When a fix commits and a structural review/recheck step follows, we acquire
273
+ // the remote lock on the pushed SHA. Track it here so the finally can detect
274
+ // whether the recheck was actually skipped (by `when`, no_reviewer, etc.) and
275
+ // release the SHA as `failure` rather than `success` in that case.
276
+ let fixPushedShaRequiresRecheck = null;
137
277
  // workflow_complete event accumulators. Each step the runner dispatches is
138
278
  // recorded in stepsRun (including ones that get logged as step_skipped —
139
279
  // the event records the workflow's declared shape, the per-step skip
@@ -141,11 +281,12 @@ export async function runWorkflow(ctx) {
141
281
  const workflowId = randomUUID();
142
282
  const workflowStart = Date.now();
143
283
  const stepsRun = [];
284
+ emitPRComplexity(ctx, triggerField);
144
285
  try {
145
286
  for (const step of steps) {
146
287
  stepsRun.push(step.name);
147
288
  const effectiveType = getEffectiveStepType(step.type, ctx.isRecheckRun === true);
148
- if (exceedsMaxRounds(effectiveType, step.type, step.max_rounds, ctx.round)) {
289
+ if (exceedsMaxRounds(effectiveType, step.type, ctx.overrideMaxRounds ?? step.max_rounds, ctx.round)) {
149
290
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'max_rounds' });
150
291
  results[step.name] = { skipped: true };
151
292
  if (effectiveType === 'fix')
@@ -170,14 +311,17 @@ export async function runWorkflow(ctx) {
170
311
  }
171
312
  if (effectiveType === 'review' || effectiveType === 'recheck') {
172
313
  const isRecheck = effectiveType === 'recheck';
173
- const reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
314
+ let reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
174
315
  if (!reviewer) {
175
316
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
176
317
  results[step.name] = { skipped: true };
177
318
  continue;
178
319
  }
320
+ // The recheck step is confirmed to run — clear the pending-recheck guard
321
+ // so the finally doesn't release the fix-pushed SHA as failure.
322
+ fixPushedShaRequiresRecheck = null;
179
323
  const stepIdentity = buildStepIdentityFields(effectiveType, step.name);
180
- fileLog({ level: 'info', event: 'review_started', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, ...(ctx.round !== undefined && { round: ctx.round }) });
324
+ fileLog({ level: 'info', event: 'review_started', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }) });
181
325
  const startPhase = isRecheck ? 'rechecking' : 'reviewing';
182
326
  const donePhase = isRecheck ? 'rechecked' : 'reviewed';
183
327
  onPhaseChange(`${reviewer} ${isRecheck ? 'rechecking' : 'reviewing'}...`, { phase: startPhase });
@@ -185,16 +329,50 @@ export async function runWorkflow(ctx) {
185
329
  // workflow start and never reset, so a recheck's duration_ms would
186
330
  // otherwise include the prior review and fix wall time.
187
331
  const stepStart = Date.now();
188
- let rawReview;
332
+ let rawReview = '';
189
333
  let tokensUsed;
334
+ let inputTokens;
335
+ let outputTokens;
190
336
  let model = 'default';
191
- if (reviewer === 'codex') {
192
- ;
193
- ({ review: rawReview, tokensUsed, model } = await runCodexReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.codex, step.instructions));
337
+ const runReviewWithVendor = async (candidate) => {
338
+ if (candidate === 'codex') {
339
+ ;
340
+ ({ review: rawReview, tokensUsed, model } = await runCodexReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.codex, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec)));
341
+ inputTokens = undefined;
342
+ outputTokens = undefined;
343
+ }
344
+ else {
345
+ ;
346
+ ({ review: rawReview, tokensUsed, inputTokens, outputTokens, model } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.claude, config.budget.per_review_usd, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), !!ctx.roundMode));
347
+ }
348
+ };
349
+ try {
350
+ await runReviewWithVendor(reviewer);
194
351
  }
195
- else {
196
- ;
197
- ({ review: rawReview, tokensUsed, model } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.claude, config.budget.per_review_usd, step.instructions));
352
+ catch (err) {
353
+ if (!isSubscriptionLimitError(err))
354
+ throw err;
355
+ const failedVendor = reviewer;
356
+ const fallbackVendor = resolveLimitFallbackVendor(failedVendor, effectiveType, config);
357
+ const reason = err instanceof Error ? err.message : String(err);
358
+ ctx.onVendorLimit?.(failedVendor, fallbackVendor, reason, step.name);
359
+ if (!fallbackVendor)
360
+ throw err;
361
+ fileLog({
362
+ level: 'warn',
363
+ event: 'vendor_fallback',
364
+ repo: `${owner}/${repoName}`,
365
+ pr: prNumber,
366
+ step: step.name,
367
+ step_type: effectiveType,
368
+ failed_vendor: failedVendor,
369
+ fallback_vendor: fallbackVendor,
370
+ reason: reason.slice(0, 300),
371
+ });
372
+ log(chalk.yellow(`⚠ ${failedVendor} hit a usage limit — switching ${effectiveType} step to ${fallbackVendor}`));
373
+ reviewer = fallbackVendor;
374
+ onPhaseChange(`${reviewer} ${isRecheck ? 'rechecking' : 'reviewing'}...`, { phase: startPhase });
375
+ await runReviewWithVendor(reviewer);
198
376
  }
199
377
  const { verdict, clean } = parseVerdict(rawReview);
200
378
  if (verdict === null) {
@@ -204,7 +382,7 @@ export async function runWorkflow(ctx) {
204
382
  ? `${NULL_VERDICT_WARNING}\n\n${clean}`
205
383
  : prependVerdictToComment(clean, verdict);
206
384
  const commentCount = countComments(rawReview);
207
- fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, tokens_used: tokensUsed, ...(ctx.round !== undefined && { round: ctx.round }) });
385
+ fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, tokens_used: tokensUsed, ...(inputTokens !== undefined && { input_tokens: inputTokens }), ...(outputTokens !== undefined && { output_tokens: outputTokens }), ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }), ...triggerField });
208
386
  // Recheck verdict is stored separately to preserve the original review's commentCount on the board
209
387
  const phaseUpdate = isRecheck
210
388
  ? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: config.quality.tier }
@@ -212,7 +390,7 @@ export async function runWorkflow(ctx) {
212
390
  if (ctx.dryRun) {
213
391
  onPhaseChange('dry-run — comment not posted', phaseUpdate);
214
392
  log(chalk.dim(`\n--- dry-run: comment that would be posted ---\n${commentBody}\n--- end ---`));
215
- results[step.name] = { verdict, commentBody };
393
+ results[step.name] = { verdict, commentBody, tokens_used: tokensUsed, input_tokens: inputTokens, output_tokens: outputTokens, vendor: reviewer, model };
216
394
  }
217
395
  else {
218
396
  onPhaseChange(isRecheck ? 'posting recheck...' : 'posting comment...', phaseUpdate);
@@ -227,10 +405,26 @@ export async function runWorkflow(ctx) {
227
405
  priorReviewId = await getLastCrossCheckCommentId(owner, repoName, prNumber, token);
228
406
  }
229
407
  }
230
- const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck, model, effectiveType, ctx.round ?? 1, pr.head.sha);
408
+ // Pre-compute next_step for the annotation so readers can skip full
409
+ // comment scans: find the first remaining step whose when-condition holds.
410
+ const currentStepIdx = steps.indexOf(step);
411
+ const syntheticResultsForNext = {
412
+ review: { verdict }, [step.name]: { verdict },
413
+ };
414
+ const nextWorkflowStep = steps.slice(currentStepIdx + 1).find(s => !s.when || evaluateWhen(s.when, syntheticResultsForNext));
415
+ const nextStepAnnotation = nextWorkflowStep?.type ?? 'none';
416
+ // Read the actual HEAD from the checkout: after an in-run fix step pushes a
417
+ // new commit, pr.head.sha is still the pre-fix SHA and would make the recheck
418
+ // annotation look stale to the step detector on the next run.
419
+ let annotationSha = pr.head.sha;
420
+ try {
421
+ annotationSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
422
+ }
423
+ catch { /* fall back to pr.head.sha if git is unavailable */ }
424
+ const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck, model, effectiveType, ctx.round ?? 1, annotationSha, nextStepAnnotation, ctx.trigger === 'kickass' ? 'kickass' : undefined);
231
425
  const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
232
426
  fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
233
- results[step.name] = { verdict, commentBody, commentUrl, commentId };
427
+ results[step.name] = { verdict, commentBody, commentUrl, commentId, tokens_used: tokensUsed, input_tokens: inputTokens, output_tokens: outputTokens, vendor: reviewer, model };
234
428
  }
235
429
  }
236
430
  else if (effectiveType === 'fix') {
@@ -262,9 +456,15 @@ export async function runWorkflow(ctx) {
262
456
  reviewCommentId = ctx.initialReviewComment?.id;
263
457
  }
264
458
  if (!reviewCommentBody) {
265
- const latestReviewComment = await getLastCrossCheckReviewComment(owner, repoName, prNumber, token);
266
- reviewCommentBody = latestReviewComment?.body;
267
- reviewCommentId = latestReviewComment?.id;
459
+ try {
460
+ const latestReviewComment = await getLastCrossCheckReviewComment(owner, repoName, prNumber, token);
461
+ reviewCommentBody = latestReviewComment?.body;
462
+ reviewCommentId = latestReviewComment?.id;
463
+ }
464
+ catch (fetchErr) {
465
+ fileLog({ level: 'warn', event: 'review_comment_fetch_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: fetchErr instanceof Error ? fetchErr.message : String(fetchErr) });
466
+ throw fetchErr;
467
+ }
268
468
  }
269
469
  if (!reviewCommentBody) {
270
470
  skipFix('no_review_comment');
@@ -272,17 +472,18 @@ export async function runWorkflow(ctx) {
272
472
  }
273
473
  // Vendor is resolved from the workflow step's reviewer field, same as review/recheck steps.
274
474
  // Use 'origin' to fix with the same vendor that authored the PR (recommended default).
275
- const vendor = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
475
+ // resolveFixVendor extends resolveReviewer with a human-origin fallback so human-authored
476
+ // PRs don't silently skip when no explicit vendor is configured.
477
+ const { vendor, usedHumanFallback } = resolveFixVendor(step.reviewer, origin, config, ctx.smartSwitchFallback);
478
+ if (usedHumanFallback && vendor) {
479
+ fileLog({ level: 'info', event: 'fix_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: 'none', to: vendor, reason: 'human_origin' });
480
+ }
276
481
  if (!vendor) {
277
482
  skipFix('no_vendor');
278
483
  continue;
279
484
  }
280
- // Codex fix not yet implemented — skip gracefully
281
- if (vendor === 'codex') {
282
- skipFix('codex_fix_unsupported');
283
- continue;
284
- }
285
- const fixModel = resolveClaudeModel(config.quality);
485
+ const claudeFixModel = resolveClaudeModel(config.quality, config.vendors.claude);
486
+ const codexFixModel = resolveCodexModel(config.quality, config.vendors.codex);
286
487
  // Guard: don't push more than MAX_CROSSCHECK_COMMITS per PR.
287
488
  // Scope to commits ahead of base so long-lived branches (e.g. staging)
288
489
  // don't count [crosscheck] commits from previously merged PRs.
@@ -293,29 +494,54 @@ export async function runWorkflow(ctx) {
293
494
  continue;
294
495
  }
295
496
  onPhaseChange(`${vendor} fixing...`, { phase: 'fixing' });
296
- // Per-step start timestamp covers attempt + retry + retry-delay wall time
297
- // (the user perceives the whole interval as "the fix step").
298
497
  const fixStepStart = Date.now();
299
498
  let appliedCount = 0;
300
499
  let fixTokensUsed;
301
500
  let fixErr = undefined;
501
+ let activeVendor = vendor;
502
+ const runFix = async (v) => {
503
+ if (v === 'codex') {
504
+ return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec));
505
+ }
506
+ return runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', config, 'default', ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec));
507
+ };
302
508
  try {
303
509
  ;
304
- ({ appliedCount, tokensUsed: fixTokensUsed } = await runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', config));
510
+ ({ appliedCount, tokensUsed: fixTokensUsed } = await runFix(vendor));
305
511
  }
306
512
  catch (err) {
307
- logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1 }, err);
308
- fixErr = err;
513
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1, vendor }, err);
514
+ const fallbackVendor = resolveLimitFallbackVendor(vendor, effectiveType, config);
515
+ if (isSubscriptionLimitError(err)) {
516
+ const reason = err instanceof Error ? err.message : String(err);
517
+ ctx.onVendorLimit?.(vendor, fallbackVendor, reason, step.name);
518
+ }
519
+ if (fallbackVendor !== null && (isRetryableFixError(err) || isSubscriptionLimitError(err))) {
520
+ log(chalk.yellow(`⚠ ${vendor} fix failed — falling back to ${fallbackVendor}...`));
521
+ fileLog({ level: 'warn', event: 'fix_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: vendor, to: fallbackVendor, ...(isSubscriptionLimitError(err) && { reason: 'vendor_limit' }) });
522
+ try {
523
+ ;
524
+ ({ appliedCount, tokensUsed: fixTokensUsed } = await runFix(fallbackVendor));
525
+ activeVendor = fallbackVendor;
526
+ }
527
+ catch (fallbackErr) {
528
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1, vendor: fallbackVendor }, fallbackErr);
529
+ fixErr = fallbackErr;
530
+ }
531
+ }
532
+ else {
533
+ fixErr = err;
534
+ }
309
535
  }
310
536
  if (fixErr !== undefined && isRetryableFixError(fixErr)) {
311
537
  log(chalk.yellow(`⚠ fix step failed — retrying in 2 min...`));
312
538
  onPhaseChange('fix retry in 2 min...', { phase: 'fixing' });
313
539
  fileLog({ level: 'info', event: 'fix_retry_scheduled', repo: `${owner}/${repoName}`, pr: prNumber });
314
540
  await new Promise(resolve => setTimeout(resolve, FIX_RETRY_DELAY_MS));
315
- onPhaseChange(`${vendor} fixing (retry)...`, { phase: 'fixing' });
541
+ onPhaseChange(`${activeVendor} fixing (retry)...`, { phase: 'fixing' });
316
542
  try {
317
543
  ;
318
- ({ appliedCount, tokensUsed: fixTokensUsed } = await runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', config));
544
+ ({ appliedCount, tokensUsed: fixTokensUsed } = await runFix(activeVendor));
319
545
  fileLog({ level: 'info', event: 'fix_retry_succeeded', repo: `${owner}/${repoName}`, pr: prNumber });
320
546
  fixErr = undefined;
321
547
  }
@@ -325,7 +551,7 @@ export async function runWorkflow(ctx) {
325
551
  }
326
552
  }
327
553
  if (fixErr !== undefined) {
328
- skipFix('fix_error');
554
+ skipFix(isSubscriptionLimitError(fixErr) ? 'vendor_limit' : 'fix_error');
329
555
  // Only notify for transient failures — auth errors are operator issues, not PR author issues
330
556
  if (isRetryableFixError(fixErr)) {
331
557
  try {
@@ -342,7 +568,7 @@ export async function runWorkflow(ctx) {
342
568
  }
343
569
  if (appliedCount === 0) {
344
570
  onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: fixTokensUsed });
345
- results[step.name] = { applied_count: 0 };
571
+ results[step.name] = { applied_count: 0, ...(fixTokensUsed !== undefined && { tokens_used: fixTokensUsed }), vendor };
346
572
  continue;
347
573
  }
348
574
  const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
@@ -352,13 +578,14 @@ export async function runWorkflow(ctx) {
352
578
  }
353
579
  const deliveryMode = config.post_review.auto_fix.delivery.mode;
354
580
  if (deliveryMode === 'commit') {
581
+ const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
355
582
  execSync('git add -A', { cwd: tmpDir });
356
583
  execFileSync('git', [
357
584
  'commit',
358
585
  '-m',
359
586
  `[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by Claude Code`,
360
587
  '-m',
361
- buildCommitTrailers({ reviewer: vendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
588
+ buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
362
589
  ], { cwd: tmpDir });
363
590
  const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
364
591
  execSync(`git push origin HEAD:${pr.head.ref}`, {
@@ -366,8 +593,27 @@ export async function runWorkflow(ctx) {
366
593
  env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
367
594
  });
368
595
  ctx.crosscheckShas.add(newSha);
596
+ // Set a pending status on the pushed commit only when a review/recheck
597
+ // step follows in THIS workflow invocation — that step will release it.
598
+ // Fix-only runs (kickass `--steps fix`) must NOT acquire the lock here:
599
+ // doing so leaves a PENDING status that the separately-dispatched recheck
600
+ // (`--steps recheck`) sees as "in-progress" via checkRemoteLock and skips,
601
+ // permanently orphaning the PENDING status.
602
+ const currentStepIdx = steps.indexOf(step);
603
+ const hasRecheckAfterFix = steps.slice(currentStepIdx + 1).some(s => s.type === 'review' || s.type === 'recheck');
604
+ if (hasRecheckAfterFix) {
605
+ try {
606
+ const lockOctokit = createGithubClient(token);
607
+ await acquireRemoteLock(lockOctokit, owner, repoName, newSha);
608
+ pushedShasNeedingRelease.push(newSha);
609
+ fixPushedShaRequiresRecheck = newSha;
610
+ }
611
+ catch (err) {
612
+ fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
613
+ }
614
+ }
369
615
  onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
370
- fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart });
616
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
371
617
  // Post a summary comment so the silent commit push is visible on the timeline
372
618
  // as a comment card. Best-effort — a failure here must not fail the run.
373
619
  try {
@@ -382,9 +628,10 @@ export async function runWorkflow(ctx) {
382
628
  catch (err) {
383
629
  fileLog({ level: 'warn', event: 'fix_applied_comment_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
384
630
  }
385
- results[step.name] = { applied_count: appliedCount };
631
+ results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
386
632
  }
387
633
  else if (deliveryMode === 'pull_request') {
634
+ const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
388
635
  // Create a fix branch and open a PR targeting the original branch
389
636
  const fixBranch = `fix/cr-${prNumber}-review-issues`;
390
637
  execSync(`git checkout -b ${fixBranch}`, { cwd: tmpDir });
@@ -394,7 +641,7 @@ export async function runWorkflow(ctx) {
394
641
  '-m',
395
642
  `[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by Claude Code`,
396
643
  '-m',
397
- buildCommitTrailers({ reviewer: vendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
644
+ buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
398
645
  ], { cwd: tmpDir });
399
646
  const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
400
647
  execSync(`git push origin HEAD:${fixBranch}`, {
@@ -421,8 +668,8 @@ export async function runWorkflow(ctx) {
421
668
  catch { /* label may not exist in this repo — skip */ }
422
669
  }
423
670
  onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
424
- fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPr.number, tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart });
425
- results[step.name] = { applied_count: appliedCount };
671
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPr.number, tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
672
+ results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
426
673
  }
427
674
  else {
428
675
  // comment: post the diff as a suggested-fix comment, no code push needed (works for fork PRs too)
@@ -437,8 +684,8 @@ export async function runWorkflow(ctx) {
437
684
  await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
438
685
  }
439
686
  onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
440
- fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart });
441
- results[step.name] = { applied_count: appliedCount };
687
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
688
+ results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
442
689
  }
443
690
  }
444
691
  else if (effectiveType === 'conflict-resolve') {
@@ -510,7 +757,7 @@ export async function runWorkflow(ctx) {
510
757
  skipConflictResolve('codex_conflict_resolve_unsupported');
511
758
  continue;
512
759
  }
513
- const conflictResolveModel = resolveClaudeModel(config.quality);
760
+ const conflictResolveModel = resolveClaudeModel(config.quality, config.vendors.claude);
514
761
  const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
515
762
  if (isFork) {
516
763
  try {
@@ -539,7 +786,7 @@ export async function runWorkflow(ctx) {
539
786
  let resolveTokensUsed;
540
787
  try {
541
788
  ;
542
- ({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? '', conflictResolveModel));
789
+ ({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? '', conflictResolveModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec)));
543
790
  }
544
791
  catch (err) {
545
792
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'conflict-resolve', attempt: 1 }, err);
@@ -547,7 +794,7 @@ export async function runWorkflow(ctx) {
547
794
  execSync('git merge --abort', { cwd: tmpDir });
548
795
  }
549
796
  catch { /* ignore */ }
550
- skipConflictResolve('resolve_error');
797
+ skipConflictResolve(isSubscriptionLimitError(err) ? 'vendor_limit' : 'resolve_error');
551
798
  continue;
552
799
  }
553
800
  if (appliedCount === 0) {
@@ -556,7 +803,7 @@ export async function runWorkflow(ctx) {
556
803
  }
557
804
  catch { /* ignore */ }
558
805
  onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: resolveTokensUsed });
559
- results[step.name] = { applied_count: 0 };
806
+ results[step.name] = { applied_count: 0, ...(resolveTokensUsed !== undefined && { tokens_used: resolveTokensUsed }), vendor };
560
807
  continue;
561
808
  }
562
809
  // P2: Verify every conflict region was resolved before committing. Scope the
@@ -646,7 +893,7 @@ export async function runWorkflow(ctx) {
646
893
  fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
647
894
  }
648
895
  onPhaseChange('conflicts resolved ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: resolveTokensUsed });
649
- fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed, duration_ms: Date.now() - conflictResolveStepStart });
896
+ fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed, duration_ms: Date.now() - conflictResolveStepStart, ...triggerField });
650
897
  // Post a summary comment so the silent merge-commit push is visible on the
651
898
  // timeline as a comment card. Best-effort — a failure here must not fail the run.
652
899
  // Prefer the resolver's actual rewrite set; fall back to the originally-conflicted
@@ -664,20 +911,54 @@ export async function runWorkflow(ctx) {
664
911
  catch (err) {
665
912
  fileLog({ level: 'warn', event: 'conflict_resolved_comment_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
666
913
  }
667
- results[step.name] = { applied_count: appliedCount };
914
+ results[step.name] = { applied_count: appliedCount, ...(resolveTokensUsed !== undefined && { tokens_used: resolveTokensUsed }), vendor };
668
915
  }
669
916
  }
670
917
  const verdict = Object.values(results).reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
671
- return { verdict: verdict ?? null };
918
+ const fixAppliedCount = Object.values(results).reduce((acc, r) => {
919
+ if (r.applied_count === undefined)
920
+ return acc;
921
+ return (acc ?? 0) + r.applied_count;
922
+ }, undefined);
923
+ const latestReviewResult = Object.values(results).reverse().find(r => r.commentBody !== undefined);
924
+ return {
925
+ verdict: verdict ?? null,
926
+ fixAppliedCount,
927
+ ...(latestReviewResult?.commentBody && {
928
+ latestReviewComment: {
929
+ body: latestReviewResult.commentBody,
930
+ ...(latestReviewResult.commentId !== undefined && { id: latestReviewResult.commentId }),
931
+ },
932
+ }),
933
+ };
672
934
  }
673
935
  catch (err) {
674
936
  workflowFailed = true;
675
937
  throw err;
676
938
  }
677
939
  finally {
678
- if (pushedShasNeedingRelease.length > 0) {
940
+ if (pushedShasNeedingRelease.length > 0 || fixPushedShaRequiresRecheck !== null) {
679
941
  const lockOctokit = createGithubClient(token);
680
942
  const outcome = workflowFailed ? 'failure' : 'success';
943
+ // A recheck step was expected after the fix but was skipped (by `when`,
944
+ // no_reviewer, max_rounds, etc.). The fix-pushed SHA is in
945
+ // pushedShasNeedingRelease but no recheck ran to confirm the commit, so
946
+ // releasing it as `success` would mislead branch protection. Release it
947
+ // as `failure` instead so the commit remains unreviewed until the recheck
948
+ // runs in a subsequent invocation (e.g. via `crosscheck run --steps recheck`).
949
+ if (fixPushedShaRequiresRecheck !== null) {
950
+ const unrecheckedSha = fixPushedShaRequiresRecheck;
951
+ fixPushedShaRequiresRecheck = null;
952
+ const idx = pushedShasNeedingRelease.indexOf(unrecheckedSha);
953
+ if (idx !== -1)
954
+ pushedShasNeedingRelease.splice(idx, 1);
955
+ try {
956
+ await releaseRemoteLock(lockOctokit, owner, repoName, unrecheckedSha, 'failure');
957
+ }
958
+ catch (err) {
959
+ fileLog({ level: 'warn', event: 'pushed_sha_release_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: unrecheckedSha, error: err instanceof Error ? err.message : String(err) });
960
+ }
961
+ }
681
962
  // Drain via shift() so each released sha is synchronously removed from
682
963
  // the shared array. The command-layer SIGINT/SIGTERM handler iterates
683
964
  // the same array — if a late signal arrives after this finally has
@@ -704,6 +985,8 @@ export async function runWorkflow(ctx) {
704
985
  owner, repoName, prNumber,
705
986
  workflowId, workflowStart, stepsRun, results, workflowFailed,
706
987
  round: ctx.round,
988
+ trigger: ctx.trigger,
989
+ qualityTier: config.quality.tier,
707
990
  }));
708
991
  }
709
992
  }