@humanbased/crosscheck 1.2.0-beta.80 → 1.2.0-beta.81

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 (53) hide show
  1. package/README.md +51 -5
  2. package/crosscheck.config.example.yml +39 -8
  3. package/dist/__tests__/board.test.js +11 -0
  4. package/dist/__tests__/board.test.js.map +1 -1
  5. package/dist/__tests__/onboard-preservation.test.js +54 -3
  6. package/dist/__tests__/onboard-preservation.test.js.map +1 -1
  7. package/dist/__tests__/review-strategy.test.js +202 -0
  8. package/dist/__tests__/review-strategy.test.js.map +1 -1
  9. package/dist/commands/onboard.d.ts +25 -3
  10. package/dist/commands/onboard.d.ts.map +1 -1
  11. package/dist/commands/onboard.js +151 -46
  12. package/dist/commands/onboard.js.map +1 -1
  13. package/dist/commands/run.d.ts.map +1 -1
  14. package/dist/commands/run.js +22 -4
  15. package/dist/commands/run.js.map +1 -1
  16. package/dist/commands/watch.d.ts.map +1 -1
  17. package/dist/commands/watch.js +38 -6
  18. package/dist/commands/watch.js.map +1 -1
  19. package/dist/config/review-strategy.json +8 -6
  20. package/dist/config/schema.d.ts +7 -5
  21. package/dist/config/schema.d.ts.map +1 -1
  22. package/dist/config/schema.js +29 -7
  23. package/dist/config/schema.js.map +1 -1
  24. package/dist/github/client.d.ts +16 -1
  25. package/dist/github/client.d.ts.map +1 -1
  26. package/dist/github/client.js +32 -1
  27. package/dist/github/client.js.map +1 -1
  28. package/dist/github/webhook.d.ts +4 -0
  29. package/dist/github/webhook.d.ts.map +1 -1
  30. package/dist/github/webhook.js.map +1 -1
  31. package/dist/lib/annotation.d.ts +7 -0
  32. package/dist/lib/annotation.d.ts.map +1 -1
  33. package/dist/lib/annotation.js +11 -1
  34. package/dist/lib/annotation.js.map +1 -1
  35. package/dist/lib/board.d.ts +3 -0
  36. package/dist/lib/board.d.ts.map +1 -1
  37. package/dist/lib/board.js +4 -2
  38. package/dist/lib/board.js.map +1 -1
  39. package/dist/lib/comment-bodies.d.ts +2 -0
  40. package/dist/lib/comment-bodies.d.ts.map +1 -1
  41. package/dist/lib/comment-bodies.js +5 -1
  42. package/dist/lib/comment-bodies.js.map +1 -1
  43. package/dist/lib/review-strategy.d.ts +34 -4
  44. package/dist/lib/review-strategy.d.ts.map +1 -1
  45. package/dist/lib/review-strategy.js +71 -24
  46. package/dist/lib/review-strategy.js.map +1 -1
  47. package/dist/lib/runner.d.ts +80 -0
  48. package/dist/lib/runner.d.ts.map +1 -1
  49. package/dist/lib/runner.js +289 -17
  50. package/dist/lib/runner.js.map +1 -1
  51. package/docs/dynamic-thoroughness.md +677 -225
  52. package/get-started.md +67 -6
  53. package/package.json +1 -1
@@ -3,6 +3,7 @@ import type { PREvent } from '../github/webhook.js';
3
3
  import type { PROrigin } from '../github/detector.js';
4
4
  import type { Vendor } from '../lib/vendor.js';
5
5
  import { type ResolvedLinearAuth } from '../linear/identity.js';
6
+ import { type PRContext, type ResolvedStrategy } from './review-strategy.js';
6
7
  import { type StepResult } from '../lib/workflow.js';
7
8
  import type { PRPhase } from '../lib/board.js';
8
9
  export declare function isRetryableFixError(err: unknown): boolean;
@@ -85,6 +86,9 @@ export interface WorkflowContext {
85
86
  }
86
87
  export interface WorkflowResult {
87
88
  verdict: string | null;
89
+ /** Set when the review strategy classified the PR as not worth reviewing
90
+ * (e.g. a lockfile-only change). Carries the matched class id. */
91
+ strategySkipped?: string;
88
92
  fixAppliedCount?: number;
89
93
  fixSkipReason?: string;
90
94
  latestReviewComment?: {
@@ -96,5 +100,81 @@ export declare function resolveFixVendor(stepReviewer: string, origin: PROrigin,
96
100
  vendor: 'claude' | 'codex' | null;
97
101
  usedHumanFallback: boolean;
98
102
  };
103
+ /**
104
+ * Builds the input the review strategy classifies on, from the already-cloned
105
+ * working copy rather than the API — the runner has the repo on disk, so this
106
+ * costs one `git diff` instead of a round trip.
107
+ *
108
+ * Returns null when the diff can't be read. Callers then fall back to the
109
+ * configured tier, which is why `quality.tier` stays meaningful under smart mode.
110
+ */
111
+ export declare function buildPRContext(ctx: WorkflowContext): PRContext | null;
112
+ /**
113
+ * Folds a resolved strategy into the quality config the reviewers receive, so
114
+ * every downstream `quality.tier` read picks up the per-PR decision without
115
+ * threading a new parameter through each vendor signature.
116
+ *
117
+ * A null strategy — fixed mode, or an unreadable diff — returns the config
118
+ * untouched, which is why `quality.tier` remains the documented fallback.
119
+ */
120
+ export declare function strategyQuality(quality: WorkflowContext['config']['quality'], strategy: ResolvedStrategy | null): WorkflowContext['config']['quality'];
121
+ /**
122
+ * Applies the class's effort alongside its tier. Without this the strategy's
123
+ * effort was resolved and logged but never sent, so the run line named a level
124
+ * the CLI was never given.
125
+ *
126
+ * `accepted` is the vendor CLI's vocabulary, which is narrower than the model's:
127
+ * the strategy escalates to `xhigh` on round 3 and claude-opus-5 reasons at that
128
+ * level, but the claude CLI has no flag for it, so claudeEffort() mapped the
129
+ * unknown value to `medium` — a round 3 weaker than round 2. Clamp here, where
130
+ * the strategy meets the config, rather than at each CLI.
131
+ */
132
+ export declare function strategyVendor<T extends {
133
+ effort?: string;
134
+ }>(vendor: T, strategy: ResolvedStrategy | null, accepted: readonly string[]): T;
135
+ /**
136
+ * True when the strategy actually determined the model that ran.
137
+ *
138
+ * Judged from the resolved model rather than from config shape, because two
139
+ * different configs defeat the tier map:
140
+ * - an explicit `vendors.*.model` outranks it, and
141
+ * - codex under subscription auth with no `model`/`model_tiers` resolves every
142
+ * tier to the CLI's own `default`, so fast/balanced/thorough are the same run.
143
+ * In both cases the strategy's tier is not what happened, and citing it would
144
+ * assert a routing decision that never took place — the exact auditability
145
+ * property this feature exists to provide.
146
+ */
147
+ export declare function strategyDeterminedModel(vendor: {
148
+ model?: string | null;
149
+ }, strategy: ResolvedStrategy | null, resolvedModel?: string): boolean;
150
+ /**
151
+ * Classifies the PR and resolves the strategy, or returns null under
152
+ * `quality.mode: fixed` so the single configured tier applies unchanged.
153
+ */
154
+ export declare function resolveStrategyForPR(ctx: WorkflowContext): ResolvedStrategy | null;
155
+ export interface RoundExecution {
156
+ /** The class as escalated for this round; null under fixed mode. */
157
+ strategy: ResolvedStrategy | null;
158
+ quality: Config['quality'];
159
+ claudeVendor: Config['vendors']['claude'];
160
+ codexVendor: Config['vendors']['codex'];
161
+ /** `config` with the above folded in, for callees that take the whole config. */
162
+ roundConfig: Config;
163
+ escalated: boolean;
164
+ }
165
+ /**
166
+ * The tier, effort, and vendor configs every step of one round runs under.
167
+ *
168
+ * One function rather than a fold at each use site: the review step ran the
169
+ * escalated strategy while the fix step re-folded the base class, so a promoted
170
+ * round reviewed with the stronger model and then fixed with the weaker one —
171
+ * and took the weaker tier's subprocess timeout with it.
172
+ *
173
+ * Rounds beyond the first escalate: the class tier was already tried and did not
174
+ * resolve the PR, so difficulty is now measured rather than predicted. escalate()
175
+ * raises effort where the model supports it and promotes a tier where it does
176
+ * not, and never weakens the model.
177
+ */
178
+ export declare function resolveRoundExecution(config: Config, strategy: ResolvedStrategy | null, round: number): RoundExecution;
99
179
  export declare function runWorkflow(ctx: WorkflowContext): Promise<WorkflowResult>;
100
180
  //# sourceMappingURL=runner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/lib/runner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/lib/runner.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,7 @@ import { randomUUID } from 'crypto';
3
3
  import { readFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import chalk from 'chalk';
6
+ import { filterStepsByTypes } from './repo-workflow.js';
6
7
  import { runCodexReview } from '../reviewers/codex.js';
7
8
  import { runClaudeReview } from '../reviewers/claude.js';
8
9
  import { runFixStep, runCodexFixStep } from '../reviewers/fix.js';
@@ -17,6 +18,8 @@ import { acquireRemoteLock, releaseRemoteLock } from '../github/review-status.js
17
18
  import { log as fileLog, logError, classifyError } from '../lib/logger.js';
18
19
  import { buildCommitTrailers } from '../lib/annotation.js';
19
20
  import { resolveClaudeModel, resolveCodexModel } from '../lib/review-models.js';
21
+ import { resolveReviewStrategy, escalate, clampToLevels } from './review-strategy.js';
22
+ import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS } from '../config/schema.js';
20
23
  import { buildStepIdentityFields } from '../lib/event-fields.js';
21
24
  import { buildAttributionFooter, buildFixAppliedCommentBody, buildFixFailedCommentBody, buildConflictResolvedCommentBody, buildRetriedReviewBanner } from '../lib/comment-bodies.js';
22
25
  import { linearWritePossible, loadWorkflow, loadHarnessSection, evaluateWhen } from '../lib/workflow.js';
@@ -239,8 +242,173 @@ function diffBucket(totalLines) {
239
242
  return 'large';
240
243
  return 'xlarge';
241
244
  }
242
- function emitPRComplexity(ctx, triggerField) {
243
- const { owner, repoName, prNumber, tmpDir, pr, config } = ctx;
245
+ /**
246
+ * Builds the input the review strategy classifies on, from the already-cloned
247
+ * working copy rather than the API — the runner has the repo on disk, so this
248
+ * costs one `git diff` instead of a round trip.
249
+ *
250
+ * Returns null when the diff can't be read. Callers then fall back to the
251
+ * configured tier, which is why `quality.tier` stays meaningful under smart mode.
252
+ */
253
+ export function buildPRContext(ctx) {
254
+ const { tmpDir, pr } = ctx;
255
+ try {
256
+ // execFileSync, not execSync: a git ref may legally contain `;`, `$( )` and
257
+ // backticks, and this value drives routing rather than best-effort logging.
258
+ const raw = execFileSync('git', ['diff', '--numstat', `origin/${pr.base.ref}...HEAD`], { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
259
+ if (!raw)
260
+ return null;
261
+ const files = [];
262
+ let additions = 0;
263
+ let deletions = 0;
264
+ for (const line of raw.split('\n')) {
265
+ // numstat: <added>\t<deleted>\t<path>. Binary files report '-' for both.
266
+ const [add, del, ...rest] = line.split('\t');
267
+ const path = rest.join('\t').trim();
268
+ if (!path)
269
+ continue;
270
+ files.push(path);
271
+ additions += parseInt(add, 10) || 0;
272
+ deletions += parseInt(del, 10) || 0;
273
+ }
274
+ if (files.length === 0)
275
+ return null;
276
+ return {
277
+ files,
278
+ additions,
279
+ deletions,
280
+ labels: pr.labels?.map(l => l.name) ?? [],
281
+ title: pr.title,
282
+ baseRef: pr.base.ref,
283
+ ...(pr.base.repo.default_branch !== undefined && { defaultBranch: pr.base.repo.default_branch }),
284
+ };
285
+ }
286
+ catch {
287
+ return null;
288
+ }
289
+ }
290
+ /**
291
+ * Folds a resolved strategy into the quality config the reviewers receive, so
292
+ * every downstream `quality.tier` read picks up the per-PR decision without
293
+ * threading a new parameter through each vendor signature.
294
+ *
295
+ * A null strategy — fixed mode, or an unreadable diff — returns the config
296
+ * untouched, which is why `quality.tier` remains the documented fallback.
297
+ */
298
+ export function strategyQuality(quality, strategy) {
299
+ if (!strategy?.tier)
300
+ return quality;
301
+ return { ...quality, tier: strategy.tier };
302
+ }
303
+ /**
304
+ * Applies the class's effort alongside its tier. Without this the strategy's
305
+ * effort was resolved and logged but never sent, so the run line named a level
306
+ * the CLI was never given.
307
+ *
308
+ * `accepted` is the vendor CLI's vocabulary, which is narrower than the model's:
309
+ * the strategy escalates to `xhigh` on round 3 and claude-opus-5 reasons at that
310
+ * level, but the claude CLI has no flag for it, so claudeEffort() mapped the
311
+ * unknown value to `medium` — a round 3 weaker than round 2. Clamp here, where
312
+ * the strategy meets the config, rather than at each CLI.
313
+ */
314
+ export function strategyVendor(vendor, strategy, accepted) {
315
+ if (!strategy?.effort)
316
+ return vendor;
317
+ const effort = clampToLevels(strategy.effort, accepted);
318
+ if (effort === null)
319
+ return vendor;
320
+ return { ...vendor, effort };
321
+ }
322
+ // NOTE on the asymmetry with `model`: an explicit vendors.*.model is honored
323
+ // over the strategy, but vendors.*.effort is not. That is deliberate rather than
324
+ // an oversight — `effort` carries a schema default, so a parsed config cannot
325
+ // distinguish "the user chose medium" from "nobody set it", and treating the
326
+ // default as a user choice would disable effort escalation for everyone. The
327
+ // override is documented in crosscheck.config.example.yml next to the model
328
+ // note; set `quality.mode: fixed` to keep a hand-set effort on every call.
329
+ /**
330
+ * True when the strategy actually determined the model that ran.
331
+ *
332
+ * Judged from the resolved model rather than from config shape, because two
333
+ * different configs defeat the tier map:
334
+ * - an explicit `vendors.*.model` outranks it, and
335
+ * - codex under subscription auth with no `model`/`model_tiers` resolves every
336
+ * tier to the CLI's own `default`, so fast/balanced/thorough are the same run.
337
+ * In both cases the strategy's tier is not what happened, and citing it would
338
+ * assert a routing decision that never took place — the exact auditability
339
+ * property this feature exists to provide.
340
+ */
341
+ export function strategyDeterminedModel(vendor, strategy, resolvedModel) {
342
+ if (strategy === null || vendor.model)
343
+ return false;
344
+ // 'default' means the vendor CLI chose, not us.
345
+ return resolvedModel !== 'default';
346
+ }
347
+ /**
348
+ * Classifies the PR and resolves the strategy, or returns null under
349
+ * `quality.mode: fixed` so the single configured tier applies unchanged.
350
+ */
351
+ export function resolveStrategyForPR(ctx) {
352
+ if (ctx.config.quality.mode !== 'smart')
353
+ return null;
354
+ const prContext = buildPRContext(ctx);
355
+ if (!prContext)
356
+ return null;
357
+ return resolveReviewStrategy(prContext);
358
+ }
359
+ /**
360
+ * The tier, effort, and vendor configs every step of one round runs under.
361
+ *
362
+ * One function rather than a fold at each use site: the review step ran the
363
+ * escalated strategy while the fix step re-folded the base class, so a promoted
364
+ * round reviewed with the stronger model and then fixed with the weaker one —
365
+ * and took the weaker tier's subprocess timeout with it.
366
+ *
367
+ * Rounds beyond the first escalate: the class tier was already tried and did not
368
+ * resolve the PR, so difficulty is now measured rather than predicted. escalate()
369
+ * raises effort where the model supports it and promotes a tier where it does
370
+ * not, and never weakens the model.
371
+ */
372
+ export function resolveRoundExecution(config, strategy, round) {
373
+ if (!strategy) {
374
+ return {
375
+ strategy: null,
376
+ quality: config.quality,
377
+ claudeVendor: config.vendors.claude,
378
+ codexVendor: config.vendors.codex,
379
+ roundConfig: config,
380
+ escalated: false,
381
+ };
382
+ }
383
+ // The vendors that may actually run this round, each with the model it would
384
+ // use and the vocabulary its CLI accepts. Keyed to the enabled vendors rather
385
+ // than to claude alone: on a codex-only install the claude tier model is never
386
+ // called, so judging escalation by its effort ladder promoted a tier every
387
+ // round while codex sat at the effort it started on.
388
+ const baseQuality = strategyQuality(config.quality, strategy);
389
+ const lanes = [];
390
+ if (config.vendors.claude.enabled) {
391
+ lanes.push({ model: resolveClaudeModel(baseQuality, config.vendors.claude), accepted: CLAUDE_EFFORT_LEVELS });
392
+ }
393
+ if (config.vendors.codex.enabled) {
394
+ lanes.push({ model: resolveCodexModel(baseQuality, config.vendors.codex), accepted: CODEX_EFFORT_LEVELS });
395
+ }
396
+ const escalated = escalate({ tier: strategy.tier ?? config.quality.tier, effort: strategy.effort }, round, lanes);
397
+ const roundStrategy = { ...strategy, tier: escalated.tier, effort: escalated.effort };
398
+ const quality = strategyQuality(config.quality, roundStrategy);
399
+ const claudeVendor = strategyVendor(config.vendors.claude, roundStrategy, CLAUDE_EFFORT_LEVELS);
400
+ const codexVendor = strategyVendor(config.vendors.codex, roundStrategy, CODEX_EFFORT_LEVELS);
401
+ return {
402
+ strategy: roundStrategy,
403
+ quality,
404
+ claudeVendor,
405
+ codexVendor,
406
+ roundConfig: { ...config, quality, vendors: { ...config.vendors, claude: claudeVendor, codex: codexVendor } },
407
+ escalated: escalated.tier !== strategy.tier || escalated.effort !== strategy.effort,
408
+ };
409
+ }
410
+ function emitPRComplexity(ctx, triggerField, effectiveTierForRun) {
411
+ const { owner, repoName, prNumber, tmpDir, pr } = ctx;
244
412
  try {
245
413
  const raw = execSync(`git diff --stat origin/${pr.base.ref}...HEAD`, { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
246
414
  if (!raw)
@@ -271,7 +439,7 @@ function emitPRComplexity(ctx, triggerField) {
271
439
  diff_bucket: diffBucket(insertions + deletions),
272
440
  file_mix: mix,
273
441
  languages: [...langSet],
274
- quality_tier: config.quality.tier,
442
+ quality_tier: effectiveTierForRun,
275
443
  ...triggerField,
276
444
  });
277
445
  }
@@ -340,12 +508,68 @@ async function pushWithNonFastForwardHandling(params) {
340
508
  export async function runWorkflow(ctx) {
341
509
  const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange, trigger } = ctx;
342
510
  const triggerField = trigger !== undefined ? { trigger } : {};
343
- const steps = (ctx.steps ?? loadWorkflow(process.cwd())).map(step => {
511
+ const configuredSteps = (ctx.steps ?? loadWorkflow(process.cwd())).map(step => {
344
512
  if (!step.harness || step.instructions)
345
513
  return step;
346
514
  const resolved = loadHarnessSection(step.harness, process.cwd());
347
515
  return resolved ? { ...step, instructions: resolved } : step;
348
516
  });
517
+ // Resolved once per runWorkflow call: the fix step pushes commits, so
518
+ // re-classifying per step could yield a different class and make the review
519
+ // and recheck comments cite different tiers for the same PR.
520
+ //
521
+ // Not once per PR: --crazy/--halfcrazy re-enter runWorkflow per round, and by
522
+ // then the diff includes crosscheck's own fix commits, so a later round can
523
+ // legitimately classify differently. Each comment cites the class that
524
+ // produced it, so the record stays accurate either way.
525
+ const strategy = resolveStrategyForPR(ctx);
526
+ if (config.quality.mode === 'smart' && !strategy) {
527
+ // A smart-mode install quietly behaving as fixed is otherwise invisible.
528
+ fileLog({ level: 'warn', event: 'strategy_unresolved', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'pr_context_unavailable', fallback_tier: config.quality.tier });
529
+ }
530
+ else if (strategy) {
531
+ // A config written before `mode` existed parses as smart on upgrade, so a
532
+ // hand-set `quality.tier` can be silently overridden. onboard preserves the
533
+ // old tier by reading raw yaml, but that only helps users who re-run it —
534
+ // so record it here for everyone else.
535
+ //
536
+ // info, not warn: `config.quality.tier` carries a schema default of
537
+ // `balanced` on every install, so the parsed config cannot tell a hand-set
538
+ // tier from an unset one. Five of the eight classes resolve to something
539
+ // other than balanced, which made this fire on the majority of PRs — and
540
+ // recommend a `mode: fixed` opt-out to users who never chose a tier at all.
541
+ // Only the raw yaml can draw that distinction (thoroughnessDefaults), and it
542
+ // is not available on this path.
543
+ if (strategy.tier && strategy.tier !== config.quality.tier) {
544
+ fileLog({ level: 'info', event: 'strategy_overrode_configured_tier', repo: `${owner}/${repoName}`, pr: prNumber, configured_tier: config.quality.tier, applied_tier: strategy.tier, pr_class: strategy.classId });
545
+ }
546
+ fileLog({ level: 'info', event: 'strategy_resolved', repo: `${owner}/${repoName}`, pr: prNumber, strategy_version: strategy.version, pr_class: strategy.classId, tier: strategy.tier, effort: strategy.effort, steps: strategy.steps, domain: strategy.domain });
547
+ }
548
+ // The class's step set NARROWS the configured pipeline; it never widens it.
549
+ // A repo set to review-only stays review-only whatever the class says, which
550
+ // matches how per-repo `crosscheck alter` overrides compose. Reuses
551
+ // filterStepsByTypes so the conflict-resolve rule (orthogonal to the depth
552
+ // ladder, kept only when the depth permits code modification) stays in one
553
+ // place rather than being re-derived here.
554
+ const steps = (() => {
555
+ if (!strategy || strategy.steps.length === 0)
556
+ return configuredSteps;
557
+ const classTypes = strategy.steps.filter((t) => t === 'review' || t === 'fix' || t === 'recheck');
558
+ if (classTypes.length === 0)
559
+ return configuredSteps;
560
+ const narrowed = filterStepsByTypes(configuredSteps, classTypes);
561
+ const dropped = configuredSteps.length - narrowed.length;
562
+ if (dropped > 0) {
563
+ log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → ${classTypes.join(', ')} (${dropped} step${dropped === 1 ? '' : 's'} dropped)`));
564
+ 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 });
565
+ }
566
+ return narrowed;
567
+ })();
568
+ if (strategy && strategy.tier === null) {
569
+ log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → skipped (${strategy.reason})`));
570
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'strategy_class_skip', pr_class: strategy.classId, strategy_version: strategy.version });
571
+ return { verdict: null, strategySkipped: strategy.classId };
572
+ }
349
573
  const results = {};
350
574
  // SHAs the workflow pushed AND set a `crosscheck/review` pending status on.
351
575
  // Each one must be released in the finally below — otherwise the pending
@@ -426,7 +650,15 @@ export async function runWorkflow(ctx) {
426
650
  const workflowStart = Date.now();
427
651
  const stepsRun = [];
428
652
  let currentStepName;
429
- emitPRComplexity(ctx, triggerField);
653
+ // Class picks tier AND effort under smart; untouched config under fixed.
654
+ // Every step of this round reads from here — review, fix, and recheck alike —
655
+ // so a promoted round cannot review with one model and fix with another.
656
+ //
657
+ // Above the try, and above emitPRComplexity, because both the complexity event
658
+ // and workflow_complete report the tier that ran: an escalated round reporting
659
+ // the base class tier is the same defect as a comment citing one.
660
+ const { strategy: roundStrategy, quality, claudeVendor, codexVendor, roundConfig, escalated } = resolveRoundExecution(config, strategy, ctx.round ?? 1);
661
+ emitPRComplexity(ctx, triggerField, quality.tier);
430
662
  try {
431
663
  // Inside the try so a preflight failure still reaches the completion handler in
432
664
  // the finally — resolving above it meant a failed mint skipped workflow_complete
@@ -438,6 +670,22 @@ export async function runWorkflow(ctx) {
438
670
  linearAuth = await resolveLinearAuth(config.linear, getLinearCredentials(config.linear.auth));
439
671
  fileLog({ level: 'info', event: 'linear_auth_resolved', repo: `${owner}/${repoName}`, pr: prNumber, mode: linearAuth.mode, actor: linearAuth.actor });
440
672
  }
673
+ // Logged once per run, not per step: nothing here depends on `step`, and
674
+ // recomputing inside the loop printed the same line for review and recheck.
675
+ if (strategy && roundStrategy) {
676
+ // Report the effort each vendor was actually GIVEN, not the level the round
677
+ // asked for. The two CLI vocabularies differ, so one round can send codex
678
+ // `xhigh` and claude `high`; printing the request names a level nobody ran.
679
+ const appliedEffort = [...new Set([
680
+ ...(config.vendors.claude.enabled ? [claudeVendor.effort] : []),
681
+ ...(config.vendors.codex.enabled ? [codexVendor.effort] : []),
682
+ ])].join('/');
683
+ const escalatedNote = escalated ? ` · round ${ctx.round} escalated` : '';
684
+ log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → ${roundStrategy.tier ?? 'skip'} tier${appliedEffort ? ` (${appliedEffort})` : ''}${escalatedNote}`));
685
+ if (escalatedNote) {
686
+ fileLog({ level: 'info', event: 'strategy_escalated', repo: `${owner}/${repoName}`, pr: prNumber, round: ctx.round, from_tier: strategy.tier, to_tier: roundStrategy.tier, from_effort: strategy.effort, to_effort: roundStrategy.effort, applied_effort_claude: config.vendors.claude.enabled ? claudeVendor.effort : null, applied_effort_codex: config.vendors.codex.enabled ? codexVendor.effort : null, strategy_version: strategy.version });
687
+ }
688
+ }
441
689
  for (const step of steps) {
442
690
  currentStepName = step.name;
443
691
  stepsRun.push(step.name);
@@ -503,16 +751,18 @@ export async function runWorkflow(ctx) {
503
751
  let effort;
504
752
  let retried;
505
753
  const skillSession = skillSessionFor(step.name, effectiveType);
754
+ // Under `quality.mode: smart` the PR's class picks the tier; under fixed
755
+ // this is config.quality untouched.
506
756
  const runReviewWithVendor = async (candidate) => {
507
757
  if (candidate === 'codex') {
508
758
  ;
509
- ({ review: rawReview, tokensUsed, model, effort, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.codex, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec), log, ctx.issueContext, skillSession));
759
+ ({ review: rawReview, tokensUsed, model, effort, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title, quality, codexVendor, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec), log, ctx.issueContext, skillSession));
510
760
  inputTokens = undefined;
511
761
  outputTokens = undefined;
512
762
  }
513
763
  else {
514
764
  ;
515
- ({ review: rawReview, tokensUsed, inputTokens, outputTokens, model, effort, retried } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.claude, config.budget.per_review_usd, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), !!ctx.roundMode, log, ctx.issueContext, skillSession));
765
+ ({ 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));
516
766
  }
517
767
  };
518
768
  try {
@@ -604,8 +854,8 @@ export async function runWorkflow(ctx) {
604
854
  fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, tokens_used: tokensUsed, skills_activated: activatedSkills.map(skill => skill.name), ...(inputTokens !== undefined && { input_tokens: inputTokens }), ...(outputTokens !== undefined && { output_tokens: outputTokens }), ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }), ...triggerField });
605
855
  // Recheck verdict is stored separately to preserve the original review's commentCount on the board
606
856
  const phaseUpdate = isRecheck
607
- ? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: config.quality.tier }
608
- : { verdict, commentCount, phase: donePhase, crTokens: tokensUsed, crReviewer: reviewer, qualityTier: config.quality.tier };
857
+ ? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: quality.tier }
858
+ : { verdict, commentCount, phase: donePhase, crTokens: tokensUsed, crReviewer: reviewer, qualityTier: quality.tier };
609
859
  if (ctx.dryRun) {
610
860
  onPhaseChange('dry-run — comment not posted', phaseUpdate);
611
861
  log(chalk.dim(`\n--- dry-run: comment that would be posted ---\n${commentBody}\n--- end ---`));
@@ -640,7 +890,13 @@ export async function runWorkflow(ctx) {
640
890
  annotationSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
641
891
  }
642
892
  catch { /* fall back to pr.head.sha if git is unavailable */ }
643
- const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck, model, effectiveType, ctx.round ?? 1, annotationSha, nextStepAnnotation, ctx.trigger === 'kickass' ? 'kickass' : undefined, activatedSkills, effort);
893
+ const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck, model, effectiveType, ctx.round ?? 1, annotationSha, nextStepAnnotation, ctx.trigger === 'kickass' ? 'kickass' : undefined, activatedSkills, effort,
894
+ // Withheld when an explicit vendors.*.model overrode the tier map:
895
+ // citing a tier the run did not use would assert a routing decision
896
+ // that never happened.
897
+ strategyDeterminedModel(reviewer === 'codex' ? config.vendors.codex : config.vendors.claude, roundStrategy, model) && roundStrategy?.tier
898
+ ? { version: roundStrategy.version, classId: roundStrategy.classId, tier: roundStrategy.tier, reason: roundStrategy.reason }
899
+ : undefined);
644
900
  const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
645
901
  fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
646
902
  // Mirror the verdict onto the PR's Linear issue. `run` and `watch` both
@@ -734,8 +990,14 @@ export async function runWorkflow(ctx) {
734
990
  skipFix('no_vendor');
735
991
  continue;
736
992
  }
737
- const claudeFixModel = resolveClaudeModel(config.quality, config.vendors.claude);
738
- const codexFixModel = resolveCodexModel(config.quality, config.vendors.codex);
993
+ // The fix step holds the review's tier — this round's, escalation
994
+ // included: it is generation against an explicit findings list, which
995
+ // models handle well, but a cheap fixer that introduces a regression costs
996
+ // a whole extra round. Recheck does not step down either — it decides
997
+ // whether to spend another round, and a weak judge there is how loops run
998
+ // away.
999
+ const claudeFixModel = resolveClaudeModel(quality, claudeVendor);
1000
+ const codexFixModel = resolveCodexModel(quality, codexVendor);
739
1001
  // Guard: don't push more than MAX_CROSSCHECK_COMMITS per PR.
740
1002
  // Scope to commits ahead of base so long-lived branches (e.g. staging)
741
1003
  // don't count [crosscheck] commits from previously merged PRs.
@@ -760,13 +1022,17 @@ export async function runWorkflow(ctx) {
760
1022
  let fixEffort;
761
1023
  let fixErr = undefined;
762
1024
  let activeVendor = vendor;
763
- const tierMs = tierTimeoutMs(config.quality.tier);
1025
+ // The strategy tier, not the configured one: a risky PR runs the thorough
1026
+ // model here, and the balanced 600s budget would cut it off.
1027
+ const tierMs = tierTimeoutMs(quality.tier);
764
1028
  const skillSession = skillSessionFor(step.name, effectiveType);
765
1029
  const runFix = async (v) => {
766
1030
  if (v === 'codex') {
767
- return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs, skillSession, config.vendors.codex.effort);
1031
+ return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs, skillSession, codexVendor.effort);
768
1032
  }
769
- return runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', config, claudeFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec) ?? tierMs, skillSession);
1033
+ // roundConfig, not config: runFixStep reads vendors.claude.effort and
1034
+ // quality.tier out of it, and both must be this round's values.
1035
+ return runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', roundConfig, claudeFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec) ?? tierMs, skillSession);
770
1036
  };
771
1037
  try {
772
1038
  ;
@@ -1069,7 +1335,9 @@ export async function runWorkflow(ctx) {
1069
1335
  skipConflictResolve('codex_conflict_resolve_unsupported');
1070
1336
  continue;
1071
1337
  }
1072
- const conflictResolveModel = resolveClaudeModel(config.quality, config.vendors.claude);
1338
+ // Conflict-resolve is mechanical text surgery bounded by the markers —
1339
+ // measured at 37s against ~643s for a review — so it always runs fast.
1340
+ const conflictResolveModel = resolveClaudeModel({ ...config.quality, tier: config.quality.mode === 'smart' ? 'fast' : config.quality.tier }, config.vendors.claude);
1073
1341
  const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
1074
1342
  if (isFork) {
1075
1343
  try {
@@ -1324,7 +1592,11 @@ export async function runWorkflow(ctx) {
1324
1592
  failedStep,
1325
1593
  round: ctx.round,
1326
1594
  trigger: ctx.trigger,
1327
- qualityTier: config.quality.tier,
1595
+ // The tier that actually ran, not the configured one and not the base
1596
+ // class tier — under smart mode all three differ, and telemetry naming the
1597
+ // wrong one is the same class of problem as a comment citing a tier that
1598
+ // never reached the vendor.
1599
+ qualityTier: quality.tier,
1328
1600
  }));
1329
1601
  }
1330
1602
  }