@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
@@ -2,6 +2,7 @@ import { execSync, spawn } from 'child_process';
2
2
  import chalk from 'chalk';
3
3
  import { createWebhookServer } from '../github/webhook.js';
4
4
  import { createGithubClient, getCommitMessage, registerOrgWebhook, deleteOrgWebhook, registerRepoWebhook, deleteRepoWebhook, patchOrgWebhookEvents, patchRepoWebhookEvents, findOrgWebhook, findRepoWebhook, listUserRepos, checkRepoAccessible, } from '../github/client.js';
5
+ import { closeSupersededAutoFixPRs } from '../github/superseded-fix-pr.js';
5
6
  import { detectOriginFull, assignReviewer } from '../github/detector.js';
6
7
  import { loadConfig, getGithubToken, getWebhookSecret, resolveConfigPath, promptDeploymentMode, detectScopesForDeployment, patchDeploymentConfig, detectGitHubLogin, getLinearCredentials, } from '../config/loader.js';
7
8
  import { randomFortune } from '../lib/fortune.js';
@@ -10,7 +11,7 @@ import { initLogger, log as fileLog, logError, logUncaught } from '../lib/logger
10
11
  import { isAuthorAllowed } from '../lib/filter.js';
11
12
  import { runWorkflow } from '../lib/runner.js';
12
13
  import { loadWorkflow, linearWritePossible, DEFAULT_RECHECK_INSTRUCTIONS } from '../lib/workflow.js';
13
- import { filterStepsByTypes, formatRepoWorkflowSteps, isRecheckWithoutFix, isReviewOnlyWorkflow, readRepoWorkflowStepTypes, resolveRepoWorkflowSteps, workflowHasStep } from '../lib/repo-workflow.js';
14
+ import { filterStepsByTypes, formatRepoWorkflowSteps, isReviewOnlyWorkflow, readRepoWorkflowStepTypes, resolveRepoWorkflowSteps, workflowHasStep } from '../lib/repo-workflow.js';
14
15
  import { fetchStepHistory, identifyNextWorkflowStep, decideReviewOnly } from '../lib/pr-workflow-state.js';
15
16
  import { parseAnnotation } from '../lib/annotation.js';
16
17
  import { PRBoard, fmtTime, FMT_TIME_WIDTH } from '../lib/board.js';
@@ -129,6 +130,21 @@ function openTunnel(localPort) {
129
130
  });
130
131
  });
131
132
  }
133
+ // PR history is what decides whether a step runs at all — the approval stop, the
134
+ // per-SHA dedup, the resume point. Acting without it means posting a duplicate review
135
+ // or modifying an already-approved commit, so callers fail closed rather than assume an
136
+ // empty history. One short retry first: the usual cause is a transient GitHub blip or a
137
+ // rate-limit tick, and deferring the event strands the PR until something else happens.
138
+ const HISTORY_RETRY_DELAY_MS = 3_000;
139
+ async function fetchStepHistoryWithRetry(owner, repo, prNumber, token) {
140
+ try {
141
+ return await fetchStepHistory(owner, repo, prNumber, token);
142
+ }
143
+ catch {
144
+ await new Promise(resolve => setTimeout(resolve, HISTORY_RETRY_DELAY_MS));
145
+ return await fetchStepHistory(owner, repo, prNumber, token);
146
+ }
147
+ }
132
148
  export async function runWatch(opts = {}) {
133
149
  const configPath = opts.config;
134
150
  let config = loadConfig(configPath);
@@ -211,12 +227,16 @@ export async function runWatch(opts = {}) {
211
227
  // Last-reviewed diff hash per PR — skip reviews when a new SHA has identical diff vs base
212
228
  // (force-push, amend, no-op rebase). Persisted so restarts don't re-review unchanged content.
213
229
  const diffHashes = new PersistentDiffHashMap();
214
- // PRs reviewed at least once this session synchronize events on these run as recheck rounds
215
- const reviewedPRKeys = new Set();
230
+ // Completed round count per PRthe auto-loop's round counter, which history cannot
231
+ // supply (it returns the last recheck's round unchanged across iterations).
216
232
  const prRoundCounts = new Map();
217
- // PR+sha pairs completed by this watch session used to suppress issue_comment
218
- // re-entries for reviews that watch posted itself (as opposed to kickass).
219
- const reviewedPRShaKeys = new Set();
233
+ // Comment IDs the issue_comment bridge has already handled, so a webhook redelivery
234
+ // of the same comment does not dispatch twice. There is no need to guess at which
235
+ // comments are "ours": webhook.ts forwards only `trigger=kickass` annotations, and
236
+ // this watcher's own reviews carry trigger=watch/comment/backtrace, so they never
237
+ // reach the bridge. Every comment that does reach it is a kickass dispatch whose
238
+ // verdict this watcher is meant to act on — history detection decides how.
239
+ const handledCommentIds = new Set();
220
240
  // Idle tracking — reset on any PR activity; used by the idle-issue timer.
221
241
  let lastActivityAt = Date.now();
222
242
  let idleAnalysisRunning = false;
@@ -235,6 +255,7 @@ export async function runWatch(opts = {}) {
235
255
  fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'author_not_allowed', author: params.author });
236
256
  return;
237
257
  }
258
+ const prKey = `${owner}/${repoName}#${prNumber}`;
238
259
  const { origin, method: originMethod } = await detectOriginFull(params.body ?? '', params.headRef, owner, repoName, prNumber, config, token, params.author);
239
260
  // Smart-switch: when cross-vendor is degraded, override to single-vendor with the healthy vendor
240
261
  const ss = getSmartSwitch();
@@ -265,6 +286,7 @@ export async function runWatch(opts = {}) {
265
286
  html_url: `https://github.com/${owner}/${repoName}/pull/${prNumber}`,
266
287
  user: { login: params.author },
267
288
  ...(params.labels !== undefined && { labels: params.labels.map(name => ({ name })) }),
289
+ ...(params.createdAt !== undefined && { created_at: params.createdAt }),
268
290
  };
269
291
  if (!acquirePRLock(owner, repoName, prNumber, params.headSha)) {
270
292
  fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'in_progress_local' });
@@ -284,21 +306,26 @@ export async function runWatch(opts = {}) {
284
306
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'lock' }, err);
285
307
  return;
286
308
  }
287
- const prKey = `${owner}/${repoName}#${prNumber}`;
288
- // Determine the correct starting step from PR comment history so watch
289
- // behaves correctly after a restart (reviewedPRKeys is in-memory only).
290
- // Fast-path: if the PR was reviewed in this session, skip the API call.
291
- // Exception: comment-triggered runs must always do live detection
292
- // reviewedPRKeys is SHA-agnostic and would misroute a kickass review
293
- // annotation for a new SHA as a recheck run instead of routing to fix.
294
- // auto_loop runs also skip the session fast-path so history detection
295
- // picks the correct starting step (fix, not recheck); round is instead
296
- // incremented from prRoundCounts to match the completed round count.
297
- let isRecheckRun = params.action === 'comment'
298
- ? reviewedPRShaKeys.has(key) // SHA-specific: already processed this exact sha
299
- : params.action === 'auto_loop'
300
- ? false // always use history detection for auto-loop rounds
301
- : reviewedPRKeys.has(prKey); // session fast-path for webhook/backtrace events
309
+ // Determine the correct starting step from PR comment history. Webhook, backtrace,
310
+ // and auto_loop events always ask there is no PR-level session fast-path.
311
+ //
312
+ // There used to be one (`reviewedPRKeys`): reviewed-in-this-session meant "run the
313
+ // next event as a recheck". It was wrong by construction on two axes. It is
314
+ // SHA-agnostic, so a push it had never seen inherited the previous SHA's routing;
315
+ // and it only knows what THIS process did, so an approval, a forced re-review, or a
316
+ // fix pushed by `kickass`, `crosscheck run`, or a second watcher was invisible to
317
+ // it. Each case where it guessed wrong grew its own guard — recheck-no-fix depth,
318
+ // repo overrides, approvals and every one of those guards did the same thing:
319
+ // undo the guess and ask history. So ask history. It costs one call per event, and
320
+ // it is the only thing that sees the PR as it actually is.
321
+ //
322
+ // Comment triggers ask too. The SHA-specific fast path they used to take ran an
323
+ // externally-posted NEEDS_WORK as a recheck instead of routing to fix, because it
324
+ // skipped detection whenever this session had already touched the SHA.
325
+ let isRecheckRun = false;
326
+ // auto_loop's round is pre-computed from the completed round count rather than
327
+ // from history, which returns the last recheck's round unchanged across iterations
328
+ // and would defeat the max_rounds cap.
302
329
  let round = params.action === 'auto_loop'
303
330
  ? (prRoundCounts.get(prKey) ?? 1) + 1
304
331
  : isRecheckRun ? (prRoundCounts.get(prKey) ?? 1) + 1 : 1;
@@ -312,17 +339,6 @@ export async function runWatch(opts = {}) {
312
339
  isRecheckRun = false;
313
340
  round = 1;
314
341
  }
315
- // A recheck-no-fix depth (`review,recheck`) decides review-vs-recheck per SHA from
316
- // the last verdict: an unresolved review gets a recheck, an APPROVE gets a fresh
317
- // review (identifyNextWorkflowStep). The session fast-path above is PR-level and
318
- // SHA-agnostic, so it would coerce every later push in this process into a recheck
319
- // and never consult that decision. Force history detection instead. Comment-
320
- // triggered runs keep their SHA-specific fast path — a kickass re-review targets a
321
- // SHA history detection would report as already complete.
322
- if (isRecheckWithoutFix(repoStepOverride) && isRecheckRun && params.action !== 'comment') {
323
- isRecheckRun = false;
324
- round = 1;
325
- }
326
342
  if (reviewOnlyForRepo) {
327
343
  // Review-only comes from a per-repo override (crosscheck alter --review-only).
328
344
  // Restrict the workflow to review step(s) and dedup on the exact head sha so
@@ -339,7 +355,7 @@ export async function runWatch(opts = {}) {
339
355
  round = 1;
340
356
  resolvedSteps = reviewSteps;
341
357
  try {
342
- const history = await fetchStepHistory(owner, repoName, prNumber, token);
358
+ const history = await fetchStepHistoryWithRetry(owner, repoName, prNumber, token);
343
359
  const decision = decideReviewOnly(history, params.headSha);
344
360
  if (decision.alreadyReviewed) {
345
361
  await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'success');
@@ -349,7 +365,18 @@ export async function runWatch(opts = {}) {
349
365
  }
350
366
  round = decision.round;
351
367
  }
352
- catch { /* best-effort — fall back to a fresh round-1 review */ }
368
+ catch (err) {
369
+ // The dedup this mode relies on lives in that history. Reviewing without it
370
+ // would post a second review on a SHA already covered.
371
+ // Released as `failure`, not `success`: this SHA was not reviewed, and
372
+ // `crosscheck/review` is a commit status a repo can require for merge — a
373
+ // green one here would let an unreviewed HEAD satisfy branch protection
374
+ // during a GitHub outage.
375
+ await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'failure');
376
+ releasePRLock(owner, repoName, prNumber, params.headSha);
377
+ fileLog({ level: 'warn', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'history_unavailable', sha: params.headSha, error: err instanceof Error ? err.message : String(err) });
378
+ return;
379
+ }
353
380
  }
354
381
  else if (!isRecheckRun) {
355
382
  try {
@@ -358,18 +385,34 @@ export async function runWatch(opts = {}) {
358
385
  // fresh so live edits to workflow.yml apply across events.
359
386
  const globalWorkflow = loadWorkflow(process.cwd());
360
387
  const allSteps = repoStepOverride ? filterStepsByTypes(globalWorkflow, repoStepOverride) : globalWorkflow;
361
- const history = await fetchStepHistory(owner, repoName, prNumber, token);
362
- const nextResult = identifyNextWorkflowStep(history, allSteps, params.headSha);
388
+ const history = await fetchStepHistoryWithRetry(owner, repoName, prNumber, token);
389
+ // The webhook payload carries no usable `mergeable`, so ask — but only for
390
+ // workflows that can act on the answer. GitHub computes it lazily, and a null
391
+ // simply means the conflict route is not offered on this event. Unlike the
392
+ // history read above this one stays best-effort: not knowing whether the PR
393
+ // conflicts only costs a deferred resolve, not a wrong review.
394
+ let mergeable = null;
395
+ if (allSteps.some(s => s.type === 'conflict-resolve')) {
396
+ try {
397
+ const { data: freshPR } = await lockOctokit.rest.pulls.get({ owner, repo: repoName, pull_number: prNumber });
398
+ mergeable = freshPR.mergeable;
399
+ }
400
+ catch { /* best-effort — unknown mergeability just skips the conflict route */ }
401
+ }
402
+ const nextResult = identifyNextWorkflowStep(history, allSteps, params.headSha, { mergeable });
363
403
  if (nextResult.step === null) {
364
404
  // Workflow already complete for this HEAD sha — release lock and skip.
365
405
  // Happens when a synchronize event fires after all steps are done.
366
406
  await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'success');
367
407
  releasePRLock(owner, repoName, prNumber, params.headSha);
368
- fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'workflow_complete', sha: params.headSha });
408
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: nextResult.stopReason ?? 'workflow_complete', sha: params.headSha });
369
409
  return;
370
410
  }
371
411
  if (nextResult.hasExistingReview) {
372
- isRecheckRun = nextResult.step.type !== 'review';
412
+ // conflict-resolve is excluded: the run continues into the review step, and
413
+ // the merge commit it produces is new content owed a real review, not a
414
+ // recheck of findings the merge had nothing to do with.
415
+ isRecheckRun = nextResult.step.type !== 'review' && nextResult.step.type !== 'conflict-resolve';
373
416
  // auto_loop pre-computed the correct incremented round from prRoundCounts;
374
417
  // history detection returns the last recheck's round (unchanged across
375
418
  // iterations), so overwriting here would defeat the max_rounds cap.
@@ -418,15 +461,26 @@ export async function runWatch(opts = {}) {
418
461
  }
419
462
  }
420
463
  }
421
- catch {
422
- // best-effort fall back to session-based detection.
423
- // For auto_loop, force isRecheckRun=true so a transient history-fetch
424
- // failure does not restart the workflow from the review step.
425
- // A recheck-no-fix repo has no session state left to fall back to (the guard
426
- // above cleared it), so it re-reviews the new SHA the conservative direction,
427
- // since a fresh review never assumes findings that may already be resolved.
428
- if (params.action === 'auto_loop')
429
- isRecheckRun = true;
464
+ catch (err) {
465
+ // History still unreadable after the retry. Everything this function decides —
466
+ // whether HEAD is already approved, already reviewed, or mid-loop lives in
467
+ // that history, so running anyway risks a second review on an approved commit
468
+ // or a fix re-applied to code that already has one. Defer instead: release the
469
+ // locks and let the next event, `kickass`, or `crosscheck run` pick the PR up.
470
+ //
471
+ // auto_loop is the exception. It is a continuation of a run this process just
472
+ // completed, so the state is known from the session rather than from history,
473
+ // and deferring would strand the loop — nothing else re-triggers it.
474
+ if (params.action !== 'auto_loop') {
475
+ // `failure`, not `success` — see the review-only branch above: a green
476
+ // `crosscheck/review` on a HEAD nobody reviewed can satisfy branch
477
+ // protection.
478
+ await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'failure');
479
+ releasePRLock(owner, repoName, prNumber, params.headSha);
480
+ fileLog({ level: 'warn', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'history_unavailable', sha: params.headSha, error: err instanceof Error ? err.message : String(err) });
481
+ return;
482
+ }
483
+ isRecheckRun = true;
430
484
  }
431
485
  }
432
486
  const reviewStart = Date.now();
@@ -523,15 +577,9 @@ export async function runWatch(opts = {}) {
523
577
  round,
524
578
  trigger: params.action === 'backtrace' ? 'backtrace' : params.action === 'comment' ? 'comment' : 'watch',
525
579
  });
526
- void verdict;
527
- // A strategy-skipped PR never ran a review, so it must stay out of the
528
- // session caches. reviewedPRKeys is what makes the next event on this PR
529
- // an `isRecheckRun`, and a review coerced to a recheck is deliberately
530
- // never gated by max_rounds — so a lockfile-only PR that later gains
531
- // source files would be "rechecked" against findings never posted.
580
+ // A strategy-skipped PR never ran a review, so it must not advance the
581
+ // auto-loop's round counter that run posted nothing to count.
532
582
  if (strategySkipped === undefined) {
533
- reviewedPRKeys.add(prKey);
534
- reviewedPRShaKeys.add(key); // key = "owner/repo#pr@sha"
535
583
  prRoundCounts.set(prKey, round);
536
584
  }
537
585
  // Recompute the diff hash AFTER runWorkflow — workflow steps such as
@@ -720,6 +768,7 @@ export async function runWatch(opts = {}) {
720
768
  baseRef: pr.base.ref, action: event.action,
721
769
  ...(pr.labels !== undefined && { labels: pr.labels.map(l => l.name) }),
722
770
  ...(pr.base.repo.default_branch !== undefined && { defaultBranch: pr.base.repo.default_branch }),
771
+ ...(pr.created_at !== undefined && { createdAt: pr.created_at }),
723
772
  });
724
773
  }, (msg) => bLog(chalk.dim(fmtTime()) + ' ' + msg), fileLog, async (event) => {
725
774
  const owner = event.repository.owner.login;
@@ -769,10 +818,11 @@ export async function runWatch(opts = {}) {
769
818
  fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'comment_stale_sha', sha: prData.head.sha, annotation_sha: annotationSha });
770
819
  return;
771
820
  }
772
- if (reviewedPRShaKeys.has(`${owner}/${repoName}#${prNumber}@${prData.head.sha}`)) {
773
- fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'comment_self_trigger', sha: prData.head.sha });
821
+ if (handledCommentIds.has(event.comment.id)) {
822
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'comment_already_handled', comment_id: event.comment.id });
774
823
  return;
775
824
  }
825
+ handledCommentIds.add(event.comment.id);
776
826
  // GitHub can deliver issue_comment before ck run's finally block releases
777
827
  // the remote lock. Retry with backoff; re-fetch the PR head on each attempt
778
828
  // so we don't dispatch against a sha that advanced while we were waiting.
@@ -796,10 +846,6 @@ export async function runWatch(opts = {}) {
796
846
  break;
797
847
  }
798
848
  }
799
- if (reviewedPRShaKeys.has(`${owner}/${repoName}#${prNumber}@${prData.head.sha}`)) {
800
- fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'comment_self_trigger', sha: prData.head.sha });
801
- break;
802
- }
803
849
  if (await checkRemoteLock(octokit, owner, repoName, prData.head.sha).catch(() => false)) {
804
850
  fileLog({ level: 'info', event: 'comment_trigger_deferred', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'in_progress_remote', attempt });
805
851
  continue;
@@ -819,6 +865,7 @@ export async function runWatch(opts = {}) {
819
865
  // depending on which trigger ran it.
820
866
  ...(prData.labels !== undefined && { labels: prData.labels.map((l) => l.name) }),
821
867
  ...(prData.base.repo?.default_branch !== undefined && { defaultBranch: prData.base.repo.default_branch }),
868
+ ...(prData.created_at !== undefined && { createdAt: prData.created_at }),
822
869
  });
823
870
  break;
824
871
  }
@@ -826,6 +873,29 @@ export async function runWatch(opts = {}) {
826
873
  catch (err) {
827
874
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'comment_trigger' }, err);
828
875
  }
876
+ }, async (event) => {
877
+ const owner = event.repository.owner.login;
878
+ const repoName = event.repository.name;
879
+ const prNumber = event.number;
880
+ try {
881
+ const octokit = createGithubClient(token);
882
+ const outcomes = await closeSupersededAutoFixPRs(octokit, owner, repoName, prNumber, event.pull_request.merge_commit_sha ?? null);
883
+ for (const outcome of outcomes) {
884
+ fileLog({
885
+ level: outcome.status === 'failed' ? 'warn' : 'info',
886
+ event: 'auto_fix_pr_superseded',
887
+ repo: `${owner}/${repoName}`, pr: prNumber,
888
+ fix_pr: outcome.prNumber, status: outcome.status,
889
+ ...(outcome.reason && { reason: outcome.reason }),
890
+ });
891
+ if (outcome.status === 'closed') {
892
+ bLog(chalk.dim(fmtTime()) + ` closed superseded auto-fix PR ${owner}/${repoName}#${outcome.prNumber} (${prNumber} merged)`);
893
+ }
894
+ }
895
+ }
896
+ catch (err) {
897
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'auto_fix_supersede' }, err);
898
+ }
829
899
  });
830
900
  await new Promise((resolve, reject) => {
831
901
  server.on('error', (err) => {
@@ -1107,6 +1177,7 @@ export async function runWatch(opts = {}) {
1107
1177
  baseRef: pr.baseRef, action: 'backtrace',
1108
1178
  ...(pr.labels !== undefined && { labels: pr.labels }),
1109
1179
  ...(pr.defaultBranch !== undefined && { defaultBranch: pr.defaultBranch }),
1180
+ createdAt: pr.createdAt,
1110
1181
  })));
1111
1182
  }
1112
1183
  catch (err) {