@humanbased/crosscheck 1.8.0-beta.17 → 1.8.0-beta.23

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 (55) hide show
  1. package/crosscheck.config.example.yml +23 -0
  2. package/dist/__tests__/conflict-resolve.test.js +93 -1
  3. package/dist/__tests__/conflict-resolve.test.js.map +1 -1
  4. package/dist/__tests__/onboard-preservation.test.js +15 -0
  5. package/dist/__tests__/onboard-preservation.test.js.map +1 -1
  6. package/dist/__tests__/opencode-auth.test.js +29 -1
  7. package/dist/__tests__/opencode-auth.test.js.map +1 -1
  8. package/dist/__tests__/opencode-cli-compat.test.d.ts +2 -0
  9. package/dist/__tests__/opencode-cli-compat.test.d.ts.map +1 -0
  10. package/dist/__tests__/opencode-cli-compat.test.js +108 -0
  11. package/dist/__tests__/opencode-cli-compat.test.js.map +1 -0
  12. package/dist/__tests__/opencode-policy.test.js +8 -3
  13. package/dist/__tests__/opencode-policy.test.js.map +1 -1
  14. package/dist/__tests__/opencode-standalone.test.js +42 -30
  15. package/dist/__tests__/opencode-standalone.test.js.map +1 -1
  16. package/dist/__tests__/optimize.test.js +1 -0
  17. package/dist/__tests__/optimize.test.js.map +1 -1
  18. package/dist/__tests__/runner.test.js +65 -21
  19. package/dist/__tests__/runner.test.js.map +1 -1
  20. package/dist/commands/issue.d.ts.map +1 -1
  21. package/dist/commands/issue.js +3 -5
  22. package/dist/commands/issue.js.map +1 -1
  23. package/dist/commands/onboard.d.ts +3 -0
  24. package/dist/commands/onboard.d.ts.map +1 -1
  25. package/dist/commands/onboard.js +92 -12
  26. package/dist/commands/onboard.js.map +1 -1
  27. package/dist/commands/optimize.d.ts.map +1 -1
  28. package/dist/commands/optimize.js +3 -5
  29. package/dist/commands/optimize.js.map +1 -1
  30. package/dist/config/schema.d.ts +37 -0
  31. package/dist/config/schema.d.ts.map +1 -1
  32. package/dist/config/schema.js +14 -0
  33. package/dist/config/schema.js.map +1 -1
  34. package/dist/lib/comment-bodies.d.ts +3 -0
  35. package/dist/lib/comment-bodies.d.ts.map +1 -1
  36. package/dist/lib/comment-bodies.js +2 -3
  37. package/dist/lib/comment-bodies.js.map +1 -1
  38. package/dist/lib/review-memory.d.ts +2 -2
  39. package/dist/lib/runner.d.ts +1 -0
  40. package/dist/lib/runner.d.ts.map +1 -1
  41. package/dist/lib/runner.js +114 -66
  42. package/dist/lib/runner.js.map +1 -1
  43. package/dist/reviewers/conflict-resolve.d.ts +7 -3
  44. package/dist/reviewers/conflict-resolve.d.ts.map +1 -1
  45. package/dist/reviewers/conflict-resolve.js +197 -11
  46. package/dist/reviewers/conflict-resolve.js.map +1 -1
  47. package/dist/reviewers/fix.d.ts.map +1 -1
  48. package/dist/reviewers/fix.js +8 -6
  49. package/dist/reviewers/fix.js.map +1 -1
  50. package/dist/reviewers/opencode.d.ts +25 -2
  51. package/dist/reviewers/opencode.d.ts.map +1 -1
  52. package/dist/reviewers/opencode.js +128 -47
  53. package/dist/reviewers/opencode.js.map +1 -1
  54. package/get-started.md +15 -1
  55. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { runCodexReview } from '../reviewers/codex.js';
9
9
  import { runClaudeReview } from '../reviewers/claude.js';
10
10
  import { runOpenCodeReview } from '../reviewers/opencode.js';
11
11
  import { runFixStep, runCodexFixStep, runOpenCodeFixStep } from '../reviewers/fix.js';
12
- import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
12
+ import { runConflictResolveForVendor, findConflictedFiles } from '../reviewers/conflict-resolve.js';
13
13
  import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING, applySeverityGate, SEVERITY_GATE_NOTE, DOC_ONLY_GATE_NOTE, detectInconclusiveReview, reviewHasNoFindings } from '../lib/verdict.js';
14
14
  import { createGithubClient, postReviewComment, getLastCrossCheckCommentId, getLastCrossCheckReviewComment } from '../github/client.js';
15
15
  import { fetchCommentsAfter, selectHumanFeedback, formatHumanFeedback } from '../lib/human-feedback.js';
@@ -250,36 +250,85 @@ function countComments(reviewText) {
250
250
  const numbered = (reviewText.match(/^\d+\.\s/gm) ?? []).length;
251
251
  return bullets + numbered;
252
252
  }
253
- function resolveReviewer(reviewer, origin, config, fallback) {
253
+ function priorityTaskForStep(stepType) {
254
+ if (stepType === 'conflict-resolve')
255
+ return 'conflict_resolve';
256
+ if (stepType === 'fix')
257
+ return 'fix';
258
+ return 'review';
259
+ }
260
+ function originVendorOf(origin) {
261
+ return origin === 'claude' || origin === 'codex' || origin === 'opencode' ? origin : null;
262
+ }
263
+ // Configured order first, then any other enabled/capable vendor so a newly
264
+ // enabled agent is still usable without re-running onboard. An empty configured
265
+ // list returns [] — callers then use the legacy codex-first order, which keeps
266
+ // pre-priority configs byte-for-byte behaviourally identical.
267
+ function priorityOrder(task, config, stepType) {
268
+ const configured = config.agent_priority?.[task] ?? [];
269
+ if (configured.length === 0)
270
+ return [];
271
+ const usable = (vendor) => config.vendors[vendor].enabled && supportsStep(vendor, stepType);
272
+ const ordered = configured.filter(usable);
273
+ for (const vendor of ['claude', 'codex', 'opencode']) {
274
+ if (usable(vendor) && !ordered.includes(vendor))
275
+ ordered.push(vendor);
276
+ }
277
+ return ordered;
278
+ }
279
+ function legacyAutoReviewer(origin, config) {
280
+ if (origin === 'claude' && config.vendors.codex.enabled)
281
+ return 'codex';
282
+ if (origin === 'claude' && config.vendors.opencode.enabled)
283
+ return 'opencode';
284
+ if (origin === 'codex' && config.vendors.claude.enabled)
285
+ return 'claude';
286
+ if (origin === 'codex' && config.vendors.opencode.enabled)
287
+ return 'opencode';
288
+ if (origin === 'opencode' && config.vendors.claude.enabled)
289
+ return 'claude';
290
+ if (origin === 'opencode' && config.vendors.codex.enabled)
291
+ return 'codex';
292
+ if (config.vendors.codex.enabled)
293
+ return 'codex';
294
+ if (config.vendors.claude.enabled)
295
+ return 'claude';
296
+ if (config.vendors.opencode.enabled)
297
+ return 'opencode';
298
+ return null;
299
+ }
300
+ function resolveReviewer(reviewer, origin, config, fallback, stepType = 'review') {
301
+ const originVendor = originVendorOf(origin);
302
+ const task = priorityTaskForStep(stepType);
303
+ const writesCode = stepType === 'fix' || stepType === 'conflict-resolve';
254
304
  if (reviewer === 'origin') {
255
- if (origin === 'claude' && config.vendors.claude.enabled)
256
- return 'claude';
257
- if (origin === 'codex' && config.vendors.codex.enabled)
258
- return 'codex';
259
- if (origin === 'opencode' && config.vendors.opencode.enabled)
260
- return 'opencode';
305
+ // Fix and conflict-resolve deliberately start from the PR author's own
306
+ // agent — it has the most context on the change. The configured order only
307
+ // decides who runs when the author is human or that agent is unavailable.
308
+ if (originVendor && config.vendors[originVendor].enabled && supportsStep(originVendor, stepType))
309
+ return originVendor;
310
+ if (writesCode) {
311
+ const preferred = priorityOrder(task, config, stepType)[0];
312
+ if (preferred)
313
+ return preferred;
314
+ }
261
315
  return fallback && config.vendors[fallback].enabled ? fallback : null;
262
316
  }
263
317
  if (reviewer === 'auto') {
264
- if (origin === 'claude' && config.vendors.codex.enabled)
265
- return 'codex';
266
- if (origin === 'claude' && config.vendors.opencode.enabled)
267
- return 'opencode';
268
- if (origin === 'codex' && config.vendors.claude.enabled)
269
- return 'claude';
270
- if (origin === 'codex' && config.vendors.opencode.enabled)
271
- return 'opencode';
272
- if (origin === 'opencode' && config.vendors.claude.enabled)
273
- return 'claude';
274
- if (origin === 'opencode' && config.vendors.codex.enabled)
275
- return 'codex';
276
- if (config.vendors.codex.enabled)
277
- return 'codex';
278
- if (config.vendors.claude.enabled)
279
- return 'claude';
280
- if (config.vendors.opencode.enabled)
281
- return 'opencode';
282
- return null;
318
+ if (writesCode && originVendor && config.vendors[originVendor].enabled && supportsStep(originVendor, stepType)) {
319
+ return originVendor;
320
+ }
321
+ let ordered = priorityOrder(task, config, stepType);
322
+ if (ordered.length === 0)
323
+ return legacyAutoReviewer(origin, config);
324
+ // Review is cross-vendor even when the author's agent tops the list. Fall
325
+ // back to it only when it is the sole enabled/capable vendor.
326
+ if (!writesCode && ordered.length > 1 && originVendor) {
327
+ const crossVendor = ordered.filter(vendor => vendor !== originVendor);
328
+ if (crossVendor.length > 0)
329
+ ordered = crossVendor;
330
+ }
331
+ return ordered[0] ?? null;
283
332
  }
284
333
  if (reviewer === 'claude')
285
334
  return config.vendors.claude.enabled ? 'claude' : (fallback && config.vendors[fallback].enabled ? fallback : null);
@@ -289,13 +338,19 @@ function resolveReviewer(reviewer, origin, config, fallback) {
289
338
  return config.vendors.opencode.enabled ? 'opencode' : (fallback && config.vendors[fallback].enabled ? fallback : null);
290
339
  return null;
291
340
  }
341
+ // Exported for tests and for review call sites that need the cross-vendor rule.
342
+ export function resolveReviewVendor(stepReviewer, origin, config, fallback) {
343
+ return resolveReviewer(stepReviewer, origin, config, fallback, 'review');
344
+ }
292
345
  function supportsStep(vendor, stepType) {
293
- if (stepType === 'review' || stepType === 'recheck' || stepType === 'fix')
294
- return true;
295
- // Conflict resolution is Claude-only until Codex/OpenCode conflict resolution exists.
296
- return vendor === 'claude';
346
+ void vendor;
347
+ return stepType === 'review' || stepType === 'recheck' || stepType === 'fix' || stepType === 'conflict-resolve';
297
348
  }
298
349
  function resolveLimitFallbackVendor(failedVendor, stepType, config) {
350
+ const ordered = priorityOrder(priorityTaskForStep(stepType), config, stepType)
351
+ .filter(vendor => vendor !== failedVendor);
352
+ if (ordered.length > 0)
353
+ return ordered[0];
299
354
  const candidates = failedVendor === 'claude'
300
355
  ? ['codex', 'opencode']
301
356
  : failedVendor === 'codex'
@@ -340,16 +395,12 @@ export function conflictResolveCommitSubject(conflictCount, vendor) {
340
395
  // unsupported-step skip instead of silently substituting a different vendor.
341
396
  // null disables the fallback entirely.
342
397
  function resolveStepVendor(stepType, stepReviewer, origin, config, fallback) {
343
- const vendor = resolveReviewer(stepReviewer, origin, config, fallback);
344
- // Origin detection can assign a vendor that cannot run the step: conflict
345
- // resolution is Claude-only, so a Codex-origin PR (reviewer: 'origin', origin:
346
- // 'codex') resolves to 'codex' and the dispatch skips it as unsupported — the
347
- // conflicts never get resolved. (#284's `Crosscheck-Reviewer: codex` detection
348
- // introduced this: these crosscheck-authored fix PRs used to detect as 'human'
349
- // and the auto fallback picked Claude.) Substitute a capable, enabled vendor.
350
- // Scoped to origin-derived assignment only — an explicit reviewer: claude|codex,
351
- // reviewer: auto, or routing.fallback_reviewer is an operator decision, left as
352
- // written so the caller can report the precise unsupported-step skip.
398
+ const vendor = resolveReviewer(stepReviewer, origin, config, fallback, stepType);
399
+ // Every current step supports every vendor, so this substitution is a guard
400
+ // for future step capabilities rather than a live path. It stays scoped to
401
+ // origin-derived assignment: an explicit reviewer choice or
402
+ // routing.fallback_reviewer is an operator decision and must be reported
403
+ // precisely instead of silently swapped.
353
404
  if (stepReviewer === 'origin' &&
354
405
  (origin === 'claude' || origin === 'codex' || origin === 'opencode') &&
355
406
  vendor !== null &&
@@ -359,6 +410,13 @@ function resolveStepVendor(stepType, stepReviewer, origin, config, fallback) {
359
410
  return { vendor: capable, usedHumanFallback: false, substitutedOriginVendor: vendor };
360
411
  }
361
412
  }
413
+ // A configured task order is the human-origin fallback now, so report it as
414
+ // one: the fix/conflict-resolve callers use this flag for their fallback log.
415
+ const humanOriginPriority = stepReviewer === 'origin' && origin === 'human'
416
+ && priorityOrder(priorityTaskForStep(stepType), config, stepType).length > 0;
417
+ if (vendor !== null && humanOriginPriority) {
418
+ return { vendor, usedHumanFallback: true };
419
+ }
362
420
  if (vendor !== null || origin !== 'human' || stepReviewer !== 'origin') {
363
421
  return { vendor, usedHumanFallback: false };
364
422
  }
@@ -371,10 +429,12 @@ function resolveStepVendor(stepType, stepReviewer, origin, config, fallback) {
371
429
  else if (fb === 'opencode')
372
430
  humanFallback = config.vendors.opencode.enabled ? 'opencode' : null;
373
431
  else if (fb !== null) {
374
- // 'auto': prefer codex then claude then opencode, same as resolveReviewer's
375
- // auto path, narrowed to vendors that support this step type.
432
+ // 'auto': the configured task order wins; with no order configured, keep
433
+ // the legacy codex-first preference. Either way, narrow to vendors that
434
+ // support this step type.
376
435
  const usable = (v) => config.vendors[v].enabled && supportsStep(v, stepType);
377
- humanFallback = usable('codex') ? 'codex' : usable('claude') ? 'claude' : usable('opencode') ? 'opencode' : null;
436
+ humanFallback = priorityOrder(priorityTaskForStep(stepType), config, stepType)[0]
437
+ ?? (usable('codex') ? 'codex' : usable('claude') ? 'claude' : usable('opencode') ? 'opencode' : null);
378
438
  }
379
439
  if (!humanFallback)
380
440
  return { vendor: null, usedHumanFallback: false };
@@ -950,7 +1010,7 @@ export async function runWorkflow(ctx) {
950
1010
  }
951
1011
  if (effectiveType === 'review' || effectiveType === 'recheck') {
952
1012
  const isRecheck = effectiveType === 'recheck';
953
- let reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
1013
+ let reviewer = resolveReviewVendor(step.reviewer, origin, config, ctx.smartSwitchFallback);
954
1014
  if (!reviewer) {
955
1015
  fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
956
1016
  results[step.name] = { skipped: true, skipReason: 'no_reviewer' };
@@ -1963,27 +2023,14 @@ export async function runWorkflow(ctx) {
1963
2023
  skipConflictResolve('no_vendor');
1964
2024
  continue;
1965
2025
  }
1966
- if (vendor === 'codex') {
1967
- try {
1968
- runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1969
- }
1970
- catch { /* ignore */ }
1971
- ;
1972
- skipConflictResolve('codex_conflict_resolve_unsupported');
1973
- continue;
1974
- }
1975
- if (vendor === 'opencode') {
1976
- try {
1977
- runGitWithoutHooks(tmpDir, ['merge', '--abort']);
1978
- }
1979
- catch { /* ignore */ }
1980
- ;
1981
- skipConflictResolve('opencode_conflict_resolve_unsupported');
1982
- continue;
1983
- }
1984
2026
  // Conflict-resolve is mechanical text surgery bounded by the markers —
1985
2027
  // measured at 37s against ~643s for a review — so it always runs fast.
1986
- const conflictResolveModel = resolveClaudeModel({ ...config.quality, tier: config.quality.mode === 'smart' ? 'fast' : config.quality.tier }, config.vendors.claude);
2028
+ const conflictResolveQuality = { ...config.quality, tier: config.quality.mode === 'smart' ? 'fast' : config.quality.tier };
2029
+ const conflictResolveModel = vendor === 'claude'
2030
+ ? resolveClaudeModel(conflictResolveQuality, config.vendors.claude)
2031
+ : vendor === 'codex'
2032
+ ? resolveCodexModel(conflictResolveQuality, config.vendors.codex)
2033
+ : resolveOpenCodeModel(conflictResolveQuality, config.vendors.opencode);
1987
2034
  const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
1988
2035
  if (isFork) {
1989
2036
  try {
@@ -2016,7 +2063,7 @@ export async function runWorkflow(ctx) {
2016
2063
  const skillSession = skillSessionFor(step.name, effectiveType);
2017
2064
  try {
2018
2065
  ;
2019
- ({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed, effort: resolveEffort } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? '', conflictResolveModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), skillSession, config.vendors.claude.effort));
2066
+ ({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed, effort: resolveEffort } = await runConflictResolveForVendor(vendor, tmpDir, pr.title, step.instructions ?? '', conflictResolveModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors[vendor].timeout_sec), skillSession, config.vendors[vendor].effort, config.skills.codex_full_access));
2020
2067
  }
2021
2068
  catch (err) {
2022
2069
  logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'conflict-resolve', attempt: 1 }, err);
@@ -2106,7 +2153,7 @@ export async function runWorkflow(ctx) {
2106
2153
  '-m',
2107
2154
  conflictResolveCommitSubject(conflictedFiles.length, vendor),
2108
2155
  '-m',
2109
- buildCommitTrailers({ reviewer: vendor, model: conflictResolveModel, step: 'conflict-resolve', service: 'crosscheck' }),
2156
+ buildCommitTrailers({ reviewer: vendor, model: conflictResolveModel ?? 'default', step: 'conflict-resolve', service: 'crosscheck' }),
2110
2157
  ]);
2111
2158
  const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
2112
2159
  await pushWithNonFastForwardHandling({
@@ -2149,6 +2196,7 @@ export async function runWorkflow(ctx) {
2149
2196
  effort: resolveEffort,
2150
2197
  author: authorAttribution ?? undefined,
2151
2198
  skills: activatedSkills,
2199
+ vendor,
2152
2200
  });
2153
2201
  await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
2154
2202
  fileLog({ level: 'info', event: 'conflict_resolved_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha });