@expo/code-review-cli 0.6.0 → 0.8.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 (54) hide show
  1. package/README.md +151 -25
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +307 -36
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +170 -33
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +86 -11
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +99 -3
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +127 -10
  16. package/build/core/claude-code.js +691 -0
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +282 -9
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +117 -15
  24. package/build/core/prompts.js +330 -5
  25. package/build/core/render.js +274 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +447 -39
  28. package/build/core/schema.js +219 -3
  29. package/build/core/scrub.js +63 -1
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +12 -0
  35. package/build/core/util.js +18 -0
  36. package/build/core/verify.js +18 -1
  37. package/build/reporters/github.js +544 -44
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +286 -7
  40. package/build/sources/local-git.js +6 -2
  41. package/build/sources/source.js +35 -0
  42. package/package.json +4 -3
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +71 -4
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +124 -1
  54. package/templates/workflow.yml +5 -0
@@ -1,12 +1,17 @@
1
+ // @ref LLP 0002#pipeline-stages [implements] — the mode-agnostic pipeline core owning budgets, coverage, and logging
1
2
  import path from "node:path";
2
3
  import { prepareAuth } from "./auth.js";
3
4
  import { coordinate } from "./coordinator.js";
4
5
  import { writeRunLog } from "./log.js";
5
6
  import { filterNoise, writePatchWorkspace } from "./noise.js";
6
- import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
7
+ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, promptAndParse, STACK_VERIFIER_AGENT, startOpencode, } from "./opencode.js";
8
+ import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
7
9
  import { routeAgents } from "./router.js";
8
10
  import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
9
- import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
11
+ import { fingerprintFinding, isOverallRiskHandoff, parseReviewerOutput } from "./schema.js";
12
+ import { adjudicateFeedback } from "./adjudicate.js";
13
+ import { buildManifestMembership, manifestKey, normalizeManifestPath } from "./stack.js";
14
+ import { confirmStackRequalifications, patchConfirmer } from "./stack-confirm.js";
10
15
  import { sortFindings } from "./render.js";
11
16
  import { appendStepSummary } from "./step-summary.js";
12
17
  import { errorMessage, sleep } from "./util.js";
@@ -33,18 +38,36 @@ function makeRunId() {
33
38
  * thin wrappers that supply a Source and render the result.
34
39
  */
35
40
  /**
36
- * Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when a
37
- * subscription (oauth) credential is configured, else 6. One ChatGPT account
38
- * handles six parallel streams poorly — requests get parked server-side (the
39
- * stall signature seen on eas-cli#4084), and several PRs may be reviewing on the
40
- * same credential at once — so subscription runs trade a little wall-clock for a
41
- * lot of reliability. Exported for tests.
41
+ * Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when the
42
+ * run leans on a subscription credential, else 6. One subscription account handles
43
+ * six parallel streams poorly — requests get parked server-side (the stall signature
44
+ * seen on eas-cli#4084), and several PRs may be reviewing on the same credential at
45
+ * once — so subscription runs trade a little wall-clock for a lot of reliability.
46
+ *
47
+ * A subscription run is any of: an oauth (ChatGPT/Codex) entry; OR the Claude Code
48
+ * engine being in use (an `anthropic/…` model) on an OAUTH credential — a subscription
49
+ * token OR the local `claude` login fallback (no forwardable token). An anthropic
50
+ * credential that classifies as an API KEY is metered per-request and does NOT force
51
+ * the cap. Exported for tests.
42
52
  */
43
- export function effectiveConcurrency(config) {
53
+ // @ref LLP 0002#concurrency-and-budgets [implements] — compound oauth/API-key detection is load-bearing, not simplifiable
54
+ export function effectiveConcurrency(config, env = process.env) {
44
55
  if (config.chunk.concurrency) {
45
56
  return config.chunk.concurrency;
46
57
  }
47
- return config.auth.some((entry) => entry.mode === "oauth") ? 3 : 6;
58
+ if (config.auth.some((entry) => entry.mode === "oauth")) {
59
+ return 3;
60
+ }
61
+ if (buildEngineMap(config).usesClaude) {
62
+ const entry = config.auth.find((auth) => auth.provider === "anthropic");
63
+ const credential = claudeTokenCredential(entry, env);
64
+ // No forwardable token ⇒ the `claude` login (a subscription) covers the run; an
65
+ // "sk-ant-oat…" token is a subscription too. Either caps; an API key does not.
66
+ if (!credential || credential.kind === "oauth") {
67
+ return 3;
68
+ }
69
+ }
70
+ return 6;
48
71
  }
49
72
  export async function runReview(source, options) {
50
73
  const { config } = options;
@@ -59,9 +82,14 @@ export async function runReview(source, options) {
59
82
  const explicitAgents = options.agents?.length
60
83
  ? selectAgents(config.agents, options.agents)
61
84
  : null;
62
- const [metadata, changedFiles] = await Promise.all([
85
+ const [metadata, changedFiles, stackManifest] = await Promise.all([
63
86
  source.getMetadata(),
64
87
  source.getChangedFiles(),
88
+ // Only walk when enabled AND the source can (LocalGitSource omits the method).
89
+ // The source itself fails open to null, so this never rejects the Promise.all.
90
+ options.stack && source.getStackContextAsync
91
+ ? source.getStackContextAsync(options.stack)
92
+ : Promise.resolve(null),
65
93
  ]);
66
94
  // Scope isolation: when includePaths is set, this run only ever sees its own
67
95
  // scope's files — no scope reviews another team's diff.
@@ -105,6 +133,18 @@ export async function runReview(source, options) {
105
133
  // with a base-SHA checkout the fallback tree is pre-PR content, and silently
106
134
  // reviewing/verifying that drops real findings — while a local run falls back to
107
135
  // the user's own checkout with a warning.
136
+ // Resolve each agent's engine BEFORE prepareAuth/readRoot, so the per-engine
137
+ // startup below can't leak the readRoot worktree or a temp auth dir holding a
138
+ // live credential (buildEngineMap only inspects config, so nothing needs cleanup
139
+ // yet at this point). Nothing throws here anymore — one run may drive BOTH the
140
+ // Claude Code CLI engine and OpenCode at once, inferred per agent from its model.
141
+ //
142
+ // Scope the engine set to the SELECTED agents so a run whose passes never touch
143
+ // Claude doesn't start (and fail on a missing CLI/token for) the Claude Code
144
+ // engine. An explicit `--agents` subset is known here; routing picks from the full
145
+ // roster later (its router needs an engine up first), so a routed/all run keeps
146
+ // the full roster and can drive either engine.
147
+ const { engineOf, modelOf, usesOpencode, usesClaude } = buildEngineMap(config, explicitAgents ?? config.agents);
108
148
  const originalCwd = process.cwd();
109
149
  const readRoot = await resolveReadRoot(source, options.mode, progress);
110
150
  // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
@@ -128,10 +168,21 @@ export async function runReview(source, options) {
128
168
  progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
129
169
  process.chdir(readRoot.dir);
130
170
  }
131
- progress("Starting OpenCode server…");
132
- let handle = null;
171
+ const starting = [
172
+ usesClaude ? "Claude Code engine" : null,
173
+ usesOpencode ? "OpenCode server" : null,
174
+ ]
175
+ .filter(Boolean)
176
+ .join(" + ");
177
+ progress(`Starting ${starting}…`);
178
+ // Start each engine the run actually uses. OpenCode first so a claude failure can
179
+ // close it. Two separate try blocks keep the precise per-engine error messages.
180
+ let opencodeHandle = null;
181
+ let claudeHandle = null;
133
182
  try {
134
- handle = await startOpencode(buildOpencodeConfig(config));
183
+ if (usesOpencode) {
184
+ opencodeHandle = await startOpencode(buildOpencodeConfig(config));
185
+ }
135
186
  }
136
187
  catch (error) {
137
188
  await auth.cleanup();
@@ -139,11 +190,71 @@ export async function runReview(source, options) {
139
190
  throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
140
191
  `model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
141
192
  }
142
- // Preflight: a model id the server can't resolve would otherwise fail EVERY pass
143
- // identically — N indistinguishable coverage gaps, after spending the run's budget
144
- // discovering the same fixable thing N times. Throw once, up front, naming the fix.
145
193
  try {
146
- await assertModelsResolvable(handle, [...config.agents.map((agent) => agent.model), config.coordinator.model], config.auth);
194
+ if (usesClaude) {
195
+ claudeHandle = await startClaudeCode(config);
196
+ }
197
+ }
198
+ catch (error) {
199
+ opencodeHandle?.close();
200
+ await auth.cleanup();
201
+ await restoreCwd();
202
+ throw new Error(`Failed to start the Claude Code engine. Ensure the \`claude\` CLI is installed and ` +
203
+ `logged into a Max/Team subscription (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
204
+ }
205
+ // Build the single carrier handle so every downstream `handle` call is unchanged;
206
+ // per-agent dispatch happens inside via engineOf. When OpenCode is in use the
207
+ // carrier is the opencode handle (claude reached via `.claude`); a claude-only run
208
+ // uses the claude handle itself as the carrier (engineOf maps every id → claude).
209
+ const engineFn = (agent) => engineOf[agent] ?? "opencode";
210
+ let handle;
211
+ if (opencodeHandle) {
212
+ handle = opencodeHandle;
213
+ handle.claude = claudeHandle ?? undefined;
214
+ handle.engineOf = engineFn;
215
+ const closeOpencode = opencodeHandle.close;
216
+ handle.close = () => {
217
+ closeOpencode();
218
+ claudeHandle?.close(); // claude close is a noop, but keep the composition explicit
219
+ };
220
+ }
221
+ else {
222
+ handle = claudeHandle; // claude-only: the carrier is the claude handle itself
223
+ handle.engineOf = engineFn;
224
+ }
225
+ // The claude-code engine has no temperature control (the CLI exposes no flag), so
226
+ // surface a tuned-but-ignored temperature once, here, instead of letting the config
227
+ // divergence pass silently. Applies whenever any pass is claude-routed.
228
+ if (usesClaude) {
229
+ const note = claudeTemperatureNote(config, engineOf);
230
+ if (note) {
231
+ progress(`Note: ${note}`);
232
+ }
233
+ }
234
+ // Preflight: a model id an engine can't resolve would otherwise fail EVERY pass
235
+ // routed to it identically — N indistinguishable coverage gaps, after spending the
236
+ // run's budget rediscovering the same fixable thing. Throw once, up front, naming
237
+ // the fix. Split per engine so each engine only ever sees its own model subset: the
238
+ // OpenCode server never receives an anthropic-claude id (unknown-provider error) and
239
+ // assertClaudeModels never receives a non-anthropic id (its foreign-id throw).
240
+ const modelsFor = (eng) => [
241
+ ...new Set(Object.keys(engineOf)
242
+ .filter((id) => engineOf[id] === eng)
243
+ .map((id) => modelOf[id])),
244
+ ];
245
+ try {
246
+ if (opencodeHandle) {
247
+ const models = modelsFor("opencode");
248
+ if (models.length > 0) {
249
+ await assertModelsResolvable(opencodeHandle, models, config.auth);
250
+ }
251
+ }
252
+ if (claudeHandle) {
253
+ const models = modelsFor("claude-code");
254
+ if (models.length > 0) {
255
+ await assertModelsResolvable(claudeHandle, models, config.auth);
256
+ }
257
+ }
147
258
  }
148
259
  catch (error) {
149
260
  handle.close();
@@ -158,6 +269,12 @@ export async function runReview(source, options) {
158
269
  // reviewers produced before the failure — partial findings are exactly what's
159
270
  // needed to debug a run that died mid-way.
160
271
  const agentFindings = {};
272
+ // First reviewer (by scheduling order) that produced each fingerprint, so a finding's
273
+ // originating agent can be carried through the coordinator's merge/rewrite by matching
274
+ // on fingerprint. Kept separate from agentFindings so the coordinator prompt and the
275
+ // run log stay byte-identical (attribution is engine metadata, never sent to a model).
276
+ // @ref LLP 0011#attribution-and-identity [constrained-by] — engine-set, excluded from fingerprintFinding, so attribution never re-keys a dismissal
277
+ const agentByFp = new Map();
161
278
  // Every model request's usage lands in the run total AND its bucket, so the run
162
279
  // log can show cache effectiveness per pass and not just run-wide.
163
280
  const trackTokens = (bucket, tokens) => {
@@ -172,6 +289,10 @@ export async function runReview(source, options) {
172
289
  // log, reported in the log line, and surfaced as a coverage note when it happens.
173
290
  const agentModels = {};
174
291
  const substituted = new Set();
292
+ // The buckets whose model was substituted, so the coverage note can name each
293
+ // one's OWN engine (a mixed run may substitute on either side, for different
294
+ // reasons — a CLI usage-limit downgrade vs. OpenCode's silent default fallback).
295
+ const substitutedBuckets = new Set();
175
296
  const trackModel = (bucket, configured, actual) => {
176
297
  if (!actual) {
177
298
  return;
@@ -179,6 +300,7 @@ export async function runReview(source, options) {
179
300
  agentModels[bucket] = actual;
180
301
  if (configured && actual !== configured) {
181
302
  substituted.add(`${bucket}: configured ${configured}, ran ${actual}`);
303
+ substitutedBuckets.add(bucket);
182
304
  }
183
305
  };
184
306
  try {
@@ -243,6 +365,7 @@ export async function runReview(source, options) {
243
365
  // whole-diff no-tools fallback with, and "elastic budget" would have quietly
244
366
  // reintroduced the coverage gap it exists to prevent. Sized for the finalize
245
367
  // soft-landing plus one FALLBACK_TIMEOUT_MS pass.
368
+ // @ref LLP 0002#the-cross-cutting-pass [constrained-by] — not a trimmable margin; funds the whole-diff fallback on timeout
246
369
  const CROSS_CUTTING_RESERVE_MS = FALLBACK_TIMEOUT_MS + 4 * 60 * 1000;
247
370
  // Floor: never LESS generous than one chunk pass. On a run whose window is already
248
371
  // small (many active scopes dividing the budget) this can exceed what's left, but
@@ -313,10 +436,8 @@ export async function runReview(source, options) {
313
436
  // smaller file set); a fallback task forbids tools and reviews the inlined diff.
314
437
  const buildTaskText = (task) => {
315
438
  const base = task.kind === "cross-cutting"
316
- ? buildCrossCuttingTask(task.files, selectedAgents, filtered, {
317
- noTools: task.fallback,
318
- })
319
- : buildReviewerTask(task.files, workspace.files, filtered);
439
+ ? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText)
440
+ : buildReviewerTask(task.files, workspace.files, filtered, options.contextText);
320
441
  return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
321
442
  };
322
443
  const filesLabel = (files) => files.length === 1
@@ -354,6 +475,12 @@ export async function runReview(source, options) {
354
475
  trackTokens(task.bucket, tokens);
355
476
  trackModel(task.bucket, taskModel(task), model);
356
477
  (agentFindings[task.bucket] ??= []).push(...value.findings);
478
+ for (const finding of value.findings) {
479
+ const fp = fingerprintFinding(finding);
480
+ if (!agentByFp.has(fp)) {
481
+ agentByFp.set(fp, task.bucket);
482
+ }
483
+ }
357
484
  completedPasses++;
358
485
  if (truncated) {
359
486
  progress(` ${task.label}: hit its budget — returned partial findings`);
@@ -448,15 +575,32 @@ export async function runReview(source, options) {
448
575
  for (const line of substituted) {
449
576
  progress(` ⚠ model substituted — ${line}`);
450
577
  }
578
+ const subEngines = new Set([...substitutedBuckets].map((bucket) => engineOf[bucket] ?? "opencode"));
579
+ const why = [];
580
+ if (subEngines.has(CLAUDE_CODE_ENGINE)) {
581
+ why.push("The Claude Code CLI answered with a different model than configured (usage-limit " +
582
+ "downgrades do this on a subscription), so these findings may come from a weaker " +
583
+ "model than intended — check the configured model ids and the subscription's limits.");
584
+ }
585
+ if (subEngines.has("opencode")) {
586
+ why.push("OpenCode silently falls back to a default model when the configured id is empty or " +
587
+ "unusable, so these findings may come from a different (possibly much weaker) model " +
588
+ "than intended — check the agents' `model`, `coordinator.model`, REVIEWER_MODEL, and the provider credential.");
589
+ }
451
590
  incomplete.push(`Some passes did not run on the configured model (${[...substituted].join("; ")}). ` +
452
- `OpenCode silently falls back to a default model when the configured id is empty or ` +
453
- `unusable, so these findings may come from a different (possibly much weaker) model ` +
454
- `than intended — check the agents' \`model\`, \`coordinator.model\`, REVIEWER_MODEL, and the provider credential.`);
591
+ why.join(" "));
455
592
  }
456
593
  // Note: routine noise filtering (lockfiles, generated, binary) is expected and
457
594
  // NOT a coverage gap — it stays in the run log (filteredFiles), not the
458
595
  // user-facing coverage note, which is reserved for passes that didn't finish.
459
596
  const coverageNotes = [...new Set(incomplete)];
597
+ // Severity LOCK: capture which FILES carried a critical/secrets/security reviewer
598
+ // finding BEFORE the coordinator can lower or rewrite it. groundStackRequalification
599
+ // uses this so a coordinator steered into "downgrade critical→warning, then
600
+ // requalify" can't slip a real critical past the carve-out. Built here (after the
601
+ // fan-out populated agentFindings) whether or not the stack feature is on — cheap,
602
+ // and keeps the grounding call unconditional.
603
+ const preCoordinationFileLocks = buildPreCoordinationFileLocks(agentFindings);
460
604
  let output;
461
605
  if (completedPasses === 0) {
462
606
  // Nothing succeeded — do NOT let this render as a clean "approve".
@@ -476,7 +620,7 @@ export async function runReview(source, options) {
476
620
  progress("Coordinating findings…");
477
621
  let consolidated;
478
622
  try {
479
- const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
623
+ const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest);
480
624
  agentCosts["coordinator"] = cost;
481
625
  trackTokens("coordinator", coordinatorTokens);
482
626
  trackModel("coordinator", config.coordinator.model, coordinatorModel);
@@ -504,8 +648,13 @@ export async function runReview(source, options) {
504
648
  // Guard against hallucinated findings before surfacing: quote-ground every
505
649
  // finding against the real file, and adversarially verify criticals. This is
506
650
  // what stops a confident but wrong critical from shipping.
651
+ // @ref LLP 0002#post-coordination-order [constrained-by] — verify must run before suppress; order is load-bearing
507
652
  const findingCountBeforeChecks = output.findings.length;
653
+ const decisionBeforeChecks = output.decision;
508
654
  let verifierDropped = [];
655
+ // Stripped requalifications (finding + reason), persisted to the run log so the
656
+ // stack-aware decision trail is auditable after the fact — mirrors verifierDropped.
657
+ const requalificationStrips = [];
509
658
  if (output.findings.length > 0) {
510
659
  progress("Verifying findings…");
511
660
  const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
@@ -523,6 +672,52 @@ export async function runReview(source, options) {
523
672
  };
524
673
  }
525
674
  }
675
+ // Stack-aware requalification grounding (deterministic, zero LLM): strip any
676
+ // `requalifiedBy` the coordinator wrote that is forged, hallucinated, or touches a
677
+ // protected finding class, then re-derive the decision over the still-BLOCKING
678
+ // (non-requalified) subset. Runs between verify and suppress, preserving the
679
+ // load-bearing verify → ground → suppress → reconcile order.
680
+ // @ref LLP 0010#grounding-and-the-decision [constrained-by] — must run after verify and before suppress; a stripped requalification means the finding stays fully blocking
681
+ if (output.findings.length > 0) {
682
+ // The decision entering this block (post-verify, pre-requalification softening)
683
+ // is the ceiling both grounding and confirmation re-derive against: confirmation
684
+ // returns findings to blocking, so re-running decisionAfterRequalification over
685
+ // the post-confirmation set re-hardens up to this value, never past it.
686
+ const decisionBeforeRequalification = output.decision;
687
+ const grounding = groundStackRequalification(output.findings, stackManifest, preCoordinationFileLocks, progress);
688
+ requalificationStrips.push(...grounding.stripped);
689
+ let grounded = grounding.findings;
690
+ // v2 patch confirmation (gated by stack.confirmWithPatch): for the requalifications
691
+ // that survived grounding, read the addressing PR's actual patch and strip any not
692
+ // clearly addressed. Fail toward blocking on any fetch/verify error or timeout.
693
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — runs right after grounding, before the decision is re-derived; never materializes the patch
694
+ if (options.stackConfirm &&
695
+ stackManifest &&
696
+ grounded.some((finding) => finding.requalifiedBy)) {
697
+ progress("Confirming stacked-PR requalifications against their patches…");
698
+ const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source), progress);
699
+ grounded = confirmation.findings;
700
+ requalificationStrips.push(...confirmation.strippedFindings);
701
+ agentCosts[STACK_VERIFIER_AGENT] = confirmation.cost;
702
+ trackTokens(STACK_VERIFIER_AGENT, confirmation.tokens);
703
+ trackModel(STACK_VERIFIER_AGENT, config.agents[0]?.model ?? config.coordinator.model, confirmation.model);
704
+ if (confirmation.stripped > 0) {
705
+ progress(`Stack confirmation returned ${confirmation.stripped} requalified finding(s) to blocking.`);
706
+ }
707
+ }
708
+ output = {
709
+ ...output,
710
+ findings: grounded,
711
+ // decisionAfterGrounding only re-derives when a requalification SURVIVED
712
+ // grounding + confirmation: with none, the coordinator's decision must stand
713
+ // untouched — an unconditional decisionAfterRequalification here would
714
+ // soften every non-critical request_changes on every run, stack feature or
715
+ // not. Criticals never carry requalifiedBy (grounding strips it), so the
716
+ // later decisionAfterRequalification call in the suppression block cannot
717
+ // re-escalate past this softened decision.
718
+ decision: decisionAfterGrounding(decisionBeforeRequalification, grounded),
719
+ };
720
+ }
526
721
  // Inline `expo-code-review-ignore` directives suppress non-critical findings.
527
722
  if (output.findings.length > 0) {
528
723
  const { kept, suppressed } = await applyInlineIgnores(output.findings, process.cwd(), progress);
@@ -531,24 +726,109 @@ export async function runReview(source, options) {
531
726
  output = {
532
727
  ...output,
533
728
  findings: kept,
534
- decision: decisionAfterVerification(output.decision, kept),
729
+ // decisionAfterRequalification, NOT decisionAfterVerification: `kept` may
730
+ // still hold requalified (non-blocking) findings, and the decision must be
731
+ // re-derived over the BLOCKING subset — else suppressing the last blocking
732
+ // finding leaves a stale approve_with_comments. With no requalifications
733
+ // the two derivations are identical.
734
+ decision: decisionAfterRequalification(output.decision, kept),
535
735
  };
536
736
  }
537
737
  }
538
738
  // The coordinator's summary was written against the pre-check finding set, so if
539
739
  // verification/suppression removed anything it can now reference issues that are
540
740
  // no longer listed. Reconcile the summary so it never contradicts the findings.
741
+ // A decision change WITHOUT a count drop gets its own note: only requalification
742
+ // does that — every finding is still listed, so the "removed" wording of the
743
+ // count-drop note would be factually wrong there.
541
744
  const removedAfterChecks = findingCountBeforeChecks - output.findings.length;
542
745
  if (removedAfterChecks > 0) {
543
746
  output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
544
747
  }
748
+ else if (output.decision !== decisionBeforeChecks) {
749
+ output = { ...output, summary: reconcileRequalifiedSummary(output.summary) };
750
+ }
751
+ // Attribution: carry each surviving finding's originating agent onto the output. The
752
+ // coordinator merges and rewrites findings, so match by fingerprint and keep the
753
+ // first agent that produced it; a finding the coordinator changed enough to break the
754
+ // fingerprint stays unattributed (reported as "unknown") rather than guessed. Agent
755
+ // is excluded from the fingerprint, so setting it can never lapse a dismissal. This
756
+ // lookup is the ONLY writer: the model-facing schema drops any `agent` the
757
+ // coordinator emitted, so nothing here has to trust (or defer to) model attribution.
758
+ // @ref LLP 0011#attribution-and-identity [implements] — attribution rides through the coordinator by fingerprint; annotation-only, and engine-set only
759
+ if (agentByFp.size > 0 && output.findings.length > 0) {
760
+ output = {
761
+ ...output,
762
+ findings: output.findings.map((finding) => {
763
+ const agent = agentByFp.get(fingerprintFinding(finding));
764
+ return agent ? { ...finding, agent } : finding;
765
+ }),
766
+ };
767
+ }
768
+ // Author-feedback adjudication (ships dark): when the caller supplied feedback input
769
+ // and the mode is on, match the replies to the final findings and — in "adjudicate"
770
+ // mode — judge each rebuttal against the source, then record the verdict and whether
771
+ // it cleared the finding. Fails open: any error leaves the review untouched, so the
772
+ // feedback path can never break a review (`ecr ci` must never fail a PR's checks).
773
+ // @ref LLP 0011#the-rebuttal-is-a-hypothesis [implements] — runs after verification, before reporting; the hard floors and the cap live in adjudicate.ts, not the prompt
774
+ let feedbackRecords;
775
+ if (options.feedback && options.feedback.config.mode !== "off") {
776
+ try {
777
+ const items = await options.feedback.match(output);
778
+ const adjudication = await adjudicateFeedback(handle, items, options.feedback.config, progress,
779
+ // The revision each verdict is judged against: the PR head OID this run
780
+ // materialized and read from. A source without one (local git) stamps
781
+ // nothing, so its verdicts never carry to a later run.
782
+ metadata.headOid);
783
+ feedbackRecords = adjudication.records;
784
+ agentCosts["adjudicator"] = adjudication.cost;
785
+ trackTokens("adjudicator", adjudication.tokens);
786
+ trackModel("adjudicator", config.agents[0]?.model ?? config.coordinator.model, adjudication.model);
787
+ // Never silent: a capped or failed adjudication is a reduced-coverage fact.
788
+ if (adjudication.skipped > 0 || adjudication.failed > 0) {
789
+ const parts = [];
790
+ if (adjudication.skipped > 0) {
791
+ parts.push(`${adjudication.skipped} left unjudged over the maxAdjudications=${options.feedback.config.maxAdjudications} cap`);
792
+ }
793
+ if (adjudication.failed > 0) {
794
+ parts.push(`${adjudication.failed} could not be judged (the source check failed)`);
795
+ }
796
+ output = {
797
+ ...output,
798
+ incomplete: [
799
+ ...new Set([
800
+ ...output.incomplete,
801
+ `Author-reply adjudication was reduced this run: ${parts.join("; ")}. ` +
802
+ `Those replies carry no verdict and cleared no finding.`,
803
+ ]),
804
+ ],
805
+ };
806
+ }
807
+ }
808
+ catch (error) {
809
+ // Fail open — feedback never breaks a review.
810
+ progress(`Author-reply adjudication failed (${errorMessage(error)}); continuing without it.`);
811
+ }
812
+ }
545
813
  // Surface provider throttling as a fact about the run: passes already waited or
546
814
  // backed off, but the operator should still SEE that it happened (a run that
547
815
  // was rate-limited is slower and may carry partial passes — that's the cause).
548
- await handle.rateLimit.check();
549
- if (handle.rateLimit.events > 0) {
550
- progress(` ⚠ provider rate-limited this run ${handle.rateLimit.events} time(s) ` +
551
- `(429s in the OpenCode server log) — passes waited it out rather than failing`);
816
+ // Sum both engines' watches; name a cause only for an engine whose watch fired
817
+ // (claude events arrive via note() in runClaudePrompt; opencode via its server log).
818
+ await opencodeHandle?.rateLimit.check();
819
+ const rlOpencode = opencodeHandle?.rateLimit.events ?? 0;
820
+ const rlClaude = claudeHandle?.rateLimit.events ?? 0;
821
+ const rlTotal = rlOpencode + rlClaude;
822
+ if (rlTotal > 0) {
823
+ const causes = [];
824
+ if (rlClaude > 0) {
825
+ causes.push("subscription rate/usage limits reported by the Claude Code CLI");
826
+ }
827
+ if (rlOpencode > 0) {
828
+ causes.push("429s in the OpenCode server log");
829
+ }
830
+ progress(` ⚠ provider rate-limited this run ${rlTotal} time(s) (${causes.join("; ")}) ` +
831
+ `— passes waited it out rather than failing`);
552
832
  }
553
833
  // Every pass says which model actually answered it — in the job log, the step
554
834
  // summary table, and the run log — so a wrong or substituted model is always
@@ -570,13 +850,19 @@ export async function runReview(source, options) {
570
850
  agentFindings,
571
851
  coverageNotes,
572
852
  verifierDropped,
573
- ...(handle.rateLimit.events > 0 ? { rateLimitEvents: handle.rateLimit.events } : {}),
853
+ requalificationStrips,
854
+ ...(rlTotal > 0
855
+ ? {
856
+ rateLimitEvents: rlTotal,
857
+ rateLimitByEngine: { opencode: rlOpencode, claudeCode: rlClaude },
858
+ }
859
+ : {}),
574
860
  durationMs: Date.now() - started,
575
861
  decision: output.decision,
576
862
  findingCount: output.findings.length,
577
863
  summary: output.summary,
578
864
  });
579
- return output;
865
+ return feedbackRecords ? { ...output, feedback: feedbackRecords } : output;
580
866
  }
581
867
  catch (error) {
582
868
  await safeLog(logPath, {
@@ -595,19 +881,26 @@ export async function runReview(source, options) {
595
881
  throw error;
596
882
  }
597
883
  finally {
598
- handle?.close();
884
+ handle.close();
599
885
  await auth.cleanup();
600
886
  await restoreCwd();
601
887
  }
602
888
  }
603
889
  /**
604
- * Policy backstop: drop suggestions unless opted in, cap by count (most severe
605
- * first), and downgrade approve_with_comments to approve when nothing remains.
890
+ * Policy backstop: strip the internal risk handoff, drop suggestions unless
891
+ * opted in, cap by count (most severe first), and downgrade
892
+ * approve_with_comments to approve when nothing remains.
606
893
  */
607
894
  export function applyReviewPolicy(output, policy) {
608
- let findings = policy.includeSuggestions
609
- ? output.findings
610
- : output.findings.filter((finding) => finding.severity !== "suggestion");
895
+ // Unconditional, and before the severity filter: the handoff is `suggestion`-
896
+ // severity, so `includeSuggestions: true` would otherwise publish it as a
897
+ // finding whenever the coordinator forgot to strip it. It is prompt-authored
898
+ // metadata for the coordinator's summary, never something an author should see.
899
+ // @ref LLP 0009#prompt-rules-for-adopters [implements] — code-level strip, not prompt-only
900
+ let findings = output.findings.filter((finding) => !isOverallRiskHandoff(finding));
901
+ if (!policy.includeSuggestions) {
902
+ findings = findings.filter((finding) => finding.severity !== "suggestion");
903
+ }
611
904
  findings = sortFindings(findings);
612
905
  if (policy.maxFindings != null) {
613
906
  findings = findings.slice(0, policy.maxFindings);
@@ -623,6 +916,7 @@ export function applyReviewPolicy(output, policy) {
623
916
  * Merges + de-dupes (by fingerprint), applies the same policy, and picks a
624
917
  * conservative decision (never a clean approve when there are findings).
625
918
  */
919
+ // @ref LLP 0002#coordinator-and-degraded-decisions [implements] — coordinator failure must never discard already-collected findings
626
920
  function fallbackConsolidation(agentFindings, policy) {
627
921
  const seen = new Set();
628
922
  const merged = [];
@@ -662,6 +956,107 @@ export function decisionAfterVerification(previous, kept) {
662
956
  }
663
957
  return previous;
664
958
  }
959
+ /**
960
+ * The normalized FILES where any reviewer emitted a critical, `secrets`, or `security`
961
+ * finding PRE-coordination. This is the severity LOCK: no finding on such a file is
962
+ * requalifiable, no matter what the coordinator later assigns it. Keyed on the file
963
+ * alone — NOT a content fingerprint — because the coordinator legitimately
964
+ * re-categorizes and paraphrases findings, and a fingerprint over those mutable
965
+ * fields would let a downgraded-then-reworded critical dodge the lock. Over-locking
966
+ * a whole file only keeps findings blocking (the feature's fail direction).
967
+ * Exported for tests.
968
+ */
969
+ // @ref LLP 0010#grounding-and-the-decision [implements] — pre-coordination file locks defeat downgrade-then-requalify
970
+ export function buildPreCoordinationFileLocks(agentFindings) {
971
+ const locked = new Set();
972
+ for (const findings of Object.values(agentFindings)) {
973
+ for (const finding of findings) {
974
+ if (finding.severity === "critical" ||
975
+ finding.category === "secrets" ||
976
+ finding.category === "security") {
977
+ locked.add(normalizeManifestPath(finding.file));
978
+ }
979
+ }
980
+ }
981
+ return locked;
982
+ }
983
+ // @ref LLP 0010#grounding-and-the-decision [implements] — deterministic zero-LLM floor over data ecr fetched itself; strips forged/hallucinated/protected requalifications even with a prompt-injected coordinator
984
+ /**
985
+ * Strip a finding's `requalifiedBy` (leaving the finding itself fully intact and
986
+ * blocking) when any of these hold — every check is over data the coordinator cannot
987
+ * influence:
988
+ * - the cited `(prNumber, file)` is not an EXACT normalized member of the fetched
989
+ * manifest (forged or hallucinated citation);
990
+ * - the finding is `critical` severity, or category `secrets` or `security`;
991
+ * - the finding's FILE carried a pre-coordination critical/secrets/security reviewer
992
+ * finding (severity lock — keyed on the file, so a coordinator re-categorization
993
+ * or paraphrase cannot dodge it).
994
+ * Returns the grounded findings plus every stripped requalification (finding +
995
+ * reason): a debug line covers the live stderr stream, and the caller persists the
996
+ * strips to the run log (mirroring verifierDropped) so a silent under-fire stays
997
+ * diagnosable after the run. Exported for tests.
998
+ */
999
+ export function groundStackRequalification(findings, manifest, lockedFiles, debug = () => { }) {
1000
+ const members = manifest ? buildManifestMembership(manifest) : new Set();
1001
+ const stripped = [];
1002
+ const grounded = findings.map((finding) => {
1003
+ const requalified = finding.requalifiedBy;
1004
+ if (!requalified) {
1005
+ return finding;
1006
+ }
1007
+ const strip = (reason) => {
1008
+ debug(`Stack: stripped requalification on "${finding.file}" (${reason}).`);
1009
+ const { requalifiedBy: _dropped, ...rest } = finding;
1010
+ stripped.push({ finding: rest, reason });
1011
+ return rest;
1012
+ };
1013
+ if (finding.severity === "critical") {
1014
+ return strip("critical severity is never requalifiable");
1015
+ }
1016
+ if (finding.category === "secrets" || finding.category === "security") {
1017
+ return strip(`${finding.category} category is never requalifiable`);
1018
+ }
1019
+ if (lockedFiles.has(normalizeManifestPath(finding.file))) {
1020
+ return strip("a reviewer emitted a critical/secrets/security finding on this file (severity lock)");
1021
+ }
1022
+ if (!members.has(manifestKey(requalified.prNumber, requalified.file))) {
1023
+ return strip(`cited #${requalified.prNumber} "${requalified.file}" is not an exact manifest member`);
1024
+ }
1025
+ return finding;
1026
+ });
1027
+ return { findings: grounded, stripped };
1028
+ }
1029
+ /**
1030
+ * Re-derive the decision after requalification over the still-BLOCKING (non-requalified)
1031
+ * findings only — the parallel of decisionAfterVerification. Requalified findings stay
1032
+ * shown and counted but never block: no blocking findings → approve; a request_changes
1033
+ * with no blocking critical left → soften to approve_with_comments. Exported for tests.
1034
+ */
1035
+ // @ref LLP 0010#grounding-and-the-decision [implements] — decision is computed over the active subset, so a requalified warning stops blocking but stays visible
1036
+ export function decisionAfterRequalification(previous, findings) {
1037
+ const blocking = findings.filter((finding) => !finding.requalifiedBy);
1038
+ if (blocking.length === 0) {
1039
+ return "approve";
1040
+ }
1041
+ if (previous === "request_changes" &&
1042
+ !blocking.some((finding) => finding.severity === "critical")) {
1043
+ return "approve_with_comments";
1044
+ }
1045
+ return previous;
1046
+ }
1047
+ /**
1048
+ * The grounding block's decision step: re-derive ONLY when a requalification
1049
+ * survived grounding. With none (the overwhelmingly common case — stack feature
1050
+ * off, or every requalification stripped), the incoming decision stands untouched:
1051
+ * re-deriving unconditionally would soften every non-critical request_changes on
1052
+ * every run, silently overriding the coordinator's (and any adopter rubric's)
1053
+ * decision policy. Exported for tests.
1054
+ */
1055
+ export function decisionAfterGrounding(previous, findings) {
1056
+ return findings.some((finding) => finding.requalifiedBy)
1057
+ ? decisionAfterRequalification(previous, findings)
1058
+ : previous;
1059
+ }
665
1060
  /**
666
1061
  * The coordinator writes its summary before findings are verified/suppressed, so a
667
1062
  * post-coordination drop can leave the summary referencing issues no longer shown.
@@ -677,6 +1072,18 @@ export function reconcileSummary(summary, remaining) {
677
1072
  "this summary was written, so it may mention issues no longer listed below._\n\n" +
678
1073
  summary);
679
1074
  }
1075
+ /**
1076
+ * The decision-changed-without-removal reconcile: requalification softened the
1077
+ * decision while keeping every finding listed, so the summary prose (written before
1078
+ * grounding ran) can read stricter than the final decision. Nothing was removed —
1079
+ * the note must not claim it was. Exported for tests.
1080
+ */
1081
+ export function reconcileRequalifiedSummary(summary) {
1082
+ return ("_Note: after this summary was written, some findings were requalified as " +
1083
+ "addressed in stacked PRs — they are still listed below but no longer block, " +
1084
+ "so the prose may read stricter than the final decision._\n\n" +
1085
+ summary);
1086
+ }
680
1087
  /**
681
1088
  * Resolve the tree the review reads from, applying the mode's trust policy:
682
1089
  *
@@ -780,6 +1187,7 @@ const QUEUE_IDLE_POLL_MS = 100;
780
1187
  * running (a running worker might yet enqueue more), so dynamically-added work is
781
1188
  * never lost. `fn` receives the item and an `enqueue` callback.
782
1189
  */
1190
+ // @ref LLP 0002#timeouts-stalls-and-subdivision [implements] — terminates on active===0, not queue-empty, so growth mid-drain isn't lost
783
1191
  export async function runGrowableQueue(initial, limit, fn) {
784
1192
  const queue = [...initial];
785
1193
  let active = 0;