@humanbased/crosscheck 1.3.1-beta.7 → 1.6.0-beta.11

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 (165) hide show
  1. package/README.md +16 -1
  2. package/assets/demo-block-to-approve.gif +0 -0
  3. package/assets/demo-block-to-approve.mp4 +0 -0
  4. package/dist/__tests__/base-ref-recovery.test.d.ts +2 -0
  5. package/dist/__tests__/base-ref-recovery.test.d.ts.map +1 -0
  6. package/dist/__tests__/base-ref-recovery.test.js +102 -0
  7. package/dist/__tests__/base-ref-recovery.test.js.map +1 -0
  8. package/dist/__tests__/board.test.js +510 -20
  9. package/dist/__tests__/board.test.js.map +1 -1
  10. package/dist/__tests__/codex-env.test.js +4 -0
  11. package/dist/__tests__/codex-env.test.js.map +1 -1
  12. package/dist/__tests__/credential-free-origin.test.js +134 -2
  13. package/dist/__tests__/credential-free-origin.test.js.map +1 -1
  14. package/dist/__tests__/demo-arc.test.d.ts +2 -0
  15. package/dist/__tests__/demo-arc.test.d.ts.map +1 -0
  16. package/dist/__tests__/demo-arc.test.js +73 -0
  17. package/dist/__tests__/demo-arc.test.js.map +1 -0
  18. package/dist/__tests__/doc-only-verdict.test.d.ts +2 -0
  19. package/dist/__tests__/doc-only-verdict.test.d.ts.map +1 -0
  20. package/dist/__tests__/doc-only-verdict.test.js +79 -0
  21. package/dist/__tests__/doc-only-verdict.test.js.map +1 -0
  22. package/dist/__tests__/error-classification.test.js +14 -0
  23. package/dist/__tests__/error-classification.test.js.map +1 -1
  24. package/dist/__tests__/fix-requires-recheck.test.d.ts +2 -0
  25. package/dist/__tests__/fix-requires-recheck.test.d.ts.map +1 -0
  26. package/dist/__tests__/fix-requires-recheck.test.js +56 -0
  27. package/dist/__tests__/fix-requires-recheck.test.js.map +1 -0
  28. package/dist/__tests__/human-feedback.test.d.ts +2 -0
  29. package/dist/__tests__/human-feedback.test.d.ts.map +1 -0
  30. package/dist/__tests__/human-feedback.test.js +132 -0
  31. package/dist/__tests__/human-feedback.test.js.map +1 -0
  32. package/dist/__tests__/inconclusive-review.test.d.ts +2 -0
  33. package/dist/__tests__/inconclusive-review.test.d.ts.map +1 -0
  34. package/dist/__tests__/inconclusive-review.test.js +110 -0
  35. package/dist/__tests__/inconclusive-review.test.js.map +1 -0
  36. package/dist/__tests__/lock-ownership.test.d.ts +2 -0
  37. package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
  38. package/dist/__tests__/lock-ownership.test.js +167 -0
  39. package/dist/__tests__/lock-ownership.test.js.map +1 -0
  40. package/dist/__tests__/merge-gate.test.d.ts +2 -0
  41. package/dist/__tests__/merge-gate.test.d.ts.map +1 -0
  42. package/dist/__tests__/merge-gate.test.js +220 -0
  43. package/dist/__tests__/merge-gate.test.js.map +1 -0
  44. package/dist/__tests__/no-verdict.test.d.ts +2 -0
  45. package/dist/__tests__/no-verdict.test.d.ts.map +1 -0
  46. package/dist/__tests__/no-verdict.test.js +415 -0
  47. package/dist/__tests__/no-verdict.test.js.map +1 -0
  48. package/dist/__tests__/pr-spec.test.js +37 -0
  49. package/dist/__tests__/pr-spec.test.js.map +1 -1
  50. package/dist/__tests__/run.test.js +50 -1
  51. package/dist/__tests__/run.test.js.map +1 -1
  52. package/dist/__tests__/runner.test.js +82 -1
  53. package/dist/__tests__/runner.test.js.map +1 -1
  54. package/dist/__tests__/standing-verdict-history.test.d.ts +2 -0
  55. package/dist/__tests__/standing-verdict-history.test.d.ts.map +1 -0
  56. package/dist/__tests__/standing-verdict-history.test.js +109 -0
  57. package/dist/__tests__/standing-verdict-history.test.js.map +1 -0
  58. package/dist/__tests__/step-plan.test.d.ts +2 -0
  59. package/dist/__tests__/step-plan.test.d.ts.map +1 -0
  60. package/dist/__tests__/step-plan.test.js +129 -0
  61. package/dist/__tests__/step-plan.test.js.map +1 -0
  62. package/dist/cli.js +14 -1
  63. package/dist/cli.js.map +1 -1
  64. package/dist/commands/merge.d.ts +11 -0
  65. package/dist/commands/merge.d.ts.map +1 -0
  66. package/dist/commands/merge.js +188 -0
  67. package/dist/commands/merge.js.map +1 -0
  68. package/dist/commands/review.d.ts +3 -1
  69. package/dist/commands/review.d.ts.map +1 -1
  70. package/dist/commands/review.js +117 -9
  71. package/dist/commands/review.js.map +1 -1
  72. package/dist/commands/run.d.ts +53 -0
  73. package/dist/commands/run.d.ts.map +1 -1
  74. package/dist/commands/run.js +218 -36
  75. package/dist/commands/run.js.map +1 -1
  76. package/dist/commands/watch.d.ts.map +1 -1
  77. package/dist/commands/watch.js +41 -26
  78. package/dist/commands/watch.js.map +1 -1
  79. package/dist/config/loader.d.ts +4 -0
  80. package/dist/config/loader.d.ts.map +1 -1
  81. package/dist/config/loader.js +8 -0
  82. package/dist/config/loader.js.map +1 -1
  83. package/dist/config/review-strategy.json +5 -5
  84. package/dist/github/client.d.ts +18 -1
  85. package/dist/github/client.d.ts.map +1 -1
  86. package/dist/github/client.js +19 -3
  87. package/dist/github/client.js.map +1 -1
  88. package/dist/github/merge.d.ts +28 -0
  89. package/dist/github/merge.d.ts.map +1 -1
  90. package/dist/github/merge.js +41 -0
  91. package/dist/github/merge.js.map +1 -1
  92. package/dist/github/review-status.d.ts +33 -0
  93. package/dist/github/review-status.d.ts.map +1 -1
  94. package/dist/github/review-status.js +94 -9
  95. package/dist/github/review-status.js.map +1 -1
  96. package/dist/github/webhook.d.ts +1 -0
  97. package/dist/github/webhook.d.ts.map +1 -1
  98. package/dist/github/webhook.js.map +1 -1
  99. package/dist/lib/board.d.ts +60 -4
  100. package/dist/lib/board.d.ts.map +1 -1
  101. package/dist/lib/board.js +357 -65
  102. package/dist/lib/board.js.map +1 -1
  103. package/dist/lib/clone.d.ts +65 -1
  104. package/dist/lib/clone.d.ts.map +1 -1
  105. package/dist/lib/clone.js +252 -16
  106. package/dist/lib/clone.js.map +1 -1
  107. package/dist/lib/human-feedback.d.ts +9 -0
  108. package/dist/lib/human-feedback.d.ts.map +1 -0
  109. package/dist/lib/human-feedback.js +83 -0
  110. package/dist/lib/human-feedback.js.map +1 -0
  111. package/dist/lib/logger.d.ts +1 -1
  112. package/dist/lib/logger.d.ts.map +1 -1
  113. package/dist/lib/logger.js +6 -0
  114. package/dist/lib/logger.js.map +1 -1
  115. package/dist/lib/merge-gate.d.ts +70 -0
  116. package/dist/lib/merge-gate.d.ts.map +1 -0
  117. package/dist/lib/merge-gate.js +138 -0
  118. package/dist/lib/merge-gate.js.map +1 -0
  119. package/dist/lib/no-verdict.d.ts +104 -0
  120. package/dist/lib/no-verdict.d.ts.map +1 -0
  121. package/dist/lib/no-verdict.js +262 -0
  122. package/dist/lib/no-verdict.js.map +1 -0
  123. package/dist/lib/pr-lock.d.ts +37 -1
  124. package/dist/lib/pr-lock.d.ts.map +1 -1
  125. package/dist/lib/pr-lock.js +176 -29
  126. package/dist/lib/pr-lock.js.map +1 -1
  127. package/dist/lib/pr-spec.d.ts +7 -0
  128. package/dist/lib/pr-spec.d.ts.map +1 -1
  129. package/dist/lib/pr-spec.js +12 -1
  130. package/dist/lib/pr-spec.js.map +1 -1
  131. package/dist/lib/pr-workflow-state.d.ts +39 -0
  132. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  133. package/dist/lib/pr-workflow-state.js +73 -1
  134. package/dist/lib/pr-workflow-state.js.map +1 -1
  135. package/dist/lib/review-strategy.d.ts +14 -0
  136. package/dist/lib/review-strategy.d.ts.map +1 -1
  137. package/dist/lib/review-strategy.js +32 -1
  138. package/dist/lib/review-strategy.js.map +1 -1
  139. package/dist/lib/runner.d.ts +51 -2
  140. package/dist/lib/runner.d.ts.map +1 -1
  141. package/dist/lib/runner.js +211 -105
  142. package/dist/lib/runner.js.map +1 -1
  143. package/dist/lib/tips.d.ts.map +1 -1
  144. package/dist/lib/tips.js +1 -0
  145. package/dist/lib/tips.js.map +1 -1
  146. package/dist/lib/verdict.d.ts +30 -1
  147. package/dist/lib/verdict.d.ts.map +1 -1
  148. package/dist/lib/verdict.js +94 -9
  149. package/dist/lib/verdict.js.map +1 -1
  150. package/dist/reviewers/codex-env.d.ts.map +1 -1
  151. package/dist/reviewers/codex-env.js +2 -0
  152. package/dist/reviewers/codex-env.js.map +1 -1
  153. package/dist/reviewers/codex.d.ts.map +1 -1
  154. package/dist/reviewers/codex.js +3 -1
  155. package/dist/reviewers/codex.js.map +1 -1
  156. package/dist/reviewers/fix.d.ts +2 -2
  157. package/dist/reviewers/fix.d.ts.map +1 -1
  158. package/dist/reviewers/fix.js +10 -4
  159. package/dist/reviewers/fix.js.map +1 -1
  160. package/docs/growth/launch/README.md +59 -0
  161. package/docs/growth/launch/essay-stop-letting-one-agent-review-its-own-code.md +118 -0
  162. package/docs/growth/launch/posts.md +175 -0
  163. package/docs/trust.md +16 -1
  164. package/get-started.md +17 -0
  165. package/package.json +11 -3
@@ -2,6 +2,7 @@ import { type Vendor } from '../lib/vendor.js';
2
2
  import { type WorkflowStep } from '../lib/workflow.js';
3
3
  import { type PRRef } from '../lib/pr-spec.js';
4
4
  import { type ConcurrencyOpts } from '../lib/multi-run.js';
5
+ import { type JudgedRecordShape } from '../lib/no-verdict.js';
5
6
  export interface RunOpts {
6
7
  config?: string;
7
8
  reviewer?: string;
@@ -14,6 +15,12 @@ export interface RunOpts {
14
15
  id?: number;
15
16
  body: string;
16
17
  };
18
+ /**
19
+ * A specific review comment to act on, from the `#issuecomment-<id>` anchor on
20
+ * the PR URL. Without it a fix step falls back to "whatever the latest review
21
+ * is", which can be a different comment from the one the user pasted.
22
+ */
23
+ reviewCommentId?: number;
17
24
  expectedHeadSha?: string;
18
25
  timeout?: string;
19
26
  noTimeout?: boolean;
@@ -26,6 +33,52 @@ export interface StepVendorOverrides {
26
33
  }
27
34
  export declare function resolveWorkflowSteps(allSteps: WorkflowStep[], stepFilter: string[] | undefined, assignedReviewer: 'claude' | 'codex', overrides?: StepVendorOverrides): WorkflowStep[];
28
35
  export declare function buildFixRecheckSteps(steps: WorkflowStep[], allSteps: WorkflowStep[], assignedReviewer: 'claude' | 'codex', overrides?: StepVendorOverrides): WorkflowStep[];
36
+ /**
37
+ * The verdict standing on the PR when the report is printed — which this run is
38
+ * one of the things that can have changed.
39
+ *
40
+ * A run can post a `BLOCK` review and then have its recheck emit no parseable
41
+ * verdict: the workflow ends with `verdict === null` because the recheck is the
42
+ * last step to record one, while the `BLOCK` it just posted is what actually
43
+ * governs the PR. Pre-run history has never heard of that comment, so reporting
44
+ * from it either says nothing stands or names the verdict the run superseded —
45
+ * pointing the reader at an older commit while a fresh judgment sits on HEAD.
46
+ *
47
+ * A successful read supersedes the pre-run selection outright rather than being
48
+ * merged with it: it is a read of the same comments, one workflow later, so
49
+ * where the two disagree the later one is the PR's current state. The pre-run
50
+ * selection is the fallback for a failed read, not a floor.
51
+ *
52
+ * That only holds if the later read can see at least as far back as the earlier
53
+ * one, which is why it is not `fetchStepHistory` — see
54
+ * `fetchStandingVerdictRecords`, whose backward scan is complete where step
55
+ * detection's fast path truncates at the newest annotated review.
56
+ */
57
+ export declare function standingVerdictForReport(preRun: {
58
+ verdict: string;
59
+ sha?: string;
60
+ } | undefined, fetchHistory: () => Promise<readonly JudgedRecordShape[]>): Promise<{
61
+ verdict: string;
62
+ sha?: string;
63
+ } | undefined>;
64
+ /**
65
+ * The commit the no-verdict report measures the standing verdict against.
66
+ *
67
+ * The head captured at dispatch stops describing the PR the moment a step
68
+ * pushes: a resumed run can land a fix commit and then have its recheck gated
69
+ * out, which is the exact shape this report exists to explain. Comparing the
70
+ * standing verdict against the pre-run head there would claim it covers HEAD
71
+ * while an unjudged fix commit sits on top of it — the inverse of the truth.
72
+ *
73
+ * A failed read yields nothing rather than the pre-run head. Reporting the
74
+ * verdict without a staleness claim is what this report already does for a
75
+ * verdict whose own SHA is unknown; asserting coverage from a commit the run
76
+ * itself moved past is the defect. Nothing else consumes the head, so a run
77
+ * with no standing verdict never pays for the round-trip.
78
+ */
79
+ export declare function headShaForStalenessClaim(standingVerdict: {
80
+ verdict?: string;
81
+ } | undefined, fetchHead: () => Promise<string>): Promise<string | undefined>;
29
82
  export declare function runRun(prUrl: string, opts?: RunOpts): Promise<void>;
30
83
  export type RunSpecOpts = RunOpts & ConcurrencyOpts;
31
84
  export declare function buildRunChildArgs(ref: PRRef, opts: RunOpts): string[];
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/commands/run.ts"],"names":[],"mappings":"AAaA,OAAO,EAAsC,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAKlF,OAAO,EAA0G,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAE9J,OAAO,EAAe,KAAK,KAAK,EAAE,MAAM,mBAAmB,CAAA;AAG3D,OAAO,EAAmG,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAO3J,MAAM,WAAW,OAAO;IACtB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,SAAS,CAAC,EAAE,OAAO,GAAG,WAAW,CAAA;IACjC,oBAAoB,CAAC,EAAE;QACrB,EAAE,CAAC,EAAE,MAAM,CAAA;QACX,IAAI,EAAE,MAAM,CAAA;KACb,CAAA;IACD,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,kBAAkB,EAAE,eAAe,CAAA;CACrD;AAiBD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAkDD,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,YAAY,EAAE,EACxB,UAAU,EAAE,MAAM,EAAE,GAAG,SAAS,EAChC,gBAAgB,EAAE,QAAQ,GAAG,OAAO,EACpC,SAAS,GAAE,mBAAwB,GAClC,YAAY,EAAE,CAgBhB;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,YAAY,EAAE,EACrB,QAAQ,EAAE,YAAY,EAAE,EACxB,gBAAgB,EAAE,QAAQ,GAAG,OAAO,EACpC,SAAS,GAAE,mBAAwB,GAClC,YAAY,EAAE,CAehB;AAgBD,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,OAAY,iBAmpB7D;AAED,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,eAAe,CAAA;AAKnD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,CAerE;AAKD,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoDpF;AAID,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAElF;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9E;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAElF"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/commands/run.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,3 @@
1
- import { execFileSync } from 'child_process';
2
1
  import { mkdtempSync, rmSync } from 'fs';
3
2
  import { tmpdir } from 'os';
4
3
  import { join } from 'path';
@@ -6,8 +5,9 @@ import chalk from 'chalk';
6
5
  import { execa } from 'execa';
7
6
  import { parseDuration } from '../lib/durations.js';
8
7
  import ora from 'ora';
9
- import { createGithubClient } from '../github/client.js';
10
- import { fetchStepHistory, identifyNextWorkflowStep } from '../lib/pr-workflow-state.js';
8
+ import { createGithubClient, fetchIssueComment } from '../github/client.js';
9
+ import { parseAnnotation } from '../lib/annotation.js';
10
+ import { fetchStandingVerdictRecords, fetchStepHistoryWithRetry, identifyNextWorkflowStep } from '../lib/pr-workflow-state.js';
11
11
  import { detectOriginFull, assignReviewer } from '../github/detector.js';
12
12
  import { loadConfig, getGithubToken, getLinearApiKey, getLinearCredentials } from '../config/loader.js';
13
13
  import { enrichIssueContext } from '../issues/enrich.js';
@@ -23,9 +23,10 @@ import { closedPRSkip } from '../lib/pr-state.js';
23
23
  import { resolveCliInvocation } from '../lib/cli-invocation.js';
24
24
  import { executeMultiPR, resolveRunConcurrency, printMultiPRSummary, concurrencyError, aggregateExitCode } from '../lib/multi-run.js';
25
25
  import { formatVerdict } from '../lib/verdict.js';
26
- import { clonePRForReview } from '../lib/clone.js';
26
+ import { buildNoVerdictReport, renderNoVerdictReport, selectStandingVerdict } from '../lib/no-verdict.js';
27
+ import { clonePRForReview, runGitWithoutHooks, BaseRefUnavailableError } from '../lib/clone.js';
27
28
  import { acquirePRLock, releasePRLock } from '../lib/pr-lock.js';
28
- import { checkRemoteLock, acquireRemoteLock, releaseRemoteLock, startRemoteLockHeartbeat } from '../github/review-status.js';
29
+ import { checkRemoteLock, acquireRemoteLock, claimRemoteLock, releaseRemoteLock, startRemoteLockHeartbeat } from '../github/review-status.js';
29
30
  function meetsCrazyStopCondition(verdict, mode) {
30
31
  if (verdict === null)
31
32
  return false;
@@ -118,6 +119,62 @@ export function buildFixRecheckSteps(steps, allSteps, assignedReviewer, override
118
119
  }
119
120
  return fixRecheckSteps;
120
121
  }
122
+ /**
123
+ * The verdict standing on the PR when the report is printed — which this run is
124
+ * one of the things that can have changed.
125
+ *
126
+ * A run can post a `BLOCK` review and then have its recheck emit no parseable
127
+ * verdict: the workflow ends with `verdict === null` because the recheck is the
128
+ * last step to record one, while the `BLOCK` it just posted is what actually
129
+ * governs the PR. Pre-run history has never heard of that comment, so reporting
130
+ * from it either says nothing stands or names the verdict the run superseded —
131
+ * pointing the reader at an older commit while a fresh judgment sits on HEAD.
132
+ *
133
+ * A successful read supersedes the pre-run selection outright rather than being
134
+ * merged with it: it is a read of the same comments, one workflow later, so
135
+ * where the two disagree the later one is the PR's current state. The pre-run
136
+ * selection is the fallback for a failed read, not a floor.
137
+ *
138
+ * That only holds if the later read can see at least as far back as the earlier
139
+ * one, which is why it is not `fetchStepHistory` — see
140
+ * `fetchStandingVerdictRecords`, whose backward scan is complete where step
141
+ * detection's fast path truncates at the newest annotated review.
142
+ */
143
+ export async function standingVerdictForReport(preRun, fetchHistory) {
144
+ try {
145
+ return selectStandingVerdict(await fetchHistory());
146
+ }
147
+ catch {
148
+ // Best-effort: fall back to what step detection already read this run.
149
+ return preRun;
150
+ }
151
+ }
152
+ /**
153
+ * The commit the no-verdict report measures the standing verdict against.
154
+ *
155
+ * The head captured at dispatch stops describing the PR the moment a step
156
+ * pushes: a resumed run can land a fix commit and then have its recheck gated
157
+ * out, which is the exact shape this report exists to explain. Comparing the
158
+ * standing verdict against the pre-run head there would claim it covers HEAD
159
+ * while an unjudged fix commit sits on top of it — the inverse of the truth.
160
+ *
161
+ * A failed read yields nothing rather than the pre-run head. Reporting the
162
+ * verdict without a staleness claim is what this report already does for a
163
+ * verdict whose own SHA is unknown; asserting coverage from a commit the run
164
+ * itself moved past is the defect. Nothing else consumes the head, so a run
165
+ * with no standing verdict never pays for the round-trip.
166
+ */
167
+ export async function headShaForStalenessClaim(standingVerdict, fetchHead) {
168
+ if (!standingVerdict?.verdict)
169
+ return undefined;
170
+ try {
171
+ return await fetchHead();
172
+ }
173
+ catch {
174
+ // Best-effort: the report drops the staleness line rather than guessing.
175
+ return undefined;
176
+ }
177
+ }
121
178
  function printRoundModeBanner(mode) {
122
179
  const BLINK = '\x1b[5m';
123
180
  const RESET = '\x1b[0m';
@@ -258,17 +315,70 @@ export async function runRun(prUrl, opts = {}) {
258
315
  }
259
316
  let stepFilter = opts.steps?.split(',').map(s => s.trim().toLowerCase());
260
317
  let initialReviewComment = opts.initialReviewComment;
318
+ // An explicit `#issuecomment-<id>` anchor names the review to act on. Resolve it
319
+ // here rather than letting the step fall back to the latest review: the two are
320
+ // often the same comment, and when they are not, the silent substitution means
321
+ // the command did something other than what the URL said.
322
+ if (initialReviewComment === undefined && opts.reviewCommentId !== undefined) {
323
+ const anchored = await fetchIssueComment(owner, repo, opts.reviewCommentId, token);
324
+ if (!anchored) {
325
+ console.error(chalk.red(`✗ Comment ${opts.reviewCommentId} not found on ${owner}/${repo}`));
326
+ process.exit(1);
327
+ }
328
+ // Compared case-insensitively: GitHub echoes the repository's canonical casing,
329
+ // so a URL typed with different case (github.com/HumanBased-AI/...) would fail an
330
+ // exact compare while naming the very same PR. The check still pins the comment
331
+ // to this owner/repo/number, which is the property that matters.
332
+ const expectedIssueUrl = `https://api.github.com/repos/${owner}/${repo}/issues/${number}`;
333
+ if (anchored.issue_url?.toLowerCase() !== expectedIssueUrl.toLowerCase()) {
334
+ console.error(chalk.red(`✗ Comment ${opts.reviewCommentId} does not belong to ${owner}/${repo}#${number} — nothing to act on`));
335
+ process.exit(1);
336
+ }
337
+ // The annotation tag is plain HTML in a comment body — any PR commenter can post
338
+ // one. It only proves provenance when the comment also comes from the token's own
339
+ // account, the same check watch.ts's onComment handler makes against
340
+ // authenticatedLogin. Fail closed if the authenticated user can't be determined.
341
+ let authenticatedLogin = null;
342
+ try {
343
+ const { data: me } = await createGithubClient(token).rest.users.getAuthenticated();
344
+ authenticatedLogin = me.login;
345
+ }
346
+ catch {
347
+ authenticatedLogin = null;
348
+ }
349
+ if (authenticatedLogin === null || anchored.user.login !== authenticatedLogin) {
350
+ console.error(chalk.red(`✗ Comment ${opts.reviewCommentId} was not posted by crosscheck — nothing to act on`));
351
+ process.exit(1);
352
+ }
353
+ // A comment ID is user-supplied and untrusted, so this mutation path requires the
354
+ // stricter annotation-based check rather than isFreshReviewComment's legacy header
355
+ // fallback — the header alone is too permissive to gate a fix dispatch on.
356
+ const parsedAnnotation = parseAnnotation(anchored.body);
357
+ if (!parsedAnnotation || parsedAnnotation.type !== 'review') {
358
+ console.error(chalk.red(`✗ Comment ${opts.reviewCommentId} is not a crosscheck review comment — nothing to act on`));
359
+ process.exit(1);
360
+ }
361
+ initialReviewComment = { id: opts.reviewCommentId, body: anchored.body };
362
+ console.log(chalk.dim(` targeting review comment ${opts.reviewCommentId}`));
363
+ }
261
364
  // When running without an explicit --steps flag, detect the next step from live
262
365
  // PR comment history. When triggered by kickass the dispatch is intentionally
263
366
  // one-step-at-a-time (watch owns continuation via webhooks). For all other
264
367
  // triggers (direct user invocation, backtrace, etc.) run all remaining steps
265
368
  // from the detected starting point so a standalone `ck run` still works end-to-end.
369
+ // The verdict already on the PR when this run started, if any — taken from the
370
+ // history step detection has to fetch anyway, so it costs nothing here. The
371
+ // no-verdict report re-reads at reporting time and prefers that; this is what
372
+ // it falls back to when the re-read fails, and all an explicit --steps run
373
+ // (which never fetches here) has to fall back to is nothing.
374
+ let standingVerdict;
266
375
  if (!opts.steps) {
267
376
  try {
268
- const history = await fetchStepHistory(owner, repo, number, token);
377
+ const history = await fetchStepHistoryWithRetry(owner, repo, number, token);
269
378
  // prData comes from pulls.get, which carries `mergeable` — so routing a conflicted
270
379
  // PR to conflict-resolve costs no extra call here.
271
380
  const nextResult = identifyNextWorkflowStep(history, allSteps, prData.head.sha, { mergeable: prData.mergeable });
381
+ standingVerdict = selectStandingVerdict(history);
272
382
  if (nextResult.step === null) {
273
383
  if (nextResult.stopReason === 'approved') {
274
384
  // This commit is approved; a push moves HEAD and re-opens the workflow.
@@ -304,7 +414,16 @@ export async function runRun(prUrl, opts = {}) {
304
414
  // so the subprocess exits non-zero and kickass records a retryable failure.
305
415
  throw err;
306
416
  }
307
- /* best-effort for other triggers fall through to normal review flow */
417
+ // Fail closed here too. This history is what carries the approval stop and the
418
+ // per-SHA dedup, so falling through without it re-reviews commits that are
419
+ // already approved — measured on 2026-09-19, when a batch of `ck run`
420
+ // invocations rate-limited this fetch and re-reviewed four already-APPROVEd
421
+ // SHAs. `--steps` remains the deliberate way to force a pass without history.
422
+ const message = err instanceof Error ? err.message : String(err);
423
+ fileLog({ level: 'warn', event: 'pr_skipped', repo: `${owner}/${repo}`, pr: number, reason: 'history_unavailable', sha: prData.head.sha, error: message });
424
+ console.log(chalk.yellow(`⚠ could not read PR history (${message}) — skipping rather than risk re-reviewing an approved commit`));
425
+ console.log(chalk.dim(' use --steps review to force a pass without history detection'));
426
+ return;
308
427
  }
309
428
  }
310
429
  const stepVendorOverrides = {
@@ -441,7 +560,15 @@ export async function runRun(prUrl, opts = {}) {
441
560
  // request still triggers releaseRemoteLock (GitHub may have already
442
561
  // created the pending status server-side).
443
562
  lockAttemptStarted = true;
444
- await acquireRemoteLock(octokit, owner, repo, sha);
563
+ if (!await claimRemoteLock(octokit, owner, repo, sha)) {
564
+ // Another instance claimed this commit inside the check-then-act window.
565
+ // Its claim is the live one, so leave it alone and drop ours.
566
+ releasePRLock(owner, repo, number, sha);
567
+ lockAttemptStarted = false;
568
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repo}`, pr: number, reason: 'lost_remote_claim', sha });
569
+ console.log(chalk.yellow(`⚠ PR #${number} was claimed by another crosscheck instance — skipping`));
570
+ return;
571
+ }
445
572
  }
446
573
  catch (err) {
447
574
  releasePRLock(owner, repo, number, sha);
@@ -470,12 +597,20 @@ export async function runRun(prUrl, opts = {}) {
470
597
  && linearWritePossible(config.linear, roundModeCanWrite ? undefined : filteredSteps)
471
598
  ? await resolveLinearAuth(config.linear, getLinearCredentials(config.linear.auth))
472
599
  : null;
473
- await clonePRForReview({
474
- owner, repo, prNumber: number, baseRef: prData.base.ref,
600
+ const { baseRefStatus } = await clonePRForReview({
601
+ owner, repo, prNumber: number, baseRef: prData.base.ref, baseSha: prData.base.sha,
475
602
  tmpDir, token, protocol: config.clone_protocol,
476
603
  onBaseFetchFailed: () => fileLog({ level: 'warn', event: 'base_branch_fetch_skipped', repo: `${owner}/${repo}`, pr: number, base: prData.base.ref }),
604
+ onBaseRefRecovered: status => fileLog({ level: 'info', event: 'base_ref_recovered', repo: `${owner}/${repo}`, pr: number, base: prData.base.ref, via: status }),
477
605
  });
478
606
  cloneSpinner.succeed('Repo ready');
607
+ if (baseRefStatus === 'unavailable') {
608
+ fileLog({ level: 'error', event: 'base_ref_unavailable', repo: `${owner}/${repo}`, pr: number, base: prData.base.ref, base_sha: prData.base.sha });
609
+ throw new BaseRefUnavailableError(prData.base.ref);
610
+ }
611
+ if (baseRefStatus !== 'fetched') {
612
+ console.log(chalk.yellow(` base ref origin/${prData.base.ref} was missing — recovered ${baseRefStatus === 'recovered_by_sha' ? 'from the PR base commit' : "from the PR's merge ref"}`));
613
+ }
479
614
  // Recover the linked tracker issue (if enabled) so the review is anchored
480
615
  // to the stated goal, not just the diff. Done here — after the PR and
481
616
  // remote locks are secured — so a PR that gets skipped (already under
@@ -501,6 +636,10 @@ export async function runRun(prUrl, opts = {}) {
501
636
  overrideTimeoutMs: reviewerTimeoutMs,
502
637
  trigger: opts.trigger ?? 'run',
503
638
  issueContext,
639
+ // Only the CLI flag counts. Resume also narrows `filteredSteps`, but it
640
+ // starts mid-workflow and still runs to the end, so it is not the operator
641
+ // scoping this run to a subset.
642
+ stepsExplicitlyScoped: opts.steps !== undefined,
504
643
  };
505
644
  let workflowResult = await runWorkflow({
506
645
  ...sharedCtx,
@@ -617,8 +756,8 @@ export async function runRun(prUrl, opts = {}) {
617
756
  // next attempt starts from a clean state. vendor_limit never touches files.
618
757
  if (workflowResult.fixSkipReason === 'fix_error') {
619
758
  try {
620
- execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: tmpDir, stdio: 'pipe' });
621
- execFileSync('git', ['clean', '-fd'], { cwd: tmpDir, stdio: 'pipe' });
759
+ runGitWithoutHooks(tmpDir, ['reset', '--hard', 'HEAD']);
760
+ runGitWithoutHooks(tmpDir, ['clean', '-fd']);
622
761
  }
623
762
  catch {
624
763
  fileLog({ level: 'warn', event: 'step_skipped', repo: `${owner}/${repo}`, pr: number, reason: 'worktree_reset_failed', mode, round: loopRound });
@@ -712,33 +851,66 @@ export async function runRun(prUrl, opts = {}) {
712
851
  }
713
852
  }
714
853
  activeSpinner.stop();
715
- // A run is "no step ran" only when every step across every round skipped.
716
- // If the first round skips everything but a later crazy/halfcrazy round
717
- // runs a fix, the overall run did work and should not report "no step ran".
718
- const ranNothing = accumulatedStepOutcomes !== undefined
719
- && accumulatedStepOutcomes.ran.length === 0
720
- && accumulatedStepOutcomes.skipped.length > 0;
721
- if (strategySkipped) {
722
- console.log(chalk.dim(`\n skipped ${strategySkipped} class, nothing to review`));
723
- }
724
- else if (accumulatedStepOutcomes && ranNothing) {
725
- // Every dispatched step skipped. `verdict —` on its own reads as "ran and
726
- // found nothing", and the green line below then certified a no-op as a
727
- // success which is how conflict-resolve skipping for want of a vendor
728
- // went unnoticed across a whole batch of PRs. The reasons are the report.
729
- console.log(chalk.yellow(`\n no step ran`));
730
- for (const { step, reason } of accumulatedStepOutcomes.skipped) {
731
- console.log(chalk.dim(` ${step} — ${reason}`));
732
- }
854
+ // Every run that ends without a verdict gets the same report, whatever the
855
+ // steps did. Keying on the steps instead "nothing ran", as this did
856
+ // before only moves the blind spot: PR #2548 ran its fix step, applied
857
+ // nothing, had recheck gated out by `fix.applied_count > 0`, and printed a
858
+ // bare `verdict —` under a green checkmark. So did a review that ran at
859
+ // full price and emitted no parseable VERDICT: line. What makes a verdict
860
+ // missing is what became of the steps that produce one.
861
+ if (verdict === null) {
862
+ // Both claims the report makes about the PR — what verdict stands, and
863
+ // whether it covers HEAD — are claims about now, so both are read now.
864
+ const standingNow = await standingVerdictForReport(standingVerdict, () => fetchStandingVerdictRecords(owner, repo, number, token));
865
+ const reportHeadSha = await headShaForStalenessClaim(standingNow, async () => {
866
+ const { data: currentPR } = await octokit.rest.pulls.get({ owner, repo, pull_number: number });
867
+ return currentPR.head.sha;
868
+ });
869
+ const report = buildNoVerdictReport({
870
+ workflowSteps: allSteps,
871
+ outcomes: accumulatedStepOutcomes,
872
+ strategySkipped,
873
+ // Resume narrows the step list too, but it still runs to the end of
874
+ // the workflow — only an explicit ask counts as deliberate scoping.
875
+ stepsExplicitlyScoped: opts.steps !== undefined || opts.trigger === 'kickass',
876
+ prUrl,
877
+ ...(standingNow && { standingVerdict: standingNow }),
878
+ ...(reportHeadSha !== undefined && { headSha: reportHeadSha }),
879
+ });
880
+ console.log('');
881
+ renderNoVerdictReport(report).forEach((line, i) => {
882
+ if (line === '')
883
+ return console.log('');
884
+ // The headline carries the alarm and the recommended command is what
885
+ // the reader is meant to copy, so neither is dimmed into the body.
886
+ if (i === 0)
887
+ return console.log(report.expected ? chalk.dim(` ${line}`) : chalk.yellow(` ${line}`));
888
+ console.log(line.startsWith('→ ') ? ` ${line}` : chalk.dim(` ${line}`));
889
+ });
890
+ fileLog({
891
+ level: report.expected ? 'info' : 'warn',
892
+ event: 'workflow_no_verdict',
893
+ repo: `${owner}/${repo}`,
894
+ pr: number,
895
+ cause: report.cause,
896
+ expected: report.expected,
897
+ verdict_steps: report.verdictSteps,
898
+ ...(accumulatedStepOutcomes && {
899
+ ran: accumulatedStepOutcomes.ran,
900
+ skipped: accumulatedStepOutcomes.skipped,
901
+ }),
902
+ });
903
+ // Exit code is unchanged: a run with no verdict is a legitimate outcome,
904
+ // not a failure, and the exit codes are part of the CLI contract. The
905
+ // marker is the signal — ⚠ only when a verdict was actually in reach.
906
+ console.log(report.expected
907
+ ? chalk.green(`\n✓ Workflow complete — ${prUrl}\n`)
908
+ : chalk.yellow(`\n⚠ Workflow complete, no verdict — ${prUrl}\n`));
733
909
  }
734
910
  else {
735
911
  console.log(`\n ${formatVerdict(verdict)}`);
912
+ console.log(chalk.green(`\n✓ Workflow complete — ${prUrl}\n`));
736
913
  }
737
- // Exit code is unchanged: a skipped step is a legitimate outcome, not a
738
- // failure, and the exit codes are part of the CLI contract.
739
- console.log(ranNothing && !strategySkipped
740
- ? chalk.yellow(`\n⚠ Workflow complete, no step ran — ${prUrl}\n`)
741
- : chalk.green(`\n✓ Workflow complete — ${prUrl}\n`));
742
914
  }
743
915
  catch (err) {
744
916
  workflowError = err;
@@ -820,9 +992,19 @@ export async function runRunSpec(spec, opts = {}) {
820
992
  process.exit(1);
821
993
  }
822
994
  if (refs.length === 1) {
823
- await runRun(refs[0].url, opts);
995
+ await runRun(refs[0].url, {
996
+ ...opts,
997
+ ...(refs[0].commentId !== undefined && { reviewCommentId: refs[0].commentId }),
998
+ });
824
999
  return;
825
1000
  }
1001
+ // Past this point the spec fans out across PRs; a comment anchor names exactly
1002
+ // one review and cannot be applied to all of them.
1003
+ const anchored = refs.filter(r => r.commentId !== undefined);
1004
+ if (anchored.length > 0) {
1005
+ console.error(chalk.red('✗ an #issuecomment- anchor targets a single review comment and cannot be combined with multiple PRs'));
1006
+ process.exit(1);
1007
+ }
826
1008
  // expected-head-sha is a single-PR guard (set by kickass dispatch); it can't
827
1009
  // apply to a fan-out across many heads.
828
1010
  if (opts.expectedHeadSha !== undefined) {