@expo/code-review-cli 0.7.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.
- package/README.md +118 -13
- package/build/cli.js +7 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +3 -0
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +255 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +290 -15
- package/build/core/schema.js +213 -2
- package/build/core/scrub.js +4 -0
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +50 -1
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +96 -1
- package/templates/workflow.yml +5 -0
package/build/core/review.js
CHANGED
|
@@ -1,13 +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, 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";
|
|
@@ -46,6 +50,7 @@ function makeRunId() {
|
|
|
46
50
|
* credential that classifies as an API KEY is metered per-request and does NOT force
|
|
47
51
|
* the cap. Exported for tests.
|
|
48
52
|
*/
|
|
53
|
+
// @ref LLP 0002#concurrency-and-budgets [implements] — compound oauth/API-key detection is load-bearing, not simplifiable
|
|
49
54
|
export function effectiveConcurrency(config, env = process.env) {
|
|
50
55
|
if (config.chunk.concurrency) {
|
|
51
56
|
return config.chunk.concurrency;
|
|
@@ -77,9 +82,14 @@ export async function runReview(source, options) {
|
|
|
77
82
|
const explicitAgents = options.agents?.length
|
|
78
83
|
? selectAgents(config.agents, options.agents)
|
|
79
84
|
: null;
|
|
80
|
-
const [metadata, changedFiles] = await Promise.all([
|
|
85
|
+
const [metadata, changedFiles, stackManifest] = await Promise.all([
|
|
81
86
|
source.getMetadata(),
|
|
82
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),
|
|
83
93
|
]);
|
|
84
94
|
// Scope isolation: when includePaths is set, this run only ever sees its own
|
|
85
95
|
// scope's files — no scope reviews another team's diff.
|
|
@@ -259,6 +269,12 @@ export async function runReview(source, options) {
|
|
|
259
269
|
// reviewers produced before the failure — partial findings are exactly what's
|
|
260
270
|
// needed to debug a run that died mid-way.
|
|
261
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();
|
|
262
278
|
// Every model request's usage lands in the run total AND its bucket, so the run
|
|
263
279
|
// log can show cache effectiveness per pass and not just run-wide.
|
|
264
280
|
const trackTokens = (bucket, tokens) => {
|
|
@@ -349,6 +365,7 @@ export async function runReview(source, options) {
|
|
|
349
365
|
// whole-diff no-tools fallback with, and "elastic budget" would have quietly
|
|
350
366
|
// reintroduced the coverage gap it exists to prevent. Sized for the finalize
|
|
351
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
|
|
352
369
|
const CROSS_CUTTING_RESERVE_MS = FALLBACK_TIMEOUT_MS + 4 * 60 * 1000;
|
|
353
370
|
// Floor: never LESS generous than one chunk pass. On a run whose window is already
|
|
354
371
|
// small (many active scopes dividing the budget) this can exceed what's left, but
|
|
@@ -419,10 +436,8 @@ export async function runReview(source, options) {
|
|
|
419
436
|
// smaller file set); a fallback task forbids tools and reviews the inlined diff.
|
|
420
437
|
const buildTaskText = (task) => {
|
|
421
438
|
const base = task.kind === "cross-cutting"
|
|
422
|
-
? buildCrossCuttingTask(task.files, selectedAgents, filtered, {
|
|
423
|
-
|
|
424
|
-
})
|
|
425
|
-
: 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);
|
|
426
441
|
return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
|
|
427
442
|
};
|
|
428
443
|
const filesLabel = (files) => files.length === 1
|
|
@@ -460,6 +475,12 @@ export async function runReview(source, options) {
|
|
|
460
475
|
trackTokens(task.bucket, tokens);
|
|
461
476
|
trackModel(task.bucket, taskModel(task), model);
|
|
462
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
|
+
}
|
|
463
484
|
completedPasses++;
|
|
464
485
|
if (truncated) {
|
|
465
486
|
progress(` ${task.label}: hit its budget — returned partial findings`);
|
|
@@ -573,6 +594,13 @@ export async function runReview(source, options) {
|
|
|
573
594
|
// NOT a coverage gap — it stays in the run log (filteredFiles), not the
|
|
574
595
|
// user-facing coverage note, which is reserved for passes that didn't finish.
|
|
575
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);
|
|
576
604
|
let output;
|
|
577
605
|
if (completedPasses === 0) {
|
|
578
606
|
// Nothing succeeded — do NOT let this render as a clean "approve".
|
|
@@ -592,7 +620,7 @@ export async function runReview(source, options) {
|
|
|
592
620
|
progress("Coordinating findings…");
|
|
593
621
|
let consolidated;
|
|
594
622
|
try {
|
|
595
|
-
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);
|
|
596
624
|
agentCosts["coordinator"] = cost;
|
|
597
625
|
trackTokens("coordinator", coordinatorTokens);
|
|
598
626
|
trackModel("coordinator", config.coordinator.model, coordinatorModel);
|
|
@@ -620,8 +648,13 @@ export async function runReview(source, options) {
|
|
|
620
648
|
// Guard against hallucinated findings before surfacing: quote-ground every
|
|
621
649
|
// finding against the real file, and adversarially verify criticals. This is
|
|
622
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
|
|
623
652
|
const findingCountBeforeChecks = output.findings.length;
|
|
653
|
+
const decisionBeforeChecks = output.decision;
|
|
624
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 = [];
|
|
625
658
|
if (output.findings.length > 0) {
|
|
626
659
|
progress("Verifying findings…");
|
|
627
660
|
const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
|
|
@@ -639,6 +672,52 @@ export async function runReview(source, options) {
|
|
|
639
672
|
};
|
|
640
673
|
}
|
|
641
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
|
+
}
|
|
642
721
|
// Inline `expo-code-review-ignore` directives suppress non-critical findings.
|
|
643
722
|
if (output.findings.length > 0) {
|
|
644
723
|
const { kept, suppressed } = await applyInlineIgnores(output.findings, process.cwd(), progress);
|
|
@@ -647,17 +726,90 @@ export async function runReview(source, options) {
|
|
|
647
726
|
output = {
|
|
648
727
|
...output,
|
|
649
728
|
findings: kept,
|
|
650
|
-
|
|
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),
|
|
651
735
|
};
|
|
652
736
|
}
|
|
653
737
|
}
|
|
654
738
|
// The coordinator's summary was written against the pre-check finding set, so if
|
|
655
739
|
// verification/suppression removed anything it can now reference issues that are
|
|
656
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.
|
|
657
744
|
const removedAfterChecks = findingCountBeforeChecks - output.findings.length;
|
|
658
745
|
if (removedAfterChecks > 0) {
|
|
659
746
|
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
|
660
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
|
+
}
|
|
661
813
|
// Surface provider throttling as a fact about the run: passes already waited or
|
|
662
814
|
// backed off, but the operator should still SEE that it happened (a run that
|
|
663
815
|
// was rate-limited is slower and may carry partial passes — that's the cause).
|
|
@@ -698,6 +850,7 @@ export async function runReview(source, options) {
|
|
|
698
850
|
agentFindings,
|
|
699
851
|
coverageNotes,
|
|
700
852
|
verifierDropped,
|
|
853
|
+
requalificationStrips,
|
|
701
854
|
...(rlTotal > 0
|
|
702
855
|
? {
|
|
703
856
|
rateLimitEvents: rlTotal,
|
|
@@ -709,7 +862,7 @@ export async function runReview(source, options) {
|
|
|
709
862
|
findingCount: output.findings.length,
|
|
710
863
|
summary: output.summary,
|
|
711
864
|
});
|
|
712
|
-
return output;
|
|
865
|
+
return feedbackRecords ? { ...output, feedback: feedbackRecords } : output;
|
|
713
866
|
}
|
|
714
867
|
catch (error) {
|
|
715
868
|
await safeLog(logPath, {
|
|
@@ -734,13 +887,20 @@ export async function runReview(source, options) {
|
|
|
734
887
|
}
|
|
735
888
|
}
|
|
736
889
|
/**
|
|
737
|
-
* Policy backstop:
|
|
738
|
-
*
|
|
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.
|
|
739
893
|
*/
|
|
740
894
|
export function applyReviewPolicy(output, policy) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
+
}
|
|
744
904
|
findings = sortFindings(findings);
|
|
745
905
|
if (policy.maxFindings != null) {
|
|
746
906
|
findings = findings.slice(0, policy.maxFindings);
|
|
@@ -756,6 +916,7 @@ export function applyReviewPolicy(output, policy) {
|
|
|
756
916
|
* Merges + de-dupes (by fingerprint), applies the same policy, and picks a
|
|
757
917
|
* conservative decision (never a clean approve when there are findings).
|
|
758
918
|
*/
|
|
919
|
+
// @ref LLP 0002#coordinator-and-degraded-decisions [implements] — coordinator failure must never discard already-collected findings
|
|
759
920
|
function fallbackConsolidation(agentFindings, policy) {
|
|
760
921
|
const seen = new Set();
|
|
761
922
|
const merged = [];
|
|
@@ -795,6 +956,107 @@ export function decisionAfterVerification(previous, kept) {
|
|
|
795
956
|
}
|
|
796
957
|
return previous;
|
|
797
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
|
+
}
|
|
798
1060
|
/**
|
|
799
1061
|
* The coordinator writes its summary before findings are verified/suppressed, so a
|
|
800
1062
|
* post-coordination drop can leave the summary referencing issues no longer shown.
|
|
@@ -810,6 +1072,18 @@ export function reconcileSummary(summary, remaining) {
|
|
|
810
1072
|
"this summary was written, so it may mention issues no longer listed below._\n\n" +
|
|
811
1073
|
summary);
|
|
812
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
|
+
}
|
|
813
1087
|
/**
|
|
814
1088
|
* Resolve the tree the review reads from, applying the mode's trust policy:
|
|
815
1089
|
*
|
|
@@ -913,6 +1187,7 @@ const QUEUE_IDLE_POLL_MS = 100;
|
|
|
913
1187
|
* running (a running worker might yet enqueue more), so dynamically-added work is
|
|
914
1188
|
* never lost. `fn` receives the item and an `enqueue` callback.
|
|
915
1189
|
*/
|
|
1190
|
+
// @ref LLP 0002#timeouts-stalls-and-subdivision [implements] — terminates on active===0, not queue-empty, so growth mid-drain isn't lost
|
|
916
1191
|
export async function runGrowableQueue(initial, limit, fn) {
|
|
917
1192
|
const queue = [...initial];
|
|
918
1193
|
let active = 0;
|