@expo/code-review-cli 0.7.0 → 0.9.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 (56) hide show
  1. package/README.md +161 -13
  2. package/build/cli.js +12 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/ref-check.js +84 -0
  9. package/build/commands/review.js +191 -51
  10. package/build/commands/setup-auth.js +3 -0
  11. package/build/commands/verify-config.js +3 -0
  12. package/build/config/load.js +39 -0
  13. package/build/config/routing.js +7 -0
  14. package/build/config/schema.js +92 -0
  15. package/build/core/adjudicate.js +194 -0
  16. package/build/core/auth.js +5 -1
  17. package/build/core/claude-code.js +12 -1
  18. package/build/core/config-refs.js +772 -0
  19. package/build/core/context-file.js +42 -0
  20. package/build/core/coordinator.js +2 -2
  21. package/build/core/diff.js +1 -0
  22. package/build/core/exec.js +4 -0
  23. package/build/core/log.js +1 -0
  24. package/build/core/noise.js +5 -0
  25. package/build/core/opencode.js +22 -0
  26. package/build/core/prompts.js +311 -3
  27. package/build/core/render.js +268 -45
  28. package/build/core/responses.js +158 -0
  29. package/build/core/review.js +307 -15
  30. package/build/core/schema.js +223 -2
  31. package/build/core/scrub.js +4 -0
  32. package/build/core/stack-confirm.js +137 -0
  33. package/build/core/stack.js +25 -0
  34. package/build/core/step-summary.js +1 -0
  35. package/build/core/suppress.js +2 -0
  36. package/build/core/throttle.js +2 -0
  37. package/build/core/util.js +1 -0
  38. package/build/core/verify.js +5 -0
  39. package/build/reporters/github.js +465 -31
  40. package/build/reporters/terminal.js +10 -0
  41. package/build/sources/github-pr.js +272 -0
  42. package/build/sources/local-git.js +3 -0
  43. package/build/sources/source.js +35 -0
  44. package/package.json +2 -1
  45. package/templates/agents/consistency.md +6 -1
  46. package/templates/agents/correctness.md +9 -1
  47. package/templates/agents/security.md +11 -1
  48. package/templates/atlantis.yml +123 -0
  49. package/templates/command.yml +4 -0
  50. package/templates/config.jsonc +50 -1
  51. package/templates/coordinator.md +34 -9
  52. package/templates/dismiss.yml +4 -0
  53. package/templates/routing.jsonc +3 -0
  54. package/templates/scope-config.jsonc +1 -0
  55. package/templates/shared.md +99 -1
  56. package/templates/workflow.yml +5 -0
@@ -1,16 +1,21 @@
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, CLAUDE_CODE_ENGINE, 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";
7
8
  import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
8
9
  import { routeAgents } from "./router.js";
9
10
  import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
10
- 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";
11
15
  import { sortFindings } from "./render.js";
12
16
  import { appendStepSummary } from "./step-summary.js";
13
17
  import { errorMessage, sleep } from "./util.js";
18
+ import { reviewSetupRefNotes } from "./config-refs.js";
14
19
  import { verifyFindings } from "./verify.js";
15
20
  import { applyInlineIgnores } from "./suppress.js";
16
21
  /**
@@ -46,6 +51,7 @@ function makeRunId() {
46
51
  * credential that classifies as an API KEY is metered per-request and does NOT force
47
52
  * the cap. Exported for tests.
48
53
  */
54
+ // @ref LLP 0002#concurrency-and-budgets [implements] — compound oauth/API-key detection is load-bearing, not simplifiable
49
55
  export function effectiveConcurrency(config, env = process.env) {
50
56
  if (config.chunk.concurrency) {
51
57
  return config.chunk.concurrency;
@@ -77,9 +83,14 @@ export async function runReview(source, options) {
77
83
  const explicitAgents = options.agents?.length
78
84
  ? selectAgents(config.agents, options.agents)
79
85
  : null;
80
- const [metadata, changedFiles] = await Promise.all([
86
+ const [metadata, changedFiles, stackManifest] = await Promise.all([
81
87
  source.getMetadata(),
82
88
  source.getChangedFiles(),
89
+ // Only walk when enabled AND the source can (LocalGitSource omits the method).
90
+ // The source itself fails open to null, so this never rejects the Promise.all.
91
+ options.stack && source.getStackContextAsync
92
+ ? source.getStackContextAsync(options.stack)
93
+ : Promise.resolve(null),
83
94
  ]);
84
95
  // Scope isolation: when includePaths is set, this run only ever sees its own
85
96
  // scope's files — no scope reviews another team's diff.
@@ -158,6 +169,19 @@ export async function runReview(source, options) {
158
169
  progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
159
170
  process.chdir(readRoot.dir);
160
171
  }
172
+ // Ref integrity of the setup that is about to review this PR. Resolved against the
173
+ // tree the reviewers read (PR head when materialized), while the setup itself may
174
+ // come from the trusted base ref — so a PR that moves cited code is reported against
175
+ // the prompts that will actually judge it.
176
+ // @ref LLP 0012#run-points-command-and-review [implements] — every review checks its own refs; advice only, never a gate
177
+ const setupNotes = await reviewSetupRefNotes({
178
+ root: readRoot?.dir ?? originalCwd,
179
+ setupDirs: [config.configDir],
180
+ changedFiles: kept.map((entry) => entry.path),
181
+ });
182
+ for (const note of setupNotes) {
183
+ progress(` setup: ${note}`);
184
+ }
161
185
  const starting = [
162
186
  usesClaude ? "Claude Code engine" : null,
163
187
  usesOpencode ? "OpenCode server" : null,
@@ -259,6 +283,12 @@ export async function runReview(source, options) {
259
283
  // reviewers produced before the failure — partial findings are exactly what's
260
284
  // needed to debug a run that died mid-way.
261
285
  const agentFindings = {};
286
+ // First reviewer (by scheduling order) that produced each fingerprint, so a finding's
287
+ // originating agent can be carried through the coordinator's merge/rewrite by matching
288
+ // on fingerprint. Kept separate from agentFindings so the coordinator prompt and the
289
+ // run log stay byte-identical (attribution is engine metadata, never sent to a model).
290
+ // @ref LLP 0011#attribution-and-identity [constrained-by] — engine-set, excluded from fingerprintFinding, so attribution never re-keys a dismissal
291
+ const agentByFp = new Map();
262
292
  // Every model request's usage lands in the run total AND its bucket, so the run
263
293
  // log can show cache effectiveness per pass and not just run-wide.
264
294
  const trackTokens = (bucket, tokens) => {
@@ -349,6 +379,7 @@ export async function runReview(source, options) {
349
379
  // whole-diff no-tools fallback with, and "elastic budget" would have quietly
350
380
  // reintroduced the coverage gap it exists to prevent. Sized for the finalize
351
381
  // soft-landing plus one FALLBACK_TIMEOUT_MS pass.
382
+ // @ref LLP 0002#the-cross-cutting-pass [constrained-by] — not a trimmable margin; funds the whole-diff fallback on timeout
352
383
  const CROSS_CUTTING_RESERVE_MS = FALLBACK_TIMEOUT_MS + 4 * 60 * 1000;
353
384
  // Floor: never LESS generous than one chunk pass. On a run whose window is already
354
385
  // small (many active scopes dividing the budget) this can exceed what's left, but
@@ -419,10 +450,8 @@ export async function runReview(source, options) {
419
450
  // smaller file set); a fallback task forbids tools and reviews the inlined diff.
420
451
  const buildTaskText = (task) => {
421
452
  const base = task.kind === "cross-cutting"
422
- ? buildCrossCuttingTask(task.files, selectedAgents, filtered, {
423
- noTools: task.fallback,
424
- })
425
- : buildReviewerTask(task.files, workspace.files, filtered);
453
+ ? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText)
454
+ : buildReviewerTask(task.files, workspace.files, filtered, options.contextText);
426
455
  return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
427
456
  };
428
457
  const filesLabel = (files) => files.length === 1
@@ -460,6 +489,12 @@ export async function runReview(source, options) {
460
489
  trackTokens(task.bucket, tokens);
461
490
  trackModel(task.bucket, taskModel(task), model);
462
491
  (agentFindings[task.bucket] ??= []).push(...value.findings);
492
+ for (const finding of value.findings) {
493
+ const fp = fingerprintFinding(finding);
494
+ if (!agentByFp.has(fp)) {
495
+ agentByFp.set(fp, task.bucket);
496
+ }
497
+ }
463
498
  completedPasses++;
464
499
  if (truncated) {
465
500
  progress(` ${task.label}: hit its budget — returned partial findings`);
@@ -573,6 +608,13 @@ export async function runReview(source, options) {
573
608
  // NOT a coverage gap — it stays in the run log (filteredFiles), not the
574
609
  // user-facing coverage note, which is reserved for passes that didn't finish.
575
610
  const coverageNotes = [...new Set(incomplete)];
611
+ // Severity LOCK: capture which FILES carried a critical/secrets/security reviewer
612
+ // finding BEFORE the coordinator can lower or rewrite it. groundStackRequalification
613
+ // uses this so a coordinator steered into "downgrade critical→warning, then
614
+ // requalify" can't slip a real critical past the carve-out. Built here (after the
615
+ // fan-out populated agentFindings) whether or not the stack feature is on — cheap,
616
+ // and keeps the grounding call unconditional.
617
+ const preCoordinationFileLocks = buildPreCoordinationFileLocks(agentFindings);
576
618
  let output;
577
619
  if (completedPasses === 0) {
578
620
  // Nothing succeeded — do NOT let this render as a clean "approve".
@@ -592,7 +634,7 @@ export async function runReview(source, options) {
592
634
  progress("Coordinating findings…");
593
635
  let consolidated;
594
636
  try {
595
- const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
637
+ const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest);
596
638
  agentCosts["coordinator"] = cost;
597
639
  trackTokens("coordinator", coordinatorTokens);
598
640
  trackModel("coordinator", config.coordinator.model, coordinatorModel);
@@ -620,8 +662,13 @@ export async function runReview(source, options) {
620
662
  // Guard against hallucinated findings before surfacing: quote-ground every
621
663
  // finding against the real file, and adversarially verify criticals. This is
622
664
  // what stops a confident but wrong critical from shipping.
665
+ // @ref LLP 0002#post-coordination-order [constrained-by] — verify must run before suppress; order is load-bearing
623
666
  const findingCountBeforeChecks = output.findings.length;
667
+ const decisionBeforeChecks = output.decision;
624
668
  let verifierDropped = [];
669
+ // Stripped requalifications (finding + reason), persisted to the run log so the
670
+ // stack-aware decision trail is auditable after the fact — mirrors verifierDropped.
671
+ const requalificationStrips = [];
625
672
  if (output.findings.length > 0) {
626
673
  progress("Verifying findings…");
627
674
  const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
@@ -639,6 +686,52 @@ export async function runReview(source, options) {
639
686
  };
640
687
  }
641
688
  }
689
+ // Stack-aware requalification grounding (deterministic, zero LLM): strip any
690
+ // `requalifiedBy` the coordinator wrote that is forged, hallucinated, or touches a
691
+ // protected finding class, then re-derive the decision over the still-BLOCKING
692
+ // (non-requalified) subset. Runs between verify and suppress, preserving the
693
+ // load-bearing verify → ground → suppress → reconcile order.
694
+ // @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
695
+ if (output.findings.length > 0) {
696
+ // The decision entering this block (post-verify, pre-requalification softening)
697
+ // is the ceiling both grounding and confirmation re-derive against: confirmation
698
+ // returns findings to blocking, so re-running decisionAfterRequalification over
699
+ // the post-confirmation set re-hardens up to this value, never past it.
700
+ const decisionBeforeRequalification = output.decision;
701
+ const grounding = groundStackRequalification(output.findings, stackManifest, preCoordinationFileLocks, progress);
702
+ requalificationStrips.push(...grounding.stripped);
703
+ let grounded = grounding.findings;
704
+ // v2 patch confirmation (gated by stack.confirmWithPatch): for the requalifications
705
+ // that survived grounding, read the addressing PR's actual patch and strip any not
706
+ // clearly addressed. Fail toward blocking on any fetch/verify error or timeout.
707
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — runs right after grounding, before the decision is re-derived; never materializes the patch
708
+ if (options.stackConfirm &&
709
+ stackManifest &&
710
+ grounded.some((finding) => finding.requalifiedBy)) {
711
+ progress("Confirming stacked-PR requalifications against their patches…");
712
+ const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source), progress);
713
+ grounded = confirmation.findings;
714
+ requalificationStrips.push(...confirmation.strippedFindings);
715
+ agentCosts[STACK_VERIFIER_AGENT] = confirmation.cost;
716
+ trackTokens(STACK_VERIFIER_AGENT, confirmation.tokens);
717
+ trackModel(STACK_VERIFIER_AGENT, config.agents[0]?.model ?? config.coordinator.model, confirmation.model);
718
+ if (confirmation.stripped > 0) {
719
+ progress(`Stack confirmation returned ${confirmation.stripped} requalified finding(s) to blocking.`);
720
+ }
721
+ }
722
+ output = {
723
+ ...output,
724
+ findings: grounded,
725
+ // decisionAfterGrounding only re-derives when a requalification SURVIVED
726
+ // grounding + confirmation: with none, the coordinator's decision must stand
727
+ // untouched — an unconditional decisionAfterRequalification here would
728
+ // soften every non-critical request_changes on every run, stack feature or
729
+ // not. Criticals never carry requalifiedBy (grounding strips it), so the
730
+ // later decisionAfterRequalification call in the suppression block cannot
731
+ // re-escalate past this softened decision.
732
+ decision: decisionAfterGrounding(decisionBeforeRequalification, grounded),
733
+ };
734
+ }
642
735
  // Inline `expo-code-review-ignore` directives suppress non-critical findings.
643
736
  if (output.findings.length > 0) {
644
737
  const { kept, suppressed } = await applyInlineIgnores(output.findings, process.cwd(), progress);
@@ -647,17 +740,90 @@ export async function runReview(source, options) {
647
740
  output = {
648
741
  ...output,
649
742
  findings: kept,
650
- decision: decisionAfterVerification(output.decision, kept),
743
+ // decisionAfterRequalification, NOT decisionAfterVerification: `kept` may
744
+ // still hold requalified (non-blocking) findings, and the decision must be
745
+ // re-derived over the BLOCKING subset — else suppressing the last blocking
746
+ // finding leaves a stale approve_with_comments. With no requalifications
747
+ // the two derivations are identical.
748
+ decision: decisionAfterRequalification(output.decision, kept),
651
749
  };
652
750
  }
653
751
  }
654
752
  // The coordinator's summary was written against the pre-check finding set, so if
655
753
  // verification/suppression removed anything it can now reference issues that are
656
754
  // no longer listed. Reconcile the summary so it never contradicts the findings.
755
+ // A decision change WITHOUT a count drop gets its own note: only requalification
756
+ // does that — every finding is still listed, so the "removed" wording of the
757
+ // count-drop note would be factually wrong there.
657
758
  const removedAfterChecks = findingCountBeforeChecks - output.findings.length;
658
759
  if (removedAfterChecks > 0) {
659
760
  output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
660
761
  }
762
+ else if (output.decision !== decisionBeforeChecks) {
763
+ output = { ...output, summary: reconcileRequalifiedSummary(output.summary) };
764
+ }
765
+ // Attribution: carry each surviving finding's originating agent onto the output. The
766
+ // coordinator merges and rewrites findings, so match by fingerprint and keep the
767
+ // first agent that produced it; a finding the coordinator changed enough to break the
768
+ // fingerprint stays unattributed (reported as "unknown") rather than guessed. Agent
769
+ // is excluded from the fingerprint, so setting it can never lapse a dismissal. This
770
+ // lookup is the ONLY writer: the model-facing schema drops any `agent` the
771
+ // coordinator emitted, so nothing here has to trust (or defer to) model attribution.
772
+ // @ref LLP 0011#attribution-and-identity [implements] — attribution rides through the coordinator by fingerprint; annotation-only, and engine-set only
773
+ if (agentByFp.size > 0 && output.findings.length > 0) {
774
+ output = {
775
+ ...output,
776
+ findings: output.findings.map((finding) => {
777
+ const agent = agentByFp.get(fingerprintFinding(finding));
778
+ return agent ? { ...finding, agent } : finding;
779
+ }),
780
+ };
781
+ }
782
+ // Author-feedback adjudication (ships dark): when the caller supplied feedback input
783
+ // and the mode is on, match the replies to the final findings and — in "adjudicate"
784
+ // mode — judge each rebuttal against the source, then record the verdict and whether
785
+ // it cleared the finding. Fails open: any error leaves the review untouched, so the
786
+ // feedback path can never break a review (`ecr ci` must never fail a PR's checks).
787
+ // @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
788
+ let feedbackRecords;
789
+ if (options.feedback && options.feedback.config.mode !== "off") {
790
+ try {
791
+ const items = await options.feedback.match(output);
792
+ const adjudication = await adjudicateFeedback(handle, items, options.feedback.config, progress,
793
+ // The revision each verdict is judged against: the PR head OID this run
794
+ // materialized and read from. A source without one (local git) stamps
795
+ // nothing, so its verdicts never carry to a later run.
796
+ metadata.headOid);
797
+ feedbackRecords = adjudication.records;
798
+ agentCosts["adjudicator"] = adjudication.cost;
799
+ trackTokens("adjudicator", adjudication.tokens);
800
+ trackModel("adjudicator", config.agents[0]?.model ?? config.coordinator.model, adjudication.model);
801
+ // Never silent: a capped or failed adjudication is a reduced-coverage fact.
802
+ if (adjudication.skipped > 0 || adjudication.failed > 0) {
803
+ const parts = [];
804
+ if (adjudication.skipped > 0) {
805
+ parts.push(`${adjudication.skipped} left unjudged over the maxAdjudications=${options.feedback.config.maxAdjudications} cap`);
806
+ }
807
+ if (adjudication.failed > 0) {
808
+ parts.push(`${adjudication.failed} could not be judged (the source check failed)`);
809
+ }
810
+ output = {
811
+ ...output,
812
+ incomplete: [
813
+ ...new Set([
814
+ ...output.incomplete,
815
+ `Author-reply adjudication was reduced this run: ${parts.join("; ")}. ` +
816
+ `Those replies carry no verdict and cleared no finding.`,
817
+ ]),
818
+ ],
819
+ };
820
+ }
821
+ }
822
+ catch (error) {
823
+ // Fail open — feedback never breaks a review.
824
+ progress(`Author-reply adjudication failed (${errorMessage(error)}); continuing without it.`);
825
+ }
826
+ }
661
827
  // Surface provider throttling as a fact about the run: passes already waited or
662
828
  // backed off, but the operator should still SEE that it happened (a run that
663
829
  // was rate-limited is slower and may carry partial passes — that's the cause).
@@ -698,6 +864,7 @@ export async function runReview(source, options) {
698
864
  agentFindings,
699
865
  coverageNotes,
700
866
  verifierDropped,
867
+ requalificationStrips,
701
868
  ...(rlTotal > 0
702
869
  ? {
703
870
  rateLimitEvents: rlTotal,
@@ -709,7 +876,10 @@ export async function runReview(source, options) {
709
876
  findingCount: output.findings.length,
710
877
  summary: output.summary,
711
878
  });
712
- return output;
879
+ // Engine-owned: overwrite whatever the coordinator may have emitted under this key,
880
+ // so setup advice is always the checker's, never model text.
881
+ const reviewed = { ...output, setupNotes };
882
+ return feedbackRecords ? { ...reviewed, feedback: feedbackRecords } : reviewed;
713
883
  }
714
884
  catch (error) {
715
885
  await safeLog(logPath, {
@@ -734,13 +904,20 @@ export async function runReview(source, options) {
734
904
  }
735
905
  }
736
906
  /**
737
- * Policy backstop: drop suggestions unless opted in, cap by count (most severe
738
- * first), and downgrade approve_with_comments to approve when nothing remains.
907
+ * Policy backstop: strip the internal risk handoff, drop suggestions unless
908
+ * opted in, cap by count (most severe first), and downgrade
909
+ * approve_with_comments to approve when nothing remains.
739
910
  */
740
911
  export function applyReviewPolicy(output, policy) {
741
- let findings = policy.includeSuggestions
742
- ? output.findings
743
- : output.findings.filter((finding) => finding.severity !== "suggestion");
912
+ // Unconditional, and before the severity filter: the handoff is `suggestion`-
913
+ // severity, so `includeSuggestions: true` would otherwise publish it as a
914
+ // finding whenever the coordinator forgot to strip it. It is prompt-authored
915
+ // metadata for the coordinator's summary, never something an author should see.
916
+ // @ref LLP 0009#prompt-rules-for-adopters [implements] — code-level strip, not prompt-only
917
+ let findings = output.findings.filter((finding) => !isOverallRiskHandoff(finding));
918
+ if (!policy.includeSuggestions) {
919
+ findings = findings.filter((finding) => finding.severity !== "suggestion");
920
+ }
744
921
  findings = sortFindings(findings);
745
922
  if (policy.maxFindings != null) {
746
923
  findings = findings.slice(0, policy.maxFindings);
@@ -756,6 +933,7 @@ export function applyReviewPolicy(output, policy) {
756
933
  * Merges + de-dupes (by fingerprint), applies the same policy, and picks a
757
934
  * conservative decision (never a clean approve when there are findings).
758
935
  */
936
+ // @ref LLP 0002#coordinator-and-degraded-decisions [implements] — coordinator failure must never discard already-collected findings
759
937
  function fallbackConsolidation(agentFindings, policy) {
760
938
  const seen = new Set();
761
939
  const merged = [];
@@ -795,6 +973,107 @@ export function decisionAfterVerification(previous, kept) {
795
973
  }
796
974
  return previous;
797
975
  }
976
+ /**
977
+ * The normalized FILES where any reviewer emitted a critical, `secrets`, or `security`
978
+ * finding PRE-coordination. This is the severity LOCK: no finding on such a file is
979
+ * requalifiable, no matter what the coordinator later assigns it. Keyed on the file
980
+ * alone — NOT a content fingerprint — because the coordinator legitimately
981
+ * re-categorizes and paraphrases findings, and a fingerprint over those mutable
982
+ * fields would let a downgraded-then-reworded critical dodge the lock. Over-locking
983
+ * a whole file only keeps findings blocking (the feature's fail direction).
984
+ * Exported for tests.
985
+ */
986
+ // @ref LLP 0010#grounding-and-the-decision [implements] — pre-coordination file locks defeat downgrade-then-requalify
987
+ export function buildPreCoordinationFileLocks(agentFindings) {
988
+ const locked = new Set();
989
+ for (const findings of Object.values(agentFindings)) {
990
+ for (const finding of findings) {
991
+ if (finding.severity === "critical" ||
992
+ finding.category === "secrets" ||
993
+ finding.category === "security") {
994
+ locked.add(normalizeManifestPath(finding.file));
995
+ }
996
+ }
997
+ }
998
+ return locked;
999
+ }
1000
+ // @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
1001
+ /**
1002
+ * Strip a finding's `requalifiedBy` (leaving the finding itself fully intact and
1003
+ * blocking) when any of these hold — every check is over data the coordinator cannot
1004
+ * influence:
1005
+ * - the cited `(prNumber, file)` is not an EXACT normalized member of the fetched
1006
+ * manifest (forged or hallucinated citation);
1007
+ * - the finding is `critical` severity, or category `secrets` or `security`;
1008
+ * - the finding's FILE carried a pre-coordination critical/secrets/security reviewer
1009
+ * finding (severity lock — keyed on the file, so a coordinator re-categorization
1010
+ * or paraphrase cannot dodge it).
1011
+ * Returns the grounded findings plus every stripped requalification (finding +
1012
+ * reason): a debug line covers the live stderr stream, and the caller persists the
1013
+ * strips to the run log (mirroring verifierDropped) so a silent under-fire stays
1014
+ * diagnosable after the run. Exported for tests.
1015
+ */
1016
+ export function groundStackRequalification(findings, manifest, lockedFiles, debug = () => { }) {
1017
+ const members = manifest ? buildManifestMembership(manifest) : new Set();
1018
+ const stripped = [];
1019
+ const grounded = findings.map((finding) => {
1020
+ const requalified = finding.requalifiedBy;
1021
+ if (!requalified) {
1022
+ return finding;
1023
+ }
1024
+ const strip = (reason) => {
1025
+ debug(`Stack: stripped requalification on "${finding.file}" (${reason}).`);
1026
+ const { requalifiedBy: _dropped, ...rest } = finding;
1027
+ stripped.push({ finding: rest, reason });
1028
+ return rest;
1029
+ };
1030
+ if (finding.severity === "critical") {
1031
+ return strip("critical severity is never requalifiable");
1032
+ }
1033
+ if (finding.category === "secrets" || finding.category === "security") {
1034
+ return strip(`${finding.category} category is never requalifiable`);
1035
+ }
1036
+ if (lockedFiles.has(normalizeManifestPath(finding.file))) {
1037
+ return strip("a reviewer emitted a critical/secrets/security finding on this file (severity lock)");
1038
+ }
1039
+ if (!members.has(manifestKey(requalified.prNumber, requalified.file))) {
1040
+ return strip(`cited #${requalified.prNumber} "${requalified.file}" is not an exact manifest member`);
1041
+ }
1042
+ return finding;
1043
+ });
1044
+ return { findings: grounded, stripped };
1045
+ }
1046
+ /**
1047
+ * Re-derive the decision after requalification over the still-BLOCKING (non-requalified)
1048
+ * findings only — the parallel of decisionAfterVerification. Requalified findings stay
1049
+ * shown and counted but never block: no blocking findings → approve; a request_changes
1050
+ * with no blocking critical left → soften to approve_with_comments. Exported for tests.
1051
+ */
1052
+ // @ref LLP 0010#grounding-and-the-decision [implements] — decision is computed over the active subset, so a requalified warning stops blocking but stays visible
1053
+ export function decisionAfterRequalification(previous, findings) {
1054
+ const blocking = findings.filter((finding) => !finding.requalifiedBy);
1055
+ if (blocking.length === 0) {
1056
+ return "approve";
1057
+ }
1058
+ if (previous === "request_changes" &&
1059
+ !blocking.some((finding) => finding.severity === "critical")) {
1060
+ return "approve_with_comments";
1061
+ }
1062
+ return previous;
1063
+ }
1064
+ /**
1065
+ * The grounding block's decision step: re-derive ONLY when a requalification
1066
+ * survived grounding. With none (the overwhelmingly common case — stack feature
1067
+ * off, or every requalification stripped), the incoming decision stands untouched:
1068
+ * re-deriving unconditionally would soften every non-critical request_changes on
1069
+ * every run, silently overriding the coordinator's (and any adopter rubric's)
1070
+ * decision policy. Exported for tests.
1071
+ */
1072
+ export function decisionAfterGrounding(previous, findings) {
1073
+ return findings.some((finding) => finding.requalifiedBy)
1074
+ ? decisionAfterRequalification(previous, findings)
1075
+ : previous;
1076
+ }
798
1077
  /**
799
1078
  * The coordinator writes its summary before findings are verified/suppressed, so a
800
1079
  * post-coordination drop can leave the summary referencing issues no longer shown.
@@ -810,6 +1089,18 @@ export function reconcileSummary(summary, remaining) {
810
1089
  "this summary was written, so it may mention issues no longer listed below._\n\n" +
811
1090
  summary);
812
1091
  }
1092
+ /**
1093
+ * The decision-changed-without-removal reconcile: requalification softened the
1094
+ * decision while keeping every finding listed, so the summary prose (written before
1095
+ * grounding ran) can read stricter than the final decision. Nothing was removed —
1096
+ * the note must not claim it was. Exported for tests.
1097
+ */
1098
+ export function reconcileRequalifiedSummary(summary) {
1099
+ return ("_Note: after this summary was written, some findings were requalified as " +
1100
+ "addressed in stacked PRs — they are still listed below but no longer block, " +
1101
+ "so the prose may read stricter than the final decision._\n\n" +
1102
+ summary);
1103
+ }
813
1104
  /**
814
1105
  * Resolve the tree the review reads from, applying the mode's trust policy:
815
1106
  *
@@ -913,6 +1204,7 @@ const QUEUE_IDLE_POLL_MS = 100;
913
1204
  * running (a running worker might yet enqueue more), so dynamically-added work is
914
1205
  * never lost. `fn` receives the item and an `enqueue` callback.
915
1206
  */
1207
+ // @ref LLP 0002#timeouts-stalls-and-subdivision [implements] — terminates on active===0, not queue-empty, so growth mid-drain isn't lost
916
1208
  export async function runGrowableQueue(initial, limit, fn) {
917
1209
  const queue = [...initial];
918
1210
  let active = 0;