@humanbased/crosscheck 1.3.1-beta.5 → 1.5.0

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 (146) 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__/codex-env.test.js +4 -0
  9. package/dist/__tests__/codex-env.test.js.map +1 -1
  10. package/dist/__tests__/credential-free-origin.test.js +134 -2
  11. package/dist/__tests__/credential-free-origin.test.js.map +1 -1
  12. package/dist/__tests__/demo-arc.test.d.ts +2 -0
  13. package/dist/__tests__/demo-arc.test.d.ts.map +1 -0
  14. package/dist/__tests__/demo-arc.test.js +73 -0
  15. package/dist/__tests__/demo-arc.test.js.map +1 -0
  16. package/dist/__tests__/doc-only-verdict.test.d.ts +2 -0
  17. package/dist/__tests__/doc-only-verdict.test.d.ts.map +1 -0
  18. package/dist/__tests__/doc-only-verdict.test.js +79 -0
  19. package/dist/__tests__/doc-only-verdict.test.js.map +1 -0
  20. package/dist/__tests__/error-classification.test.js +14 -0
  21. package/dist/__tests__/error-classification.test.js.map +1 -1
  22. package/dist/__tests__/fix-requires-recheck.test.d.ts +2 -0
  23. package/dist/__tests__/fix-requires-recheck.test.d.ts.map +1 -0
  24. package/dist/__tests__/fix-requires-recheck.test.js +56 -0
  25. package/dist/__tests__/fix-requires-recheck.test.js.map +1 -0
  26. package/dist/__tests__/human-feedback.test.d.ts +2 -0
  27. package/dist/__tests__/human-feedback.test.d.ts.map +1 -0
  28. package/dist/__tests__/human-feedback.test.js +132 -0
  29. package/dist/__tests__/human-feedback.test.js.map +1 -0
  30. package/dist/__tests__/inconclusive-review.test.d.ts +2 -0
  31. package/dist/__tests__/inconclusive-review.test.d.ts.map +1 -0
  32. package/dist/__tests__/inconclusive-review.test.js +110 -0
  33. package/dist/__tests__/inconclusive-review.test.js.map +1 -0
  34. package/dist/__tests__/merge-gate.test.d.ts +2 -0
  35. package/dist/__tests__/merge-gate.test.d.ts.map +1 -0
  36. package/dist/__tests__/merge-gate.test.js +220 -0
  37. package/dist/__tests__/merge-gate.test.js.map +1 -0
  38. package/dist/__tests__/no-verdict.test.d.ts +2 -0
  39. package/dist/__tests__/no-verdict.test.d.ts.map +1 -0
  40. package/dist/__tests__/no-verdict.test.js +415 -0
  41. package/dist/__tests__/no-verdict.test.js.map +1 -0
  42. package/dist/__tests__/pr-spec.test.js +37 -0
  43. package/dist/__tests__/pr-spec.test.js.map +1 -1
  44. package/dist/__tests__/run.test.js +50 -1
  45. package/dist/__tests__/run.test.js.map +1 -1
  46. package/dist/__tests__/runner.test.js +82 -1
  47. package/dist/__tests__/runner.test.js.map +1 -1
  48. package/dist/__tests__/standing-verdict-history.test.d.ts +2 -0
  49. package/dist/__tests__/standing-verdict-history.test.d.ts.map +1 -0
  50. package/dist/__tests__/standing-verdict-history.test.js +109 -0
  51. package/dist/__tests__/standing-verdict-history.test.js.map +1 -0
  52. package/dist/__tests__/step-plan.test.d.ts +2 -0
  53. package/dist/__tests__/step-plan.test.d.ts.map +1 -0
  54. package/dist/__tests__/step-plan.test.js +129 -0
  55. package/dist/__tests__/step-plan.test.js.map +1 -0
  56. package/dist/cli.js +12 -0
  57. package/dist/cli.js.map +1 -1
  58. package/dist/commands/merge.d.ts +11 -0
  59. package/dist/commands/merge.d.ts.map +1 -0
  60. package/dist/commands/merge.js +188 -0
  61. package/dist/commands/merge.js.map +1 -0
  62. package/dist/commands/review.d.ts.map +1 -1
  63. package/dist/commands/review.js +29 -7
  64. package/dist/commands/review.js.map +1 -1
  65. package/dist/commands/run.d.ts +53 -0
  66. package/dist/commands/run.d.ts.map +1 -1
  67. package/dist/commands/run.js +197 -32
  68. package/dist/commands/run.js.map +1 -1
  69. package/dist/commands/watch.d.ts.map +1 -1
  70. package/dist/commands/watch.js +14 -3
  71. package/dist/commands/watch.js.map +1 -1
  72. package/dist/config/loader.d.ts +4 -0
  73. package/dist/config/loader.d.ts.map +1 -1
  74. package/dist/config/loader.js +8 -0
  75. package/dist/config/loader.js.map +1 -1
  76. package/dist/config/review-strategy.json +5 -5
  77. package/dist/github/client.d.ts +18 -1
  78. package/dist/github/client.d.ts.map +1 -1
  79. package/dist/github/client.js +19 -3
  80. package/dist/github/client.js.map +1 -1
  81. package/dist/github/merge.d.ts +28 -0
  82. package/dist/github/merge.d.ts.map +1 -1
  83. package/dist/github/merge.js +41 -0
  84. package/dist/github/merge.js.map +1 -1
  85. package/dist/github/webhook.d.ts +1 -0
  86. package/dist/github/webhook.d.ts.map +1 -1
  87. package/dist/github/webhook.js.map +1 -1
  88. package/dist/lib/board.d.ts +1 -0
  89. package/dist/lib/board.d.ts.map +1 -1
  90. package/dist/lib/board.js +1 -1
  91. package/dist/lib/board.js.map +1 -1
  92. package/dist/lib/clone.d.ts +65 -1
  93. package/dist/lib/clone.d.ts.map +1 -1
  94. package/dist/lib/clone.js +252 -16
  95. package/dist/lib/clone.js.map +1 -1
  96. package/dist/lib/human-feedback.d.ts +9 -0
  97. package/dist/lib/human-feedback.d.ts.map +1 -0
  98. package/dist/lib/human-feedback.js +83 -0
  99. package/dist/lib/human-feedback.js.map +1 -0
  100. package/dist/lib/logger.d.ts +1 -1
  101. package/dist/lib/logger.d.ts.map +1 -1
  102. package/dist/lib/logger.js +6 -0
  103. package/dist/lib/logger.js.map +1 -1
  104. package/dist/lib/merge-gate.d.ts +70 -0
  105. package/dist/lib/merge-gate.d.ts.map +1 -0
  106. package/dist/lib/merge-gate.js +138 -0
  107. package/dist/lib/merge-gate.js.map +1 -0
  108. package/dist/lib/no-verdict.d.ts +104 -0
  109. package/dist/lib/no-verdict.d.ts.map +1 -0
  110. package/dist/lib/no-verdict.js +262 -0
  111. package/dist/lib/no-verdict.js.map +1 -0
  112. package/dist/lib/pr-spec.d.ts +7 -0
  113. package/dist/lib/pr-spec.d.ts.map +1 -1
  114. package/dist/lib/pr-spec.js +12 -1
  115. package/dist/lib/pr-spec.js.map +1 -1
  116. package/dist/lib/pr-workflow-state.d.ts +37 -0
  117. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  118. package/dist/lib/pr-workflow-state.js +58 -1
  119. package/dist/lib/pr-workflow-state.js.map +1 -1
  120. package/dist/lib/review-strategy.d.ts +14 -0
  121. package/dist/lib/review-strategy.d.ts.map +1 -1
  122. package/dist/lib/review-strategy.js +32 -1
  123. package/dist/lib/review-strategy.js.map +1 -1
  124. package/dist/lib/runner.d.ts +51 -2
  125. package/dist/lib/runner.d.ts.map +1 -1
  126. package/dist/lib/runner.js +211 -105
  127. package/dist/lib/runner.js.map +1 -1
  128. package/dist/lib/verdict.d.ts +30 -1
  129. package/dist/lib/verdict.d.ts.map +1 -1
  130. package/dist/lib/verdict.js +94 -9
  131. package/dist/lib/verdict.js.map +1 -1
  132. package/dist/reviewers/codex-env.d.ts.map +1 -1
  133. package/dist/reviewers/codex-env.js +2 -0
  134. package/dist/reviewers/codex-env.js.map +1 -1
  135. package/dist/reviewers/codex.d.ts.map +1 -1
  136. package/dist/reviewers/codex.js +3 -1
  137. package/dist/reviewers/codex.js.map +1 -1
  138. package/dist/reviewers/fix.d.ts +2 -2
  139. package/dist/reviewers/fix.d.ts.map +1 -1
  140. package/dist/reviewers/fix.js +10 -4
  141. package/dist/reviewers/fix.js.map +1 -1
  142. package/docs/growth/launch/README.md +59 -0
  143. package/docs/growth/launch/essay-stop-letting-one-agent-review-its-own-code.md +118 -0
  144. package/docs/growth/launch/posts.md +175 -0
  145. package/docs/trust.md +16 -1
  146. package/package.json +11 -3
@@ -9,8 +9,9 @@ import { runCodexReview } from '../reviewers/codex.js';
9
9
  import { runClaudeReview } from '../reviewers/claude.js';
10
10
  import { runFixStep, runCodexFixStep } from '../reviewers/fix.js';
11
11
  import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
12
- import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING, applySeverityGate, SEVERITY_GATE_NOTE } from '../lib/verdict.js';
12
+ import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING, applySeverityGate, SEVERITY_GATE_NOTE, DOC_ONLY_GATE_NOTE, detectInconclusiveReview, reviewHasNoFindings } from '../lib/verdict.js';
13
13
  import { createGithubClient, postReviewComment, getLastCrossCheckCommentId, getLastCrossCheckReviewComment } from '../github/client.js';
14
+ import { fetchCommentsAfter, selectHumanFeedback, formatHumanFeedback } from '../lib/human-feedback.js';
14
15
  import { autoFixBranchName, autoFixPRIntro, sourcePRHasMerged } from '../github/superseded-fix-pr.js';
15
16
  import { verifyReviewedSha, isVerifiedReviewedSha, reviewedShaRejection } from '../github/reviewed-sha.js';
16
17
  import { resolveLinearAuth, withWorker } from '../linear/identity.js';
@@ -19,9 +20,10 @@ import { shouldPostToLinear } from '../linear/comment.js';
19
20
  import { getLinearCredentials } from '../config/loader.js';
20
21
  import { acquireRemoteLock, releaseRemoteLock } from '../github/review-status.js';
21
22
  import { log as fileLog, logError, classifyError } from '../lib/logger.js';
22
- import { buildCommitTrailers } from '../lib/annotation.js';
23
+ import { buildCommitTrailers, parseAnnotation } from '../lib/annotation.js';
24
+ import { shaCovers } from '../lib/pr-workflow-state.js';
23
25
  import { resolveClaudeModel, resolveCodexModel } from '../lib/review-models.js';
24
- import { resolveReviewStrategy, escalate, clampToLevels } from './review-strategy.js';
26
+ import { resolveReviewStrategy, escalate, clampToLevels, isDocOnlyChange } from './review-strategy.js';
25
27
  import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS } from '../config/schema.js';
26
28
  import { buildStepIdentityFields } from '../lib/event-fields.js';
27
29
  import { planAutoFixDelivery, forceWithLeaseArgs, parseLsRemoteOid, isLeaseRejection, assessFixBranchOwnership, isInvalidBaseError } from '../lib/auto-fix-branch.js';
@@ -33,6 +35,7 @@ import { tierTimeoutMs } from '../reviewers/tier-timeouts.js';
33
35
  import { loadSkillCatalog } from '../skills/catalog.js';
34
36
  import { createSkillActivationSession } from '../skills/broker.js';
35
37
  import { formatSkillAttribution } from '../skills/attribution.js';
38
+ import { CompromisedCloneError, runGitWithoutHooks, changedFilesVsBase } from './clone.js';
36
39
  const MAX_CROSSCHECK_COMMITS = 5;
37
40
  const FIX_RETRY_DELAY_MS = 2 * 60 * 1000;
38
41
  const REVIEW_RETRY_DELAY_MS = 2 * 60 * 1000;
@@ -46,6 +49,8 @@ function vendorTimeoutMs(timeoutSec) {
46
49
  // Auth and quota/credit failures are operator/vendor-capacity issues that won't
47
50
  // self-heal through an immediate retry. Transient subprocess failures can retry.
48
51
  export function isRetryableFixError(err) {
52
+ if (err instanceof CompromisedCloneError)
53
+ return false;
49
54
  const msg = err instanceof Error ? err.message : String(err);
50
55
  return !/auth failure|not logged in|claude auth/i.test(msg) && !isSubscriptionLimitError(err);
51
56
  }
@@ -149,18 +154,52 @@ export function resolveFixLanding(deliveryMode) {
149
154
  case 'pull_request': return 'branch-then-separate-pr';
150
155
  }
151
156
  }
157
+ export function planStepsForClass(input) {
158
+ const { configuredSteps, classSteps, stepsExplicitlyScoped } = input;
159
+ const none = { steps: configuredSteps, outcome: 'unchanged', classTypes: [], dropped: 0, wouldDrop: 0 };
160
+ if (!classSteps || classSteps.length === 0)
161
+ return none;
162
+ // Only the three depth-ladder types narrow. conflict-resolve is orthogonal to
163
+ // depth and is handled by filterStepsByTypes, which keeps it whenever the depth
164
+ // permits code modification — so it must not be treated as a class step here.
165
+ const classTypes = classSteps.filter((t) => t === 'review' || t === 'fix' || t === 'recheck');
166
+ if (classTypes.length === 0)
167
+ return none;
168
+ const narrowed = filterStepsByTypes(configuredSteps, classTypes);
169
+ const delta = configuredSteps.length - narrowed.length;
170
+ if (stepsExplicitlyScoped) {
171
+ return { steps: configuredSteps, outcome: delta > 0 ? 'bypassed' : 'unchanged', classTypes, dropped: 0, wouldDrop: delta };
172
+ }
173
+ return { steps: narrowed, outcome: delta > 0 ? 'narrowed' : 'unchanged', classTypes, dropped: delta, wouldDrop: 0 };
174
+ }
152
175
  // stepsRun holds every step the runner dispatched, skips included; results holds
153
176
  // what each one did. Split them so callers can report a run where nothing
154
177
  // happened without re-deriving the reasons from the log file.
155
178
  export function summariseStepOutcomes(stepsRun, results) {
156
179
  const outcomes = { ran: [], skipped: [] };
180
+ const detail = {};
157
181
  for (const name of new Set(stepsRun)) {
158
182
  const result = results[name];
159
- if (result?.skipped)
183
+ if (result?.skipped) {
160
184
  outcomes.skipped.push({ step: name, reason: result.skipReason ?? 'unknown' });
161
- else
162
- outcomes.ran.push(name);
185
+ continue;
186
+ }
187
+ outcomes.ran.push(name);
188
+ if (!result)
189
+ continue;
190
+ const entry = {
191
+ ...(result.vendor !== undefined && { vendor: result.vendor }),
192
+ ...(result.tokens_used !== undefined && { tokensUsed: result.tokens_used }),
193
+ ...(result.applied_count !== undefined && { appliedCount: result.applied_count }),
194
+ // Spread on key presence, not on truthiness: `verdict: null` is the whole
195
+ // point of recording this, and a falsiness check would drop it.
196
+ ...('verdict' in result && { verdict: result.verdict ?? null }),
197
+ };
198
+ if (Object.keys(entry).length > 0)
199
+ detail[name] = entry;
163
200
  }
201
+ if (Object.keys(detail).length > 0)
202
+ outcomes.ranDetail = detail;
164
203
  return outcomes;
165
204
  }
166
205
  // Accumulates outcomes across the fix→recheck rounds of one invocation, so the
@@ -186,7 +225,21 @@ export function mergeStepOutcomes(base, next) {
186
225
  if (!ranSet.has(entry.step))
187
226
  skipped.set(entry.step, entry.reason);
188
227
  }
189
- return { ran, skipped: [...skipped].map(([step, reason]) => ({ step, reason })) };
228
+ // A step observed again in the later round is re-described by it, wholesale:
229
+ // a review that produced no verdict in round 1 and APPROVE in round 2 must not
230
+ // keep round 1's `verdict: null`, or the report calls a judged run unjudged.
231
+ const nextRan = new Set(next.ran);
232
+ const ranDetail = {};
233
+ for (const [step, detail] of Object.entries(base.ranDetail ?? {})) {
234
+ if (!nextRan.has(step))
235
+ ranDetail[step] = detail;
236
+ }
237
+ Object.assign(ranDetail, next.ranDetail ?? {});
238
+ return {
239
+ ran,
240
+ skipped: [...skipped].map(([step, reason]) => ({ step, reason })),
241
+ ...(Object.keys(ranDetail).length > 0 && { ranDetail }),
242
+ };
190
243
  }
191
244
  function countComments(reviewText) {
192
245
  const bullets = (reviewText.match(/^[-*•]\s/gm) ?? []).length;
@@ -352,40 +405,18 @@ function diffBucket(totalLines) {
352
405
  */
353
406
  export function buildPRContext(ctx) {
354
407
  const { tmpDir, pr } = ctx;
355
- try {
356
- // execFileSync, not execSync: a git ref may legally contain `;`, `$( )` and
357
- // backticks, and this value drives routing rather than best-effort logging.
358
- const raw = execFileSync('git', ['diff', '--numstat', `origin/${pr.base.ref}...HEAD`], { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
359
- if (!raw)
360
- return null;
361
- const files = [];
362
- let additions = 0;
363
- let deletions = 0;
364
- for (const line of raw.split('\n')) {
365
- // numstat: <added>\t<deleted>\t<path>. Binary files report '-' for both.
366
- const [add, del, ...rest] = line.split('\t');
367
- const path = rest.join('\t').trim();
368
- if (!path)
369
- continue;
370
- files.push(path);
371
- additions += parseInt(add, 10) || 0;
372
- deletions += parseInt(del, 10) || 0;
373
- }
374
- if (files.length === 0)
375
- return null;
376
- return {
377
- files,
378
- additions,
379
- deletions,
380
- labels: pr.labels?.map(l => l.name) ?? [],
381
- title: pr.title,
382
- baseRef: pr.base.ref,
383
- ...(pr.base.repo.default_branch !== undefined && { defaultBranch: pr.base.repo.default_branch }),
384
- };
385
- }
386
- catch {
408
+ const changed = changedFilesVsBase(tmpDir, pr.base.ref);
409
+ if (!changed)
387
410
  return null;
388
- }
411
+ return {
412
+ files: changed.files,
413
+ additions: changed.additions,
414
+ deletions: changed.deletions,
415
+ labels: pr.labels?.map(l => l.name) ?? [],
416
+ title: pr.title,
417
+ baseRef: pr.base.ref,
418
+ ...(pr.base.repo.default_branch !== undefined && { defaultBranch: pr.base.repo.default_branch }),
419
+ };
389
420
  }
390
421
  /**
391
422
  * Folds a resolved strategy into the quality config the reviewers receive, so
@@ -558,7 +589,7 @@ export async function pushWithNonFastForwardHandling(params) {
558
589
  const { tmpDir, branch, token, log, fileLog, owner, repoName, prNumber } = params;
559
590
  const env = { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token };
560
591
  try {
561
- execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
592
+ runGitWithoutHooks(tmpDir, ['push', 'origin', `HEAD:${branch}`], env);
562
593
  }
563
594
  catch (pushErr) {
564
595
  const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
@@ -576,7 +607,7 @@ export async function pushWithNonFastForwardHandling(params) {
576
607
  const fixCommitSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, env, encoding: 'utf8', stdio: 'pipe' }).trim();
577
608
  try {
578
609
  // Fetch the latest state of the branch
579
- execFileSync('git', ['fetch', 'origin', branch], { cwd: tmpDir, env, stdio: 'pipe' });
610
+ runGitWithoutHooks(tmpDir, ['fetch', 'origin', branch], env);
580
611
  try {
581
612
  // Rebase our changes onto the latest branch state. Rebase onto FETCH_HEAD,
582
613
  // NOT `origin/${branch}`: crosscheck's clone fetches the PR head without a
@@ -585,18 +616,18 @@ export async function pushWithNonFastForwardHandling(params) {
585
616
  // land-on-branch push falls back to opening a separate fix PR. The fetch on
586
617
  // the line above sets FETCH_HEAD to exactly the tip we just pulled, so that
587
618
  // is the correct (and always-present) rebase target.
588
- execFileSync('git', ['rebase', 'FETCH_HEAD'], { cwd: tmpDir, env, stdio: 'pipe' });
619
+ runGitWithoutHooks(tmpDir, ['rebase', 'FETCH_HEAD'], env);
589
620
  }
590
621
  catch (rebaseConflictErr) {
591
622
  // If rebase fails (e.g., due to conflicts), abort it and restore the fix commit.
592
623
  // Otherwise the outer catch's fallback logic sees a repo in in-progress-rebase
593
624
  // state and incorrectly treats HEAD~1 as the fix commit.
594
- execFileSync('git', ['rebase', '--abort'], { cwd: tmpDir, env, stdio: 'pipe' });
595
- execFileSync('git', ['reset', '--hard', fixCommitSha], { cwd: tmpDir, env, stdio: 'pipe' });
625
+ runGitWithoutHooks(tmpDir, ['rebase', '--abort'], env);
626
+ runGitWithoutHooks(tmpDir, ['reset', '--hard', fixCommitSha], env);
596
627
  throw rebaseConflictErr;
597
628
  }
598
629
  // Retry the push
599
- execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
630
+ runGitWithoutHooks(tmpDir, ['push', 'origin', `HEAD:${branch}`], env);
600
631
  fileLog({
601
632
  level: 'info',
602
633
  event: 'push_rebase_succeeded',
@@ -643,6 +674,11 @@ export async function runWorkflow(ctx) {
643
674
  // legitimately classify differently. Each comment cites the class that
644
675
  // produced it, so the record stays accurate either way.
645
676
  const strategy = resolveStrategyForPR(ctx);
677
+ // Read from the diff rather than the resolved class: `docs` matches a *fraction*
678
+ // of doc churn and exists only in smart mode, whereas the verdict cap needs the
679
+ // all-or-nothing answer on every install. Independent of `strategy` for that
680
+ // reason, and cheap — buildPRContext is one `git diff --numstat`.
681
+ const docOnlyChange = isDocOnlyChange(buildPRContext(ctx)?.files ?? []);
646
682
  if (config.quality.mode === 'smart' && !strategy) {
647
683
  // A smart-mode install quietly behaving as fixed is otherwise invisible.
648
684
  fileLog({ level: 'warn', event: 'strategy_unresolved', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'pr_context_unavailable', fallback_tier: config.quality.tier });
@@ -667,25 +703,29 @@ export async function runWorkflow(ctx) {
667
703
  }
668
704
  // The class's step set NARROWS the configured pipeline; it never widens it.
669
705
  // A repo set to review-only stays review-only whatever the class says, which
670
- // matches how per-repo `crosscheck alter` overrides compose. Reuses
671
- // filterStepsByTypes so the conflict-resolve rule (orthogonal to the depth
672
- // ladder, kept only when the depth permits code modification) stays in one
673
- // place rather than being re-derived here.
674
- const steps = (() => {
675
- if (!strategy || strategy.steps.length === 0)
676
- return configuredSteps;
677
- const classTypes = strategy.steps.filter((t) => t === 'review' || t === 'fix' || t === 'recheck');
678
- if (classTypes.length === 0)
679
- return configuredSteps;
680
- const narrowed = filterStepsByTypes(configuredSteps, classTypes);
681
- const dropped = configuredSteps.length - narrowed.length;
682
- if (dropped > 0) {
683
- log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → ${classTypes.join(', ')} (${dropped} step${dropped === 1 ? '' : 's'} dropped)`));
684
- fileLog({ level: 'info', event: 'strategy_steps_narrowed', repo: `${owner}/${repoName}`, pr: prNumber, pr_class: strategy.classId, configured: configuredSteps.map((x) => x.type), applied: narrowed.map((x) => x.type), strategy_version: strategy.version });
685
- }
686
- return narrowed;
687
- })();
688
- if (strategy && strategy.tier === null) {
706
+ // matches how per-repo `crosscheck alter` overrides compose.
707
+ const stepPlan = planStepsForClass({
708
+ configuredSteps,
709
+ classSteps: strategy?.steps,
710
+ stepsExplicitlyScoped: ctx.stepsExplicitlyScoped === true,
711
+ });
712
+ if (stepPlan.outcome === 'bypassed') {
713
+ log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} would drop ${stepPlan.wouldDrop} step${stepPlan.wouldDrop === 1 ? '' : 's'} honouring --steps as given`));
714
+ fileLog({ level: 'info', event: 'strategy_narrowing_bypassed', repo: `${owner}/${repoName}`, pr: prNumber, pr_class: strategy.classId, requested: configuredSteps.map((x) => x.type), class_steps: stepPlan.classTypes, strategy_version: strategy.version });
715
+ }
716
+ else if (stepPlan.outcome === 'narrowed') {
717
+ log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → ${stepPlan.classTypes.join(', ')} (${stepPlan.dropped} step${stepPlan.dropped === 1 ? '' : 's'} dropped)`));
718
+ fileLog({ level: 'info', event: 'strategy_steps_narrowed', repo: `${owner}/${repoName}`, pr: prNumber, pr_class: strategy.classId, configured: configuredSteps.map((x) => x.type), applied: stepPlan.steps.map((x) => x.type), strategy_version: strategy.version });
719
+ }
720
+ const steps = stepPlan.steps;
721
+ // A null tier means the class skips this PR outright. An explicit `--steps`
722
+ // overrides that for the same reason it overrides narrowing — but say so, or the
723
+ // run looks like the class simply did not match.
724
+ if (strategy && strategy.tier === null && ctx.stepsExplicitlyScoped) {
725
+ log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} would skip this PR (${strategy.reason}) — honouring --steps as given`));
726
+ fileLog({ level: 'info', event: 'strategy_class_skip_bypassed', repo: `${owner}/${repoName}`, pr: prNumber, pr_class: strategy.classId, strategy_version: strategy.version });
727
+ }
728
+ if (strategy && strategy.tier === null && !ctx.stepsExplicitlyScoped) {
689
729
  log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → skipped (${strategy.reason})`));
690
730
  fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'strategy_class_skip', pr_class: strategy.classId, strategy_version: strategy.version });
691
731
  return { verdict: null, strategySkipped: strategy.classId };
@@ -871,18 +911,40 @@ export async function runWorkflow(ctx) {
871
911
  let effort;
872
912
  let retried;
873
913
  const skillSession = skillSessionFor(step.name, effectiveType);
914
+ // Hoisted up from the post-review linking code below (which still reuses
915
+ // this value): a recheck needs to know which review it is rechecking
916
+ // before it runs, not just when linking the posted comment afterward, so
917
+ // it can pull in anything the PR author (or their agent) said in reply.
918
+ let priorReviewId;
919
+ if (isRecheck) {
920
+ priorReviewId = Object.values(results).reverse().find(r => r.commentId !== undefined)?.commentId;
921
+ if (priorReviewId === undefined) {
922
+ priorReviewId = await getLastCrossCheckCommentId(owner, repoName, prNumber, token);
923
+ }
924
+ }
925
+ let humanFeedbackBlock = '';
926
+ if (priorReviewId !== undefined) {
927
+ try {
928
+ const commentsAfter = await fetchCommentsAfter(owner, repoName, prNumber, token, priorReviewId);
929
+ humanFeedbackBlock = formatHumanFeedback(selectHumanFeedback(commentsAfter, pr.user.login));
930
+ }
931
+ catch (err) {
932
+ fileLog({ level: 'warn', event: 'human_feedback_fetch_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
933
+ }
934
+ }
935
+ const reviewContext = [ctx.issueContext, humanFeedbackBlock].filter(Boolean).join('\n\n') || undefined;
874
936
  // Under `quality.mode: smart` the PR's class picks the tier; under fixed
875
937
  // this is config.quality untouched.
876
938
  const runReviewWithVendor = async (candidate) => {
877
939
  if (candidate === 'codex') {
878
940
  ;
879
- ({ 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));
941
+ ({ 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, reviewContext, skillSession, config.skills.codex_full_access));
880
942
  inputTokens = undefined;
881
943
  outputTokens = undefined;
882
944
  }
883
945
  else {
884
946
  ;
885
- ({ review: rawReview, tokensUsed, inputTokens, outputTokens, model, effort, retried } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, quality, claudeVendor, config.budget.per_review_usd, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), !!ctx.roundMode, log, ctx.issueContext, skillSession));
947
+ ({ review: rawReview, tokensUsed, inputTokens, outputTokens, model, effort, retried } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, quality, claudeVendor, config.budget.per_review_usd, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), !!ctx.roundMode, log, reviewContext, skillSession));
886
948
  }
887
949
  };
888
950
  try {
@@ -954,17 +1016,31 @@ export async function runWorkflow(ctx) {
954
1016
  if (parsed.verdict === null) {
955
1017
  fileLog({ level: 'warn', event: 'verdict_parse_failed', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, output_length: rawReview.length });
956
1018
  }
1019
+ // Inconclusive gate — must run BEFORE the severity gate. A reviewer that
1020
+ // reports it could not review produces a findings-free body, which the
1021
+ // severity gate reads as "nits only" and upgrades to APPROVE: a PR cleared
1022
+ // to merge on the strength of a review that never ran. Refuse to post at
1023
+ // all instead, the same way an unverifiable sha is refused below.
1024
+ const inconclusive = detectInconclusiveReview(clean);
1025
+ if (inconclusive.inconclusive) {
1026
+ fileLog({ level: 'error', event: 'review_inconclusive', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, reason: inconclusive.reason, raw_verdict: parsed.verdict, output_length: rawReview.length, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
1027
+ log(chalk.red(`✗ ${reviewer} did not review PR #${prNumber} — ${inconclusive.reason}`));
1028
+ // The review cost vendor tokens — surface it rather than lose it silently.
1029
+ log(chalk.dim(`\n--- unposted review ---\n${clean}\n--- end ---`));
1030
+ throw new Error(`${reviewer} review inconclusive — ${inconclusive.reason}`);
1031
+ }
957
1032
  // Severity gate: NEEDS WORK with only P3 nits is downgraded to APPROVE so
958
1033
  // suggestion-only reviews don't drive the fix/recheck loop. P2 (correctness)
959
1034
  // and above keep NEEDS WORK and require human attention before merge.
960
- const gate = applySeverityGate(parsed.verdict, clean);
1035
+ const gate = applySeverityGate(parsed.verdict, clean, { docOnly: docOnlyChange });
961
1036
  const verdict = gate.verdict;
962
1037
  if (gate.downgraded) {
963
- fileLog({ level: 'info', event: 'verdict_severity_gated', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, raw_verdict: parsed.verdict, gated_verdict: verdict });
1038
+ fileLog({ level: 'info', event: 'verdict_severity_gated', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, raw_verdict: parsed.verdict, gated_verdict: verdict, reason: gate.reason });
964
1039
  }
1040
+ const gateNote = gate.reason === 'doc_only' ? DOC_ONLY_GATE_NOTE : SEVERITY_GATE_NOTE;
965
1041
  const baseBody = verdict === null
966
1042
  ? `${NULL_VERDICT_WARNING}\n\n${clean}`
967
- : prependVerdictToComment(gate.downgraded ? `${SEVERITY_GATE_NOTE}\n\n${clean}` : clean, verdict);
1043
+ : prependVerdictToComment(gate.downgraded ? `${gateNote}\n\n${clean}` : clean, verdict);
968
1044
  // Skills are not folded into the body — postReviewComment renders the
969
1045
  // receipt beneath the attribution footer.
970
1046
  const commentBody = retried
@@ -989,16 +1065,9 @@ export async function runWorkflow(ctx) {
989
1065
  else {
990
1066
  onPhaseChange(isRecheck ? 'posting recheck...' : 'posting comment...', phaseUpdate);
991
1067
  const octokit = createGithubClient(token);
992
- // For rechecks: look up the original review comment ID so the recheck
993
- // can link back to it. Check in-run results first (single-run pipelines),
994
- // then fall back to GitHub (cross-run: recheck triggered by a new push).
995
- let priorReviewId;
996
- if (isRecheck) {
997
- priorReviewId = Object.values(results).reverse().find(r => r.commentId !== undefined)?.commentId;
998
- if (priorReviewId === undefined) {
999
- priorReviewId = await getLastCrossCheckCommentId(owner, repoName, prNumber, token);
1000
- }
1001
- }
1068
+ // priorReviewId (for rechecks: the original review comment to link back
1069
+ // to) is already resolved above, before the review ran reused here
1070
+ // rather than looked up a second time.
1002
1071
  // Pre-compute next_step for the annotation so readers can skip full
1003
1072
  // comment scans: find the first remaining step whose when-condition holds.
1004
1073
  const currentStepIdx = steps.indexOf(step);
@@ -1135,6 +1204,37 @@ export async function runWorkflow(ctx) {
1135
1204
  skipFix('no_review_comment');
1136
1205
  continue;
1137
1206
  }
1207
+ // A review with nothing actionable in it cannot produce a fix. Dispatching one
1208
+ // anyway spends a full vendor call to be told NO_CHANGES — the outcome that
1209
+ // made `ck fix` look like it had worked when its review was a non-review.
1210
+ if (reviewHasNoFindings(reviewCommentBody)) {
1211
+ log(chalk.dim(` review has no actionable findings — nothing for the fix step to apply`));
1212
+ skipFix('review_has_no_findings');
1213
+ continue;
1214
+ }
1215
+ // Findings raised against a different commit still usually apply, so this
1216
+ // warns rather than skips — but silently fixing against a stale review is how
1217
+ // a round gets spent re-fixing something the author already pushed.
1218
+ // shaCovers, not ===: annotations carry the short or full form interchangeably,
1219
+ // and a raw compare would call every short-form annotation stale.
1220
+ const reviewSha = parseAnnotation(reviewCommentBody)?.sha;
1221
+ if (reviewSha && !shaCovers(reviewSha, pr.head.sha)) {
1222
+ log(chalk.yellow(`⚠ review was written against ${reviewSha.slice(0, 7)}, PR head is ${pr.head.sha.slice(0, 7)} — fixing against a stale review`));
1223
+ fileLog({ level: 'warn', event: 'fix_review_stale', repo: `${owner}/${repoName}`, pr: prNumber, review_sha: reviewSha, head_sha: pr.head.sha, ...triggerField });
1224
+ }
1225
+ // Replies the PR author (or their agent) posted since that review — e.g.
1226
+ // "already fixed in the last commit" or "not applicable here" — so the
1227
+ // fixer doesn't blindly redo work the thread already settled.
1228
+ let fixHumanFeedback = '';
1229
+ if (reviewCommentId !== undefined) {
1230
+ try {
1231
+ const commentsAfter = await fetchCommentsAfter(owner, repoName, prNumber, token, reviewCommentId);
1232
+ fixHumanFeedback = formatHumanFeedback(selectHumanFeedback(commentsAfter, pr.user.login));
1233
+ }
1234
+ catch (err) {
1235
+ fileLog({ level: 'warn', event: 'human_feedback_fetch_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
1236
+ }
1237
+ }
1138
1238
  // Vendor is resolved from the workflow step's reviewer field, same as review/recheck steps.
1139
1239
  // Use 'origin' to fix with the same vendor that authored the PR (recommended default).
1140
1240
  // resolveFixVendor extends resolveReviewer with a human-origin fallback so human-authored
@@ -1185,17 +1285,19 @@ export async function runWorkflow(ctx) {
1185
1285
  const skillSession = skillSessionFor(step.name, effectiveType);
1186
1286
  const runFix = async (v) => {
1187
1287
  if (v === 'codex') {
1188
- 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);
1288
+ 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, fixHumanFeedback);
1189
1289
  }
1190
1290
  // roundConfig, not config: runFixStep reads vendors.claude.effort and
1191
1291
  // quality.tier out of it, and both must be this round's values.
1192
- return runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', roundConfig, claudeFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec) ?? tierMs, skillSession);
1292
+ return runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', roundConfig, claudeFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec) ?? tierMs, skillSession, fixHumanFeedback);
1193
1293
  };
1194
1294
  try {
1195
1295
  ;
1196
1296
  ({ appliedCount, changedFiles: fixChangedFiles, tokensUsed: fixTokensUsed, effort: fixEffort } = await runFix(vendor));
1197
1297
  }
1198
1298
  catch (err) {
1299
+ if (err instanceof CompromisedCloneError)
1300
+ throw err;
1199
1301
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1, vendor }, err);
1200
1302
  const fallbackVendor = resolveLimitFallbackVendor(vendor, effectiveType, config);
1201
1303
  if (isSubscriptionLimitError(err)) {
@@ -1211,6 +1313,8 @@ export async function runWorkflow(ctx) {
1211
1313
  activeVendor = fallbackVendor;
1212
1314
  }
1213
1315
  catch (fallbackErr) {
1316
+ if (fallbackErr instanceof CompromisedCloneError)
1317
+ throw fallbackErr;
1214
1318
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1, vendor: fallbackVendor }, fallbackErr);
1215
1319
  activeVendor = fallbackVendor; // retry uses the fallback vendor since primary already failed
1216
1320
  fixErr = fallbackErr;
@@ -1233,6 +1337,8 @@ export async function runWorkflow(ctx) {
1233
1337
  fixErr = undefined;
1234
1338
  }
1235
1339
  catch (retryErr) {
1340
+ if (retryErr instanceof CompromisedCloneError)
1341
+ throw retryErr;
1236
1342
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 2 }, retryErr);
1237
1343
  fixErr = retryErr;
1238
1344
  }
@@ -1297,14 +1403,14 @@ export async function runWorkflow(ctx) {
1297
1403
  const landing = resolveFixLanding(deliveryMode);
1298
1404
  if (landing === 'branch') {
1299
1405
  const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
1300
- execSync('git add -A', { cwd: tmpDir });
1301
- execFileSync('git', [
1406
+ runGitWithoutHooks(tmpDir, ['add', '-A']);
1407
+ runGitWithoutHooks(tmpDir, [
1302
1408
  'commit',
1303
1409
  '-m',
1304
1410
  fixCommitSubject(appliedCount, activeVendor),
1305
1411
  '-m',
1306
1412
  buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
1307
- ], { cwd: tmpDir });
1413
+ ]);
1308
1414
  const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
1309
1415
  await pushWithNonFastForwardHandling({
1310
1416
  tmpDir,
@@ -1367,14 +1473,14 @@ export async function runWorkflow(ctx) {
1367
1473
  // original PR. Only when that push can't land — e.g. the PR was merged and
1368
1474
  // its branch deleted, or the branch is protected — do we fall back to opening
1369
1475
  // a separate follow-up PR that carries the very same commit.
1370
- execSync('git add -A', { cwd: tmpDir });
1371
- execFileSync('git', [
1476
+ runGitWithoutHooks(tmpDir, ['add', '-A']);
1477
+ runGitWithoutHooks(tmpDir, [
1372
1478
  'commit',
1373
1479
  '-m',
1374
1480
  fixPRCommitSubject(prNumber, activeVendor),
1375
1481
  '-m',
1376
1482
  buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
1377
- ], { cwd: tmpDir });
1483
+ ]);
1378
1484
  const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
1379
1485
  let landedOnBranch = false;
1380
1486
  try {
@@ -1445,7 +1551,7 @@ export async function runWorkflow(ctx) {
1445
1551
  // either with a sha the repository cannot resolve — which verifyReviewedSha
1446
1552
  // refuses to post (#290) — or with one no open PR carries.
1447
1553
  const restorePRHeadInClone = () => {
1448
- execSync('git reset --hard HEAD~1', { cwd: tmpDir, stdio: 'pipe' });
1554
+ runGitWithoutHooks(tmpDir, ['reset', '--hard', 'HEAD~1']);
1449
1555
  };
1450
1556
  // Deliver the fix as a diff on the original PR. Used wherever opening the
1451
1557
  // follow-up PR would either create a stale artifact or overwrite a branch that
@@ -1559,7 +1665,7 @@ export async function runWorkflow(ctx) {
1559
1665
  continue;
1560
1666
  }
1561
1667
  try {
1562
- execFileSync('git', forceWithLeaseArgs(fixBranch, remoteOid), { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
1668
+ runGitWithoutHooks(tmpDir, forceWithLeaseArgs(fixBranch, remoteOid), gitEnv);
1563
1669
  }
1564
1670
  catch (pushErr) {
1565
1671
  const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
@@ -1582,7 +1688,7 @@ export async function runWorkflow(ctx) {
1582
1688
  if (remoteOid !== null)
1583
1689
  return;
1584
1690
  try {
1585
- execFileSync('git', ['push', 'origin', '--delete', fixBranch], { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
1691
+ runGitWithoutHooks(tmpDir, ['push', 'origin', '--delete', fixBranch], gitEnv);
1586
1692
  fileLog({ level: 'info', event: 'fix_branch_rolled_back', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, sha: newSha });
1587
1693
  }
1588
1694
  catch (deleteErr) {
@@ -1705,10 +1811,10 @@ export async function runWorkflow(ctx) {
1705
1811
  // real conflict markers and UU entries that findConflictedFiles can detect.
1706
1812
  let hasMergeConflicts = false;
1707
1813
  try {
1708
- execSync(`git merge --no-commit origin/${pr.base.ref}`, { cwd: tmpDir, stdio: 'pipe' });
1814
+ runGitWithoutHooks(tmpDir, ['merge', '--no-commit', `origin/${pr.base.ref}`]);
1709
1815
  // Clean merge — undo the staged merge state and skip this step
1710
1816
  try {
1711
- execSync('git merge --abort', { cwd: tmpDir });
1817
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1712
1818
  }
1713
1819
  catch { /* ignore */ }
1714
1820
  }
@@ -1722,7 +1828,7 @@ export async function runWorkflow(ctx) {
1722
1828
  const conflictedFiles = findConflictedFiles(tmpDir);
1723
1829
  if (conflictedFiles.length === 0) {
1724
1830
  try {
1725
- execSync('git merge --abort', { cwd: tmpDir });
1831
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1726
1832
  }
1727
1833
  catch { /* ignore */ }
1728
1834
  skipConflictResolve('no_conflicts');
@@ -1740,7 +1846,7 @@ export async function runWorkflow(ctx) {
1740
1846
  }
1741
1847
  if (!vendor) {
1742
1848
  try {
1743
- execSync('git merge --abort', { cwd: tmpDir });
1849
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1744
1850
  }
1745
1851
  catch { /* ignore */ }
1746
1852
  ;
@@ -1749,7 +1855,7 @@ export async function runWorkflow(ctx) {
1749
1855
  }
1750
1856
  if (vendor === 'codex') {
1751
1857
  try {
1752
- execSync('git merge --abort', { cwd: tmpDir });
1858
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1753
1859
  }
1754
1860
  catch { /* ignore */ }
1755
1861
  ;
@@ -1762,7 +1868,7 @@ export async function runWorkflow(ctx) {
1762
1868
  const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
1763
1869
  if (isFork) {
1764
1870
  try {
1765
- execSync('git merge --abort', { cwd: tmpDir });
1871
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1766
1872
  }
1767
1873
  catch { /* ignore */ }
1768
1874
  ;
@@ -1772,7 +1878,7 @@ export async function runWorkflow(ctx) {
1772
1878
  const crCommitCount = countCrosscheckCommitsForPRDetailed(tmpDir, pr.base.ref);
1773
1879
  if (crCommitCount.count >= MAX_CROSSCHECK_COMMITS) {
1774
1880
  try {
1775
- execSync('git merge --abort', { cwd: tmpDir });
1881
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1776
1882
  }
1777
1883
  catch { /* ignore */ }
1778
1884
  log(crCommitCount.scoped
@@ -1796,7 +1902,7 @@ export async function runWorkflow(ctx) {
1796
1902
  catch (err) {
1797
1903
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'conflict-resolve', attempt: 1 }, err);
1798
1904
  try {
1799
- execSync('git merge --abort', { cwd: tmpDir });
1905
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1800
1906
  }
1801
1907
  catch { /* ignore */ }
1802
1908
  skipConflictResolve(isSubscriptionLimitError(err) ? 'vendor_limit' : 'resolve_error');
@@ -1809,7 +1915,7 @@ export async function runWorkflow(ctx) {
1809
1915
  logSkillsNoneActivated(skillSession, { step_type: 'conflict-resolve', step_name: step.name });
1810
1916
  if (appliedCount === 0) {
1811
1917
  try {
1812
- execSync('git merge --abort', { cwd: tmpDir });
1918
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1813
1919
  }
1814
1920
  catch { /* ignore */ }
1815
1921
  onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: resolveTokensUsed });
@@ -1836,7 +1942,7 @@ export async function runWorkflow(ctx) {
1836
1942
  }
1837
1943
  if (filesWithMarkers.length > 0) {
1838
1944
  try {
1839
- execSync('git merge --abort', { cwd: tmpDir });
1945
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1840
1946
  }
1841
1947
  catch { /* ignore */ }
1842
1948
  log(chalk.yellow(`⚠ PR #${prNumber}: ${filesWithMarkers.length} file(s) still contain conflict markers — skipping commit`));
@@ -1853,7 +1959,7 @@ export async function runWorkflow(ctx) {
1853
1959
  // resolvedPaths is derived from model output and PR-controlled filenames.
1854
1960
  for (const p of resolvedPaths) {
1855
1961
  try {
1856
- execFileSync('git', ['add', '--', p], { cwd: tmpDir, stdio: 'pipe' });
1962
+ runGitWithoutHooks(tmpDir, ['add', '--', p]);
1857
1963
  }
1858
1964
  catch { /* skip */ }
1859
1965
  }
@@ -1868,7 +1974,7 @@ export async function runWorkflow(ctx) {
1868
1974
  catch { /* ignore */ }
1869
1975
  if (unmergedPaths.length > 0) {
1870
1976
  try {
1871
- execSync('git merge --abort', { cwd: tmpDir });
1977
+ runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1872
1978
  }
1873
1979
  catch { /* ignore */ }
1874
1980
  log(chalk.yellow(`⚠ PR #${prNumber}: ${unmergedPaths.length} unmerged path(s) remain after resolve — skipping commit`));
@@ -1876,13 +1982,13 @@ export async function runWorkflow(ctx) {
1876
1982
  skipConflictResolve('unmerged_paths');
1877
1983
  continue;
1878
1984
  }
1879
- execFileSync('git', [
1985
+ runGitWithoutHooks(tmpDir, [
1880
1986
  'commit',
1881
1987
  '-m',
1882
1988
  conflictResolveCommitSubject(conflictedFiles.length, vendor),
1883
1989
  '-m',
1884
1990
  buildCommitTrailers({ reviewer: vendor, model: conflictResolveModel, step: 'conflict-resolve', service: 'crosscheck' }),
1885
- ], { cwd: tmpDir });
1991
+ ]);
1886
1992
  const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
1887
1993
  await pushWithNonFastForwardHandling({
1888
1994
  tmpDir,