@humanbased/crosscheck 1.2.0-beta.81 → 1.3.0-beta.100

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 (203) hide show
  1. package/README.md +2 -1
  2. package/crosscheck.config.example.yml +23 -2
  3. package/dist/__tests__/adoption.test.d.ts +2 -0
  4. package/dist/__tests__/adoption.test.d.ts.map +1 -0
  5. package/dist/__tests__/adoption.test.js +175 -0
  6. package/dist/__tests__/adoption.test.js.map +1 -0
  7. package/dist/__tests__/auto-fix-branch.test.d.ts +2 -0
  8. package/dist/__tests__/auto-fix-branch.test.d.ts.map +1 -0
  9. package/dist/__tests__/auto-fix-branch.test.js +143 -0
  10. package/dist/__tests__/auto-fix-branch.test.js.map +1 -0
  11. package/dist/__tests__/board.test.js +2 -0
  12. package/dist/__tests__/board.test.js.map +1 -1
  13. package/dist/__tests__/codex-env.test.d.ts +2 -0
  14. package/dist/__tests__/codex-env.test.d.ts.map +1 -0
  15. package/dist/__tests__/codex-env.test.js +70 -0
  16. package/dist/__tests__/codex-env.test.js.map +1 -0
  17. package/dist/__tests__/codex.test.js +89 -1
  18. package/dist/__tests__/codex.test.js.map +1 -1
  19. package/dist/__tests__/commit-subject.test.d.ts +2 -0
  20. package/dist/__tests__/commit-subject.test.d.ts.map +1 -0
  21. package/dist/__tests__/commit-subject.test.js +72 -0
  22. package/dist/__tests__/commit-subject.test.js.map +1 -0
  23. package/dist/__tests__/credential-free-origin.test.d.ts +2 -0
  24. package/dist/__tests__/credential-free-origin.test.d.ts.map +1 -0
  25. package/dist/__tests__/credential-free-origin.test.js +59 -0
  26. package/dist/__tests__/credential-free-origin.test.js.map +1 -0
  27. package/dist/__tests__/detector.test.js +87 -1
  28. package/dist/__tests__/detector.test.js.map +1 -1
  29. package/dist/__tests__/error-classification.test.js +34 -0
  30. package/dist/__tests__/error-classification.test.js.map +1 -1
  31. package/dist/__tests__/fix.test.js +37 -1
  32. package/dist/__tests__/fix.test.js.map +1 -1
  33. package/dist/__tests__/harness-section.test.d.ts +2 -0
  34. package/dist/__tests__/harness-section.test.d.ts.map +1 -0
  35. package/dist/__tests__/harness-section.test.js +46 -0
  36. package/dist/__tests__/harness-section.test.js.map +1 -0
  37. package/dist/__tests__/kickass.test.js +31 -0
  38. package/dist/__tests__/kickass.test.js.map +1 -1
  39. package/dist/__tests__/linear-worker.test.js.map +1 -1
  40. package/dist/__tests__/loader.test.js +2 -2
  41. package/dist/__tests__/loader.test.js.map +1 -1
  42. package/dist/__tests__/onboard-preservation.test.js +0 -1
  43. package/dist/__tests__/onboard-preservation.test.js.map +1 -1
  44. package/dist/__tests__/optimize.test.js +1 -1
  45. package/dist/__tests__/optimize.test.js.map +1 -1
  46. package/dist/__tests__/pr-status.test.js +213 -3
  47. package/dist/__tests__/pr-status.test.js.map +1 -1
  48. package/dist/__tests__/pr-workflow-state.test.js +247 -7
  49. package/dist/__tests__/pr-workflow-state.test.js.map +1 -1
  50. package/dist/__tests__/repository-guidance.test.js +21 -21
  51. package/dist/__tests__/repository-guidance.test.js.map +1 -1
  52. package/dist/__tests__/reviewed-sha.test.d.ts +2 -0
  53. package/dist/__tests__/reviewed-sha.test.d.ts.map +1 -0
  54. package/dist/__tests__/reviewed-sha.test.js +116 -0
  55. package/dist/__tests__/reviewed-sha.test.js.map +1 -0
  56. package/dist/__tests__/runner.test.js +178 -2
  57. package/dist/__tests__/runner.test.js.map +1 -1
  58. package/dist/__tests__/skill-broker.test.js +44 -2
  59. package/dist/__tests__/skill-broker.test.js.map +1 -1
  60. package/dist/__tests__/superseded-fix-pr.test.d.ts +2 -0
  61. package/dist/__tests__/superseded-fix-pr.test.d.ts.map +1 -0
  62. package/dist/__tests__/superseded-fix-pr.test.js +225 -0
  63. package/dist/__tests__/superseded-fix-pr.test.js.map +1 -0
  64. package/dist/__tests__/webhook.test.js +60 -0
  65. package/dist/__tests__/webhook.test.js.map +1 -1
  66. package/dist/cli.js +8 -0
  67. package/dist/cli.js.map +1 -1
  68. package/dist/commands/adoption.d.ts +6 -0
  69. package/dist/commands/adoption.d.ts.map +1 -0
  70. package/dist/commands/adoption.js +132 -0
  71. package/dist/commands/adoption.js.map +1 -0
  72. package/dist/commands/detect-step.d.ts.map +1 -1
  73. package/dist/commands/detect-step.js +5 -1
  74. package/dist/commands/detect-step.js.map +1 -1
  75. package/dist/commands/diagnose.d.ts.map +1 -1
  76. package/dist/commands/diagnose.js +2 -2
  77. package/dist/commands/diagnose.js.map +1 -1
  78. package/dist/commands/init.d.ts.map +1 -1
  79. package/dist/commands/init.js +1 -2
  80. package/dist/commands/init.js.map +1 -1
  81. package/dist/commands/issue.js +1 -1
  82. package/dist/commands/issue.js.map +1 -1
  83. package/dist/commands/kickass.d.ts +1 -1
  84. package/dist/commands/kickass.d.ts.map +1 -1
  85. package/dist/commands/kickass.js +36 -22
  86. package/dist/commands/kickass.js.map +1 -1
  87. package/dist/commands/linear-test.js.map +1 -1
  88. package/dist/commands/onboard.d.ts.map +1 -1
  89. package/dist/commands/onboard.js +30 -3
  90. package/dist/commands/onboard.js.map +1 -1
  91. package/dist/commands/optimize.d.ts.map +1 -1
  92. package/dist/commands/optimize.js +1 -0
  93. package/dist/commands/optimize.js.map +1 -1
  94. package/dist/commands/review.d.ts.map +1 -1
  95. package/dist/commands/review.js +1 -1
  96. package/dist/commands/review.js.map +1 -1
  97. package/dist/commands/run.d.ts.map +1 -1
  98. package/dist/commands/run.js +40 -4
  99. package/dist/commands/run.js.map +1 -1
  100. package/dist/commands/scan.d.ts.map +1 -1
  101. package/dist/commands/scan.js +6 -0
  102. package/dist/commands/scan.js.map +1 -1
  103. package/dist/commands/watch.d.ts.map +1 -1
  104. package/dist/commands/watch.js +132 -61
  105. package/dist/commands/watch.js.map +1 -1
  106. package/dist/config/loader.js +6 -6
  107. package/dist/config/loader.js.map +1 -1
  108. package/dist/config/schema.d.ts +8 -0
  109. package/dist/config/schema.d.ts.map +1 -1
  110. package/dist/config/schema.js +19 -2
  111. package/dist/config/schema.js.map +1 -1
  112. package/dist/github/detector.d.ts.map +1 -1
  113. package/dist/github/detector.js +28 -1
  114. package/dist/github/detector.js.map +1 -1
  115. package/dist/github/merge.d.ts +0 -5
  116. package/dist/github/merge.d.ts.map +1 -1
  117. package/dist/github/merge.js +0 -9
  118. package/dist/github/merge.js.map +1 -1
  119. package/dist/github/reviewed-sha.d.ts +13 -0
  120. package/dist/github/reviewed-sha.d.ts.map +1 -0
  121. package/dist/github/reviewed-sha.js +62 -0
  122. package/dist/github/reviewed-sha.js.map +1 -0
  123. package/dist/github/superseded-fix-pr.d.ts +19 -0
  124. package/dist/github/superseded-fix-pr.d.ts.map +1 -0
  125. package/dist/github/superseded-fix-pr.js +97 -0
  126. package/dist/github/superseded-fix-pr.js.map +1 -0
  127. package/dist/github/webhook.d.ts +4 -1
  128. package/dist/github/webhook.d.ts.map +1 -1
  129. package/dist/github/webhook.js +8 -1
  130. package/dist/github/webhook.js.map +1 -1
  131. package/dist/lib/adoption.d.ts +64 -0
  132. package/dist/lib/adoption.d.ts.map +1 -0
  133. package/dist/lib/adoption.js +165 -0
  134. package/dist/lib/adoption.js.map +1 -0
  135. package/dist/lib/annotation.js +0 -7
  136. package/dist/lib/annotation.js.map +1 -1
  137. package/dist/lib/auto-fix-branch.d.ts +35 -0
  138. package/dist/lib/auto-fix-branch.d.ts.map +1 -0
  139. package/dist/lib/auto-fix-branch.js +88 -0
  140. package/dist/lib/auto-fix-branch.js.map +1 -0
  141. package/dist/lib/clone.d.ts +1 -0
  142. package/dist/lib/clone.d.ts.map +1 -1
  143. package/dist/lib/clone.js +38 -1
  144. package/dist/lib/clone.js.map +1 -1
  145. package/dist/lib/comment-bodies.d.ts.map +1 -1
  146. package/dist/lib/comment-bodies.js +6 -3
  147. package/dist/lib/comment-bodies.js.map +1 -1
  148. package/dist/lib/logger.d.ts.map +1 -1
  149. package/dist/lib/logger.js +17 -1
  150. package/dist/lib/logger.js.map +1 -1
  151. package/dist/lib/pr-picker.d.ts +1 -1
  152. package/dist/lib/pr-picker.d.ts.map +1 -1
  153. package/dist/lib/pr-picker.js +10 -5
  154. package/dist/lib/pr-picker.js.map +1 -1
  155. package/dist/lib/pr-status.d.ts +2 -1
  156. package/dist/lib/pr-status.d.ts.map +1 -1
  157. package/dist/lib/pr-status.js +70 -6
  158. package/dist/lib/pr-status.js.map +1 -1
  159. package/dist/lib/pr-workflow-state.d.ts +17 -1
  160. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  161. package/dist/lib/pr-workflow-state.js +110 -5
  162. package/dist/lib/pr-workflow-state.js.map +1 -1
  163. package/dist/lib/repo-picker.d.ts.map +1 -1
  164. package/dist/lib/repo-picker.js +0 -1
  165. package/dist/lib/repo-picker.js.map +1 -1
  166. package/dist/lib/runner.d.ts +31 -1
  167. package/dist/lib/runner.d.ts.map +1 -1
  168. package/dist/lib/runner.js +460 -58
  169. package/dist/lib/runner.js.map +1 -1
  170. package/dist/lib/vendor.d.ts +1 -0
  171. package/dist/lib/vendor.d.ts.map +1 -1
  172. package/dist/lib/vendor.js +6 -0
  173. package/dist/lib/vendor.js.map +1 -1
  174. package/dist/lib/workflow.d.ts +4 -0
  175. package/dist/lib/workflow.d.ts.map +1 -1
  176. package/dist/lib/workflow.js +4 -1
  177. package/dist/lib/workflow.js.map +1 -1
  178. package/dist/linear/comment.d.ts.map +1 -1
  179. package/dist/linear/comment.js.map +1 -1
  180. package/dist/reviewers/address.d.ts +1 -1
  181. package/dist/reviewers/address.d.ts.map +1 -1
  182. package/dist/reviewers/address.js +1 -1
  183. package/dist/reviewers/address.js.map +1 -1
  184. package/dist/reviewers/codex-env.d.ts +12 -0
  185. package/dist/reviewers/codex-env.d.ts.map +1 -0
  186. package/dist/reviewers/codex-env.js +54 -0
  187. package/dist/reviewers/codex-env.js.map +1 -0
  188. package/dist/reviewers/codex.d.ts +16 -1
  189. package/dist/reviewers/codex.d.ts.map +1 -1
  190. package/dist/reviewers/codex.js +110 -33
  191. package/dist/reviewers/codex.js.map +1 -1
  192. package/dist/reviewers/fix.d.ts +1 -1
  193. package/dist/reviewers/fix.d.ts.map +1 -1
  194. package/dist/reviewers/fix.js +16 -6
  195. package/dist/reviewers/fix.js.map +1 -1
  196. package/dist/skills/broker.d.ts +2 -1
  197. package/dist/skills/broker.d.ts.map +1 -1
  198. package/dist/skills/broker.js +28 -2
  199. package/dist/skills/broker.js.map +1 -1
  200. package/docs/metrics.md +115 -0
  201. package/docs/trust.md +159 -0
  202. package/get-started.md +115 -5
  203. package/package.json +3 -1
@@ -4,12 +4,15 @@ import { readFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import chalk from 'chalk';
6
6
  import { filterStepsByTypes } from './repo-workflow.js';
7
+ import { vendorDisplayName } from '../lib/vendor.js';
7
8
  import { runCodexReview } from '../reviewers/codex.js';
8
9
  import { runClaudeReview } from '../reviewers/claude.js';
9
10
  import { runFixStep, runCodexFixStep } from '../reviewers/fix.js';
10
11
  import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
11
12
  import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING, applySeverityGate, SEVERITY_GATE_NOTE } from '../lib/verdict.js';
12
13
  import { createGithubClient, postReviewComment, getLastCrossCheckCommentId, getLastCrossCheckReviewComment } from '../github/client.js';
14
+ import { autoFixBranchName, autoFixPRIntro, sourcePRHasMerged } from '../github/superseded-fix-pr.js';
15
+ import { verifyReviewedSha, isVerifiedReviewedSha, reviewedShaRejection } from '../github/reviewed-sha.js';
13
16
  import { resolveLinearAuth, withWorker } from '../linear/identity.js';
14
17
  import { notifyLinear } from '../linear/notify.js';
15
18
  import { shouldPostToLinear } from '../linear/comment.js';
@@ -21,6 +24,8 @@ import { resolveClaudeModel, resolveCodexModel } from '../lib/review-models.js';
21
24
  import { resolveReviewStrategy, escalate, clampToLevels } from './review-strategy.js';
22
25
  import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS } from '../config/schema.js';
23
26
  import { buildStepIdentityFields } from '../lib/event-fields.js';
27
+ import { planAutoFixDelivery, forceWithLeaseArgs, parseLsRemoteOid, isLeaseRejection, assessFixBranchOwnership, isInvalidBaseError } from '../lib/auto-fix-branch.js';
28
+ import { prOpenToVerdictMs } from '../lib/adoption.js';
24
29
  import { buildAttributionFooter, buildFixAppliedCommentBody, buildFixFailedCommentBody, buildConflictResolvedCommentBody, buildRetriedReviewBanner } from '../lib/comment-bodies.js';
25
30
  import { linearWritePossible, loadWorkflow, loadHarnessSection, evaluateWhen } from '../lib/workflow.js';
26
31
  import { isSubscriptionLimitError, isVendorUnavailableError } from '../lib/smart-switch.js';
@@ -31,7 +36,6 @@ import { formatSkillAttribution } from '../skills/attribution.js';
31
36
  const MAX_CROSSCHECK_COMMITS = 5;
32
37
  const FIX_RETRY_DELAY_MS = 2 * 60 * 1000;
33
38
  const REVIEW_RETRY_DELAY_MS = 2 * 60 * 1000;
34
- const GIT_PUSH_RETRY_DELAY_MS = 3000;
35
39
  // Per-vendor configured timeout (seconds) → execa milliseconds, or undefined when
36
40
  // unset so the reviewer falls back to its built-in default. A per-run override
37
41
  // (ctx.overrideTimeoutMs, set by --timeout / --crazy / --halfcrazy) always wins
@@ -138,6 +142,52 @@ export function exceedsMaxRounds(effectiveType, originalStepType, maxRounds, rou
138
142
  export function anyFixApplied(results) {
139
143
  return Object.values(results).some(r => (r.applied_count ?? 0) > 0);
140
144
  }
145
+ export function resolveFixLanding(deliveryMode) {
146
+ switch (deliveryMode) {
147
+ case 'comment': return 'comment';
148
+ case 'commit': return 'branch';
149
+ case 'pull_request': return 'branch-then-separate-pr';
150
+ }
151
+ }
152
+ // stepsRun holds every step the runner dispatched, skips included; results holds
153
+ // what each one did. Split them so callers can report a run where nothing
154
+ // happened without re-deriving the reasons from the log file.
155
+ export function summariseStepOutcomes(stepsRun, results) {
156
+ const outcomes = { ran: [], skipped: [] };
157
+ for (const name of new Set(stepsRun)) {
158
+ const result = results[name];
159
+ if (result?.skipped)
160
+ outcomes.skipped.push({ step: name, reason: result.skipReason ?? 'unknown' });
161
+ else
162
+ outcomes.ran.push(name);
163
+ }
164
+ return outcomes;
165
+ }
166
+ // Accumulates outcomes across the fix→recheck rounds of one invocation, so the
167
+ // completion line reports whether the run as a whole did work: a first round
168
+ // that skips everything followed by a round that applies a fix is not a
169
+ // "no step ran" run.
170
+ //
171
+ // Running once wins over skipping any number of times — the step demonstrably
172
+ // happened. A step skipped in several rounds is reported once, carrying its
173
+ // latest reason: that is the state the run ended in, and listing the same step
174
+ // several times reads as several distinct problems.
175
+ export function mergeStepOutcomes(base, next) {
176
+ if (!base)
177
+ return next;
178
+ if (!next)
179
+ return base;
180
+ const ran = [...new Set([...base.ran, ...next.ran])];
181
+ const ranSet = new Set(ran);
182
+ // Map.set on an existing key overwrites the reason but keeps the original
183
+ // position, so order stays first-seen while the reason stays last-seen.
184
+ const skipped = new Map();
185
+ for (const entry of [...base.skipped, ...next.skipped]) {
186
+ if (!ranSet.has(entry.step))
187
+ skipped.set(entry.step, entry.reason);
188
+ }
189
+ return { ran, skipped: [...skipped].map(([step, reason]) => ({ step, reason })) };
190
+ }
141
191
  function countComments(reviewText) {
142
192
  const bullets = (reviewText.match(/^[-*•]\s/gm) ?? []).length;
143
193
  const numbered = (reviewText.match(/^\d+\.\s/gm) ?? []).length;
@@ -178,16 +228,54 @@ function resolveLimitFallbackVendor(failedVendor, stepType, config) {
178
228
  const fallback = failedVendor === 'claude' ? 'codex' : 'claude';
179
229
  return config.vendors[fallback].enabled && supportsStep(fallback, stepType) ? fallback : null;
180
230
  }
181
- // Extends resolveReviewer with a human-origin fallback for the fix step.
231
+ // ─── commit subjects ──────────────────────────────────────────────────────────
232
+ // The vendor is a parameter, not a constant: the vendor that ran the step is
233
+ // known at every call site, and a subject crediting a different one contradicts
234
+ // the Crosscheck-Reviewer trailer on its own commit.
235
+ // The `[crosscheck]` prefix and the step word after it are parsed —
236
+ // countCrosscheckCommitsForPR greps the prefix to enforce the commit cap — so
237
+ // only the trailing attribution varies.
238
+ export function fixCommitSubject(appliedCount, vendor) {
239
+ return `[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by ${vendorDisplayName(vendor)}`;
240
+ }
241
+ export function fixPRCommitSubject(prNumber, vendor) {
242
+ return `[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by ${vendorDisplayName(vendor)}`;
243
+ }
244
+ export function conflictResolveCommitSubject(conflictCount, vendor) {
245
+ return `[crosscheck] resolve: resolve ${conflictCount} conflict${conflictCount !== 1 ? 's' : ''} — by ${vendorDisplayName(vendor)}`;
246
+ }
247
+ // Extends resolveReviewer with a human-origin fallback for the steps that write
248
+ // code (fix, conflict-resolve).
182
249
  // Scoped to reviewer: 'origin' only — other reviewer types (claude, codex, auto)
183
250
  // already encode explicit vendor intent and need no fallback.
184
251
  // When origin is 'human' and no vendor resolved, honours routing.fallback_reviewer
185
- // so the fix step respects the same routing intent as the review step.
186
- // 'auto' mirrors resolveReviewer's auto path (config-enabled check, codex-first)
187
- // without async auth calls. null disables the fallback entirely.
188
- // Exported so callers can detect when the fallback was applied (e.g. for logging).
189
- export function resolveFixVendor(stepReviewer, origin, config, fallback) {
252
+ // so the step respects the same routing intent as the review step.
253
+ // 'auto' resolves against the vendors that can actually run stepType, so a step
254
+ // only one vendor supports doesn't fall back to the other and skip a line later.
255
+ // An explicit 'claude'/'codex' is an operator decision and is honoured as written
256
+ // even when that vendor cannot run the step — the caller then reports a precise
257
+ // unsupported-step skip instead of silently substituting a different vendor.
258
+ // null disables the fallback entirely.
259
+ function resolveStepVendor(stepType, stepReviewer, origin, config, fallback) {
190
260
  const vendor = resolveReviewer(stepReviewer, origin, config, fallback);
261
+ // Origin detection can assign a vendor that cannot run the step: conflict
262
+ // resolution is Claude-only, so a Codex-origin PR (reviewer: 'origin', origin:
263
+ // 'codex') resolves to 'codex' and the dispatch skips it as unsupported — the
264
+ // conflicts never get resolved. (#284's `Crosscheck-Reviewer: codex` detection
265
+ // introduced this: these crosscheck-authored fix PRs used to detect as 'human'
266
+ // and the auto fallback picked Claude.) Substitute a capable, enabled vendor.
267
+ // Scoped to origin-derived assignment only — an explicit reviewer: claude|codex,
268
+ // reviewer: auto, or routing.fallback_reviewer is an operator decision, left as
269
+ // written so the caller can report the precise unsupported-step skip.
270
+ if (stepReviewer === 'origin' &&
271
+ (origin === 'claude' || origin === 'codex') &&
272
+ vendor !== null &&
273
+ !supportsStep(vendor, stepType)) {
274
+ const capable = vendor === 'claude' ? 'codex' : 'claude';
275
+ if (config.vendors[capable].enabled && supportsStep(capable, stepType)) {
276
+ return { vendor: capable, usedHumanFallback: false, substitutedOriginVendor: vendor };
277
+ }
278
+ }
191
279
  if (vendor !== null || origin !== 'human' || stepReviewer !== 'origin') {
192
280
  return { vendor, usedHumanFallback: false };
193
281
  }
@@ -198,13 +286,25 @@ export function resolveFixVendor(stepReviewer, origin, config, fallback) {
198
286
  else if (fb === 'codex')
199
287
  humanFallback = config.vendors.codex.enabled ? 'codex' : null;
200
288
  else if (fb !== null) {
201
- // 'auto': prefer codex then claude, same as resolveReviewer's auto path
202
- humanFallback = config.vendors.codex.enabled ? 'codex' : config.vendors.claude.enabled ? 'claude' : null;
289
+ // 'auto': prefer codex then claude, same as resolveReviewer's auto path,
290
+ // narrowed to vendors that support this step type.
291
+ const usable = (v) => config.vendors[v].enabled && supportsStep(v, stepType);
292
+ humanFallback = usable('codex') ? 'codex' : usable('claude') ? 'claude' : null;
203
293
  }
204
294
  if (!humanFallback)
205
295
  return { vendor: null, usedHumanFallback: false };
206
296
  return { vendor: humanFallback, usedHumanFallback: true };
207
297
  }
298
+ // Exported so callers can detect when the fallback was applied (e.g. for logging).
299
+ export function resolveFixVendor(stepReviewer, origin, config, fallback) {
300
+ return resolveStepVendor('fix', stepReviewer, origin, config, fallback);
301
+ }
302
+ // The default workflow gives conflict-resolve `reviewer: origin`, so every PR
303
+ // crosscheck cannot attribute resolved to null here and skipped with 'no_vendor'
304
+ // — the fix step honoured routing.fallback_reviewer, this one did not.
305
+ export function resolveConflictResolveVendor(stepReviewer, origin, config, fallback) {
306
+ return resolveStepVendor('conflict-resolve', stepReviewer, origin, config, fallback);
307
+ }
208
308
  // ─── pr_complexity helpers ────────────────────────────────────────────────────
209
309
  const EXT_LANG = {
210
310
  ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
@@ -456,7 +556,7 @@ async function pushWithNonFastForwardHandling(params) {
456
556
  const { tmpDir, branch, token, log, fileLog, owner, repoName, prNumber } = params;
457
557
  const env = { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token };
458
558
  try {
459
- execSync(`git push origin HEAD:${branch}`, { cwd: tmpDir, env });
559
+ execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
460
560
  }
461
561
  catch (pushErr) {
462
562
  const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
@@ -472,11 +572,11 @@ async function pushWithNonFastForwardHandling(params) {
472
572
  log(chalk.yellow(`⚠ push rejected (non-fast-forward) — fetching latest and rebasing...`));
473
573
  try {
474
574
  // Fetch the latest state of the branch
475
- execSync(`git fetch origin ${branch}`, { cwd: tmpDir, env, stdio: 'pipe' });
575
+ execFileSync('git', ['fetch', 'origin', branch], { cwd: tmpDir, env, stdio: 'pipe' });
476
576
  // Rebase our changes onto the latest branch state
477
- execSync(`git rebase origin/${branch}`, { cwd: tmpDir, env, stdio: 'pipe' });
577
+ execFileSync('git', ['rebase', `origin/${branch}`], { cwd: tmpDir, env, stdio: 'pipe' });
478
578
  // Retry the push
479
- execSync(`git push origin HEAD:${branch}`, { cwd: tmpDir, env });
579
+ execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
480
580
  fileLog({
481
581
  level: 'info',
482
582
  event: 'push_rebase_succeeded',
@@ -692,7 +792,7 @@ export async function runWorkflow(ctx) {
692
792
  const effectiveType = getEffectiveStepType(step.type, ctx.isRecheckRun === true);
693
793
  if (exceedsMaxRounds(effectiveType, step.type, ctx.overrideMaxRounds ?? step.max_rounds, ctx.round)) {
694
794
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'max_rounds' });
695
- results[step.name] = { skipped: true };
795
+ results[step.name] = { skipped: true, skipReason: 'max_rounds' };
696
796
  if (effectiveType === 'fix')
697
797
  onPhaseChange('', { phase: 'fixed', fixCount: 0 });
698
798
  else if (effectiveType === 'recheck')
@@ -704,7 +804,7 @@ export async function runWorkflow(ctx) {
704
804
  // Evaluate when condition — skip step if false
705
805
  if (step.when && !evaluateWhen(step.when, results)) {
706
806
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'when_condition' });
707
- results[step.name] = { skipped: true };
807
+ results[step.name] = { skipped: true, skipReason: 'when_condition' };
708
808
  if (effectiveType === 'fix')
709
809
  onPhaseChange('', { phase: 'fixed', fixCount: 0 });
710
810
  else if (effectiveType === 'recheck')
@@ -718,7 +818,7 @@ export async function runWorkflow(ctx) {
718
818
  // review coerced to a recheck (isRecheckRun) is never blocked here.
719
819
  if (step.type === 'recheck' && reviewRanThisSession && !anyFixApplied(results)) {
720
820
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_change_since_review' });
721
- results[step.name] = { skipped: true };
821
+ results[step.name] = { skipped: true, skipReason: 'no_change_since_review' };
722
822
  onPhaseChange('', { phase: 'rechecked' });
723
823
  continue;
724
824
  }
@@ -727,7 +827,7 @@ export async function runWorkflow(ctx) {
727
827
  let reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
728
828
  if (!reviewer) {
729
829
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
730
- results[step.name] = { skipped: true };
830
+ results[step.name] = { skipped: true, skipReason: 'no_reviewer' };
731
831
  continue;
732
832
  }
733
833
  // The recheck step is confirmed to run — clear the pending-recheck guard
@@ -756,7 +856,7 @@ export async function runWorkflow(ctx) {
756
856
  const runReviewWithVendor = async (candidate) => {
757
857
  if (candidate === 'codex') {
758
858
  ;
759
- ({ review: rawReview, tokensUsed, model, effort, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title, quality, codexVendor, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec), log, ctx.issueContext, skillSession));
859
+ ({ review: rawReview, tokensUsed, model, effort, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title, quality, codexVendor, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec), log, ctx.issueContext, skillSession, config.skills.codex_full_access));
760
860
  inputTokens = undefined;
761
861
  outputTokens = undefined;
762
862
  }
@@ -851,7 +951,12 @@ export async function runWorkflow(ctx) {
851
951
  ? `${buildRetriedReviewBanner(retried.timeoutMs, retried.delayMs)}\n\n${baseBody}`
852
952
  : baseBody;
853
953
  const commentCount = countComments(rawReview);
854
- fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, tokens_used: tokensUsed, skills_activated: activatedSkills.map(skill => skill.name), ...(inputTokens !== undefined && { input_tokens: inputTokens }), ...(outputTokens !== undefined && { output_tokens: outputTokens }), ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }), ...triggerField });
954
+ // How long the PR waited for a verdict, measured from when its author opened
955
+ // it — the number a team feels, as distinct from duration_ms (how long the
956
+ // reviewer ran). Omitted rather than guessed when the PR event carried no
957
+ // created_at, so the metric never mixes real latencies with invented ones.
958
+ const openToVerdictMs = prOpenToVerdictMs(pr.created_at, verdict);
959
+ fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, ...(openToVerdictMs !== undefined && { open_to_verdict_ms: openToVerdictMs }), tokens_used: tokensUsed, skills_activated: activatedSkills.map(skill => skill.name), ...(inputTokens !== undefined && { input_tokens: inputTokens }), ...(outputTokens !== undefined && { output_tokens: outputTokens }), ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }), ...triggerField });
855
960
  // Recheck verdict is stored separately to preserve the original review's commentCount on the board
856
961
  const phaseUpdate = isRecheck
857
962
  ? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: quality.tier }
@@ -890,6 +995,28 @@ export async function runWorkflow(ctx) {
890
995
  annotationSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
891
996
  }
892
997
  catch { /* fall back to pr.head.sha if git is unavailable */ }
998
+ // ...but the clone's HEAD is only trustworthy once it is in the repo. A fix
999
+ // commit whose push failed leaves HEAD one commit ahead of the remote, and
1000
+ // stamping it attributes this verdict to code that does not exist — an APPROVE
1001
+ // on a phantom sha clears the previous BLOCK and reads as routine on the PR
1002
+ // page. Refuse to post rather than post an unattributable verdict: a missing
1003
+ // review is recoverable (the run fails, the pending status releases as
1004
+ // failure, the PR stays blocked), a phantom approval is not.
1005
+ const shaVerification = await verifyReviewedSha(octokit, owner, repoName, prNumber, annotationSha);
1006
+ if (!isVerifiedReviewedSha(shaVerification.status)) {
1007
+ const rejection = reviewedShaRejection(shaVerification, annotationSha, verdict);
1008
+ fileLog({ level: 'error', event: 'verdict_sha_unverified', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, verdict, sha: annotationSha, head_sha: shaVerification.headSha, verification: shaVerification.status, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
1009
+ log(chalk.red(`✗ ${rejection}`));
1010
+ // The review itself cost vendor tokens — surface it rather than lose it.
1011
+ log(chalk.dim(`\n--- unposted review ---\n${commentBody}\n--- end ---`));
1012
+ throw new Error(rejection);
1013
+ }
1014
+ if (shaVerification.status === 'descendant') {
1015
+ // The reviewed commit is real but ahead of the PR head — the fix landed on a
1016
+ // separate auto-fix branch. Recorded so a verdict that does not cover HEAD is
1017
+ // explicable from the log rather than looking like the head-matching case.
1018
+ fileLog({ level: 'warn', event: 'verdict_sha_off_head', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, verdict, sha: annotationSha, head_sha: shaVerification.headSha, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
1019
+ }
893
1020
  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, activatedSkills, effort,
894
1021
  // Withheld when an explicit vendors.*.model overrode the tier map:
895
1022
  // citing a tier the run did not use would assert a routing decision
@@ -899,6 +1026,16 @@ export async function runWorkflow(ctx) {
899
1026
  : undefined);
900
1027
  const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
901
1028
  fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
1029
+ // A posted verdict that blocks the merge is the product's whole reason to
1030
+ // exist, so it gets its own event rather than being re-derived downstream.
1031
+ // BLOCK blocks by definition; a NEEDS WORK that reached here survived the
1032
+ // severity gate, which only lets it through when a Critical/High/Medium
1033
+ // finding backs it — a nit-only review was already downgraded to APPROVE.
1034
+ // Logged only here, after the comment actually posted, so dry runs and
1035
+ // failed postReviewComment calls are never counted as posted findings.
1036
+ if (verdict === 'BLOCK' || verdict === 'NEEDS WORK') {
1037
+ fileLog({ level: 'info', event: 'blocking_finding_posted', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
1038
+ }
902
1039
  // Mirror the verdict onto the PR's Linear issue. `run` and `watch` both
903
1040
  // land here, so this is the path that matters — reviews posted from
904
1041
  // commands/review.ts are the exception, not the rule.
@@ -938,7 +1075,7 @@ export async function runWorkflow(ctx) {
938
1075
  const skipFix = (reason) => {
939
1076
  lastFixSkipReason = reason;
940
1077
  onPhaseChange('', { phase: 'fixed', fixCount: 0 });
941
- results[step.name] = { skipped: true };
1078
+ results[step.name] = { skipped: true, skipReason: reason };
942
1079
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
943
1080
  };
944
1081
  if (ctx.dryRun) {
@@ -1028,7 +1165,7 @@ export async function runWorkflow(ctx) {
1028
1165
  const skillSession = skillSessionFor(step.name, effectiveType);
1029
1166
  const runFix = async (v) => {
1030
1167
  if (v === 'codex') {
1031
- return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs, skillSession, codexVendor.effort);
1168
+ return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs, skillSession, codexVendor.effort, config.skills.codex_full_access);
1032
1169
  }
1033
1170
  // roundConfig, not config: runFixStep reads vendors.claude.effort and
1034
1171
  // quality.tier out of it, and both must be this round's values.
@@ -1137,13 +1274,14 @@ export async function runWorkflow(ctx) {
1137
1274
  continue;
1138
1275
  }
1139
1276
  const deliveryMode = config.post_review.auto_fix.delivery.mode;
1140
- if (deliveryMode === 'commit') {
1277
+ const landing = resolveFixLanding(deliveryMode);
1278
+ if (landing === 'branch') {
1141
1279
  const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
1142
1280
  execSync('git add -A', { cwd: tmpDir });
1143
1281
  execFileSync('git', [
1144
1282
  'commit',
1145
1283
  '-m',
1146
- `[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by Claude Code`,
1284
+ fixCommitSubject(appliedCount, activeVendor),
1147
1285
  '-m',
1148
1286
  buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
1149
1287
  ], { cwd: tmpDir });
@@ -1202,52 +1340,306 @@ export async function runWorkflow(ctx) {
1202
1340
  }
1203
1341
  results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
1204
1342
  }
1205
- else if (deliveryMode === 'pull_request') {
1343
+ else if (landing === 'branch-then-separate-pr') {
1206
1344
  const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
1207
- // Create a fix branch and open a PR targeting the original branch
1208
- const fixBranch = `fix/cr-${prNumber}-review-issues`;
1209
- execSync(`git checkout -b ${fixBranch}`, { cwd: tmpDir });
1345
+ // Commit the fix on the PR's own branch (already checked out in tmpDir) and
1346
+ // try to push it there, so the fix, recheck, and approval all stay on the
1347
+ // original PR. Only when that push can't land — e.g. the PR was merged and
1348
+ // its branch deleted, or the branch is protected — do we fall back to opening
1349
+ // a separate follow-up PR that carries the very same commit.
1210
1350
  execSync('git add -A', { cwd: tmpDir });
1211
1351
  execFileSync('git', [
1212
1352
  'commit',
1213
1353
  '-m',
1214
- `[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by Claude Code`,
1354
+ fixPRCommitSubject(prNumber, activeVendor),
1215
1355
  '-m',
1216
1356
  buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
1217
1357
  ], { cwd: tmpDir });
1218
1358
  const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
1219
- execSync(`git push origin HEAD:${fixBranch}`, {
1220
- cwd: tmpDir,
1221
- env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
1222
- });
1359
+ let landedOnBranch = false;
1360
+ try {
1361
+ await pushWithNonFastForwardHandling({
1362
+ tmpDir,
1363
+ branch: pr.head.ref,
1364
+ token,
1365
+ log,
1366
+ fileLog: (entry) => fileLog({ ...entry, phase: 'fix' }),
1367
+ owner,
1368
+ repoName,
1369
+ prNumber,
1370
+ });
1371
+ landedOnBranch = true;
1372
+ }
1373
+ catch (pushErr) {
1374
+ fileLog({ level: 'warn', event: 'fix_branch_push_fell_back', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, branch: pr.head.ref, error: pushErr instanceof Error ? pushErr.message.slice(0, 500) : String(pushErr), fallback: 'pull_request' });
1375
+ }
1223
1376
  ctx.crosscheckShas.add(newSha);
1224
- const octokit = createGithubClient(token);
1225
- const fixPrTitle = config.post_review.auto_fix.delivery.pr_title.replace('#{original_pr_title}', pr.title);
1226
- const { data: fixPr } = await octokit.rest.pulls.create({
1227
- owner,
1228
- repo: repoName,
1229
- head: fixBranch,
1230
- base: pr.head.ref,
1231
- title: fixPrTitle,
1232
- body: [
1233
- `Auto-fix by crosscheck for CR issues found in #${prNumber}.`,
1234
- '',
1235
- `Review: https://github.com/${owner}/${repoName}/pull/${prNumber}`,
1236
- '',
1237
- fixAttributionFooter(),
1238
- ].join('\n'),
1239
- });
1240
- if (config.post_review.auto_fix.delivery.label) {
1377
+ if (landedOnBranch) {
1378
+ // Fix landed on the PR branch — mirror `commit` delivery exactly: set the
1379
+ // pending status the following recheck releases, and post the fix-applied
1380
+ // comment so the push is visible on the PR timeline.
1381
+ const currentStepIdx = steps.indexOf(step);
1382
+ const hasRecheckAfterFix = steps.slice(currentStepIdx + 1).some(s => s.type === 'review' || s.type === 'recheck');
1383
+ if (hasRecheckAfterFix) {
1384
+ try {
1385
+ const lockOctokit = createGithubClient(token);
1386
+ await acquireRemoteLock(lockOctokit, owner, repoName, newSha);
1387
+ pushedShasNeedingRelease.push(newSha);
1388
+ fixPushedShaRequiresRecheck = newSha;
1389
+ }
1390
+ catch (err) {
1391
+ fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
1392
+ }
1393
+ }
1394
+ onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
1395
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
1241
1396
  try {
1242
- await octokit.rest.issues.addLabels({
1243
- owner, repo: repoName, issue_number: fixPr.number, labels: [config.post_review.auto_fix.delivery.label],
1397
+ const octokit = createGithubClient(token);
1398
+ const body = buildFixAppliedCommentBody({
1399
+ owner, repo: repoName, sha: newSha, appliedCount,
1400
+ reviewCommentId,
1401
+ changedFiles: fixChangedFiles,
1402
+ vendor: activeVendor,
1403
+ reviewCommentBody,
1404
+ model: activeVendor === 'codex' ? codexFixModel : claudeFixModel,
1405
+ effort: fixEffort,
1406
+ skills: activatedSkills,
1244
1407
  });
1408
+ await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
1409
+ fileLog({ level: 'info', event: 'fix_applied_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha });
1410
+ }
1411
+ catch (err) {
1412
+ fileLog({ level: 'warn', event: 'fix_applied_comment_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
1245
1413
  }
1246
- catch { /* label may not exist in this repo — skip */ }
1414
+ results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
1415
+ }
1416
+ else {
1417
+ // Fallback: the fix could not land on the PR branch. Push the same commit to a
1418
+ // dedicated branch and open a follow-up PR targeting the original branch.
1419
+ const fixBranch = autoFixBranchName(prNumber);
1420
+ const gitEnv = { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token };
1421
+ const octokit = createGithubClient(token);
1422
+ // The fallback commit exists only in this clone until something pushes it, so
1423
+ // every path that ends without delivering it as a PR has to put HEAD back on the
1424
+ // PR head. Otherwise a later recheck reviews that commit and stamps its verdict
1425
+ // either with a sha the repository cannot resolve — which verifyReviewedSha
1426
+ // refuses to post (#290) — or with one no open PR carries.
1427
+ const restorePRHeadInClone = () => {
1428
+ execSync('git reset --hard HEAD~1', { cwd: tmpDir, stdio: 'pipe' });
1429
+ };
1430
+ // Deliver the fix as a diff on the original PR. Used wherever opening the
1431
+ // follow-up PR would either create a stale artifact or overwrite a branch that
1432
+ // is not ours: the work still reaches a human, just not as a branch.
1433
+ const deliverFixAsComment = async (outcome) => {
1434
+ fileLog({ level: 'warn', event: outcome.event, repo: `${owner}/${repoName}`, pr: prNumber, branch: outcome.branch, reason: outcome.reason });
1435
+ log(chalk.yellow(`⚠ ${outcome.notice}`));
1436
+ let patch = '';
1437
+ try {
1438
+ patch = execSync('git diff HEAD~1', { cwd: tmpDir, encoding: 'utf8' });
1439
+ }
1440
+ catch { /* fall through to the empty check */ }
1441
+ if (patch) {
1442
+ await octokit.rest.issues.createComment({
1443
+ owner, repo: repoName, issue_number: prNumber,
1444
+ body: [
1445
+ '### Suggested fixes (crosscheck auto-fix)',
1446
+ '',
1447
+ outcome.explanation,
1448
+ '',
1449
+ '```diff',
1450
+ patch.slice(0, 16000),
1451
+ '```',
1452
+ '',
1453
+ fixAttributionFooter(),
1454
+ ].join('\n'),
1455
+ });
1456
+ }
1457
+ restorePRHeadInClone();
1458
+ onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
1459
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'comment', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
1460
+ results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
1461
+ };
1462
+ // Ask whether the intended base still exists BEFORE pushing anything. The most
1463
+ // common reason the PR-branch push failed is that the PR merged and GitHub
1464
+ // deleted its branch — and `pulls.create` rejects a missing base with
1465
+ // `field: base, code: invalid`. That used to throw after the branch was already
1466
+ // pushed, losing the fix and orphaning the branch (19 times in logged runs).
1467
+ let baseBranchExists = true;
1468
+ try {
1469
+ await octokit.rest.repos.getBranch({ owner, repo: repoName, branch: pr.head.ref });
1470
+ }
1471
+ catch (err) {
1472
+ if (err?.status === 404)
1473
+ baseBranchExists = false;
1474
+ else
1475
+ throw err;
1476
+ }
1477
+ const delivery = planAutoFixDelivery(baseBranchExists, pr.head.ref);
1478
+ if (delivery.kind === 'comment') {
1479
+ // A PR cut from this snapshot would be stale the moment it opened — it is
1480
+ // exactly the artifact the superseded-auto-fix cleanup exists to close. Post
1481
+ // the fix as a diff instead, so the work is still delivered and reviewable.
1482
+ await deliverFixAsComment({
1483
+ event: 'fix_pr_skipped',
1484
+ reason: delivery.reason,
1485
+ branch: pr.head.ref,
1486
+ notice: `${pr.head.ref} no longer exists — posting the fix as a diff instead of opening a stale follow-up PR`,
1487
+ explanation: `\`${pr.head.ref}\` no longer exists, so this PR has merged and there is no branch to land on. The fix is below rather than in a follow-up PR — a PR cut from the pre-merge state would reintroduce whatever the merged version changed.`,
1488
+ });
1489
+ continue;
1490
+ }
1491
+ // A fix branch left behind by an earlier round of this PR made a plain push
1492
+ // non-fast-forward and lost the fix (12 times in logged runs). Replace it under
1493
+ // a lease: this round's fix is built on the newer PR head and supersedes it,
1494
+ // and the lease still refuses if anything other than us moved the branch.
1495
+ let remoteOid = null;
1496
+ try {
1497
+ remoteOid = parseLsRemoteOid(execFileSync('git', ['ls-remote', '--heads', 'origin', fixBranch], { cwd: tmpDir, env: gitEnv, encoding: 'utf8' }));
1498
+ }
1499
+ catch { /* treat an unreadable remote as "branch absent"; the lease catches a wrong guess */ }
1500
+ // `--force-with-lease` only protects against changes made *after* this lookup —
1501
+ // it happily accepts a branch a human just created or moved as the lease value
1502
+ // and overwrites it. So establish that the branch is crosscheck's own artifact
1503
+ // before replacing it, from GitHub's record of who opened the PR from it rather
1504
+ // than from anything the pusher can write into a commit.
1505
+ if (remoteOid !== null) {
1506
+ let crosscheckLogin = null;
1507
+ let candidates = [];
1508
+ try {
1509
+ const [{ data: viewer }, { data: branchPrs }] = await Promise.all([
1510
+ octokit.rest.users.getAuthenticated(),
1511
+ octokit.rest.pulls.list({ owner, repo: repoName, state: 'all', head: `${owner}:${fixBranch}` }),
1512
+ ]);
1513
+ crosscheckLogin = viewer.login;
1514
+ candidates = branchPrs;
1515
+ }
1516
+ catch { /* unreadable identity or PR list — treated as not ours, never overwrite */ }
1517
+ const ownership = assessFixBranchOwnership({ sourcePrNumber: prNumber, crosscheckLogin, candidates });
1518
+ if (!ownership.owned) {
1519
+ await deliverFixAsComment({
1520
+ event: 'fix_branch_push_skipped',
1521
+ reason: ownership.reason,
1522
+ branch: fixBranch,
1523
+ notice: `${fixBranch} exists but crosscheck has no PR of its own from it — leaving it in place and posting the fix as a diff instead`,
1524
+ explanation: `\`${fixBranch}\` already exists and crosscheck cannot show it opened a pull request from it, so the branch was left untouched rather than force-replaced. The fix is posted here instead.`,
1525
+ });
1526
+ continue;
1527
+ }
1528
+ }
1529
+ // From #296: the merged-PR handler sweeps for auto-fix PRs once, so a PR opened
1530
+ // after that sweep is never seen by it and stays open and mergeable against a
1531
+ // tree that no longer exists. The base-exists check above already covers a merge
1532
+ // that deleted the branch; this catches a merge in a repo that keeps its
1533
+ // branches. It sits immediately before the push — every lookup above widens the
1534
+ // window it has to close, and nothing has been written to the remote yet, so a
1535
+ // skip here cannot orphan a branch.
1536
+ if (await sourcePRHasMerged(octokit, owner, repoName, prNumber)) {
1537
+ restorePRHeadInClone();
1538
+ skipFix('source_pr_merged');
1539
+ continue;
1540
+ }
1541
+ try {
1542
+ execFileSync('git', forceWithLeaseArgs(fixBranch, remoteOid), { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
1543
+ }
1544
+ catch (pushErr) {
1545
+ const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
1546
+ fileLog({
1547
+ level: 'error', event: 'fix_branch_push_failed', repo: `${owner}/${repoName}`, pr: prNumber,
1548
+ branch: fixBranch, lease: remoteOid ?? 'absent',
1549
+ reason: isLeaseRejection(pushMsg) ? 'lease_rejected' : 'push_failed',
1550
+ error: pushMsg.slice(0, 500),
1551
+ });
1552
+ throw pushErr;
1553
+ }
1554
+ if (remoteOid !== null) {
1555
+ fileLog({ level: 'info', event: 'fix_branch_replaced', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, previous_sha: remoteOid, sha: newSha });
1556
+ }
1557
+ // Undo this run's push when the follow-up PR turns out not to be creatable after
1558
+ // all. Only a branch this run brought into existence is removed — one that was
1559
+ // already crosscheck's is left at the newer commit rather than deleted out from
1560
+ // under whatever else references it.
1561
+ const rollBackPushedFixBranch = () => {
1562
+ if (remoteOid !== null)
1563
+ return;
1564
+ try {
1565
+ execFileSync('git', ['push', 'origin', '--delete', fixBranch], { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
1566
+ fileLog({ level: 'info', event: 'fix_branch_rolled_back', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, sha: newSha });
1567
+ }
1568
+ catch (deleteErr) {
1569
+ fileLog({ level: 'warn', event: 'fix_branch_rollback_failed', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, error: deleteErr instanceof Error ? deleteErr.message.slice(0, 500) : String(deleteErr) });
1570
+ }
1571
+ };
1572
+ // The source PR can still merge in the window between the check above and this
1573
+ // point. Asking again here is what keeps the push from becoming a stale fix PR:
1574
+ // the branch is already on the remote, so the answer decides whether it stays.
1575
+ if (await sourcePRHasMerged(octokit, owner, repoName, prNumber)) {
1576
+ rollBackPushedFixBranch();
1577
+ restorePRHeadInClone();
1578
+ skipFix('source_pr_merged');
1579
+ continue;
1580
+ }
1581
+ const fixPrTitle = config.post_review.auto_fix.delivery.pr_title.replace('#{original_pr_title}', pr.title);
1582
+ // An earlier round may already have an open PR from this branch; the force-push
1583
+ // above updated it, so opening a second one would be rejected as a duplicate.
1584
+ const { data: existingFixPrs } = await octokit.rest.pulls.list({
1585
+ owner, repo: repoName, state: 'open', head: `${owner}:${fixBranch}`,
1586
+ });
1587
+ let fixPrNumber;
1588
+ if (existingFixPrs.length > 0) {
1589
+ fixPrNumber = existingFixPrs[0].number;
1590
+ fileLog({ level: 'info', event: 'fix_pr_updated', repo: `${owner}/${repoName}`, pr: prNumber, fix_pr: fixPrNumber, sha: newSha });
1591
+ }
1592
+ else {
1593
+ let fixPr;
1594
+ try {
1595
+ const created = await octokit.rest.pulls.create({
1596
+ owner,
1597
+ repo: repoName,
1598
+ head: fixBranch,
1599
+ base: delivery.base,
1600
+ title: fixPrTitle,
1601
+ body: [
1602
+ autoFixPRIntro(prNumber),
1603
+ '',
1604
+ `Review: https://github.com/${owner}/${repoName}/pull/${prNumber}`,
1605
+ '',
1606
+ fixAttributionFooter(),
1607
+ ].join('\n'),
1608
+ });
1609
+ fixPr = created.data;
1610
+ }
1611
+ catch (createErr) {
1612
+ // A merge landing in a repo that deletes its branches takes the base away
1613
+ // between the checks above and this call, and `pulls.create` reports that as
1614
+ // `field: base, code: invalid` — the original failure this fallback exists to
1615
+ // stop, just in a narrower window. Take the branch back out and deliver the
1616
+ // fix as a diff rather than leaving it orphaned.
1617
+ if (!isInvalidBaseError(createErr))
1618
+ throw createErr;
1619
+ rollBackPushedFixBranch();
1620
+ await deliverFixAsComment({
1621
+ event: 'fix_pr_create_failed',
1622
+ reason: 'base_branch_gone_after_push',
1623
+ branch: delivery.base,
1624
+ notice: `${delivery.base} disappeared while the fix branch was being pushed — posting the fix as a diff instead of opening a stale follow-up PR`,
1625
+ explanation: `\`${delivery.base}\` existed when this fix started and was gone by the time the follow-up PR was opened, so this PR merged mid-run. The fix is below rather than in a follow-up PR — a PR cut from the pre-merge state would reintroduce whatever the merged version changed.`,
1626
+ });
1627
+ continue;
1628
+ }
1629
+ fixPrNumber = fixPr.number;
1630
+ }
1631
+ if (config.post_review.auto_fix.delivery.label) {
1632
+ try {
1633
+ await octokit.rest.issues.addLabels({
1634
+ owner, repo: repoName, issue_number: fixPrNumber, labels: [config.post_review.auto_fix.delivery.label],
1635
+ });
1636
+ }
1637
+ catch { /* label may not exist in this repo — skip */ }
1638
+ }
1639
+ onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
1640
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPrNumber, tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
1641
+ results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
1247
1642
  }
1248
- onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
1249
- 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, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
1250
- results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
1251
1643
  }
1252
1644
  else {
1253
1645
  // comment: post the diff as a suggested-fix comment, no code push needed (works for fork PRs too)
@@ -1269,7 +1661,7 @@ export async function runWorkflow(ctx) {
1269
1661
  else if (effectiveType === 'conflict-resolve') {
1270
1662
  const skipConflictResolve = (reason) => {
1271
1663
  onPhaseChange('', { phase: 'fixed', fixCount: 0 });
1272
- results[step.name] = { skipped: true };
1664
+ results[step.name] = { skipped: true, skipReason: reason };
1273
1665
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
1274
1666
  };
1275
1667
  if (ctx.dryRun) {
@@ -1316,7 +1708,16 @@ export async function runWorkflow(ctx) {
1316
1708
  skipConflictResolve('no_conflicts');
1317
1709
  continue;
1318
1710
  }
1319
- const vendor = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
1711
+ // resolveConflictResolveVendor extends resolveReviewer with the same human-origin
1712
+ // fallback the fix step uses, so a PR crosscheck cannot attribute still gets its
1713
+ // conflicts resolved instead of skipping with 'no_vendor'.
1714
+ const { vendor, usedHumanFallback, substitutedOriginVendor } = resolveConflictResolveVendor(step.reviewer, origin, config, ctx.smartSwitchFallback);
1715
+ if (usedHumanFallback && vendor) {
1716
+ fileLog({ level: 'info', event: 'conflict_resolve_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: 'none', to: vendor, reason: 'human_origin' });
1717
+ }
1718
+ else if (substitutedOriginVendor && vendor) {
1719
+ fileLog({ level: 'info', event: 'conflict_resolve_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: substitutedOriginVendor, to: vendor, reason: 'unsupported_vendor' });
1720
+ }
1320
1721
  if (!vendor) {
1321
1722
  try {
1322
1723
  execSync('git merge --abort', { cwd: tmpDir });
@@ -1458,7 +1859,7 @@ export async function runWorkflow(ctx) {
1458
1859
  execFileSync('git', [
1459
1860
  'commit',
1460
1861
  '-m',
1461
- `[crosscheck] resolve: resolve ${conflictedFiles.length} conflict${conflictedFiles.length !== 1 ? 's' : ''} — by Claude Code`,
1862
+ conflictResolveCommitSubject(conflictedFiles.length, vendor),
1462
1863
  '-m',
1463
1864
  buildCommitTrailers({ reviewer: vendor, model: conflictResolveModel, step: 'conflict-resolve', service: 'crosscheck' }),
1464
1865
  ], { cwd: tmpDir });
@@ -1522,6 +1923,7 @@ export async function runWorkflow(ctx) {
1522
1923
  return {
1523
1924
  verdict: verdict ?? null,
1524
1925
  fixAppliedCount,
1926
+ stepOutcomes: summariseStepOutcomes(stepsRun, results),
1525
1927
  ...(fixAppliedCount === undefined && lastFixSkipReason !== undefined && { fixSkipReason: lastFixSkipReason }),
1526
1928
  ...(latestReviewResult?.commentBody && {
1527
1929
  latestReviewComment: {