@expo/code-review-cli 0.9.2 → 0.11.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 +83 -60
- package/build/commands/ci.js +137 -26
- package/build/commands/init.js +18 -13
- package/build/core/adjudicate.js +1 -0
- package/build/core/auth.js +4 -1
- package/build/core/claude-code.js +315 -43
- package/build/core/coordinator.js +2 -1
- package/build/core/exec.js +10 -0
- package/build/core/opencode.js +8 -9
- package/build/core/render.js +10 -3
- package/build/core/review-cache.js +108 -0
- package/build/core/review.js +81 -13
- package/build/core/router.js +2 -1
- package/build/core/schema.js +78 -23
- package/build/core/stack-confirm.js +2 -1
- package/build/core/verify.js +1 -0
- package/build/reporters/github.js +3 -3
- package/package.json +1 -1
- package/templates/agents/security.md +3 -3
- package/templates/atlantis.yml +11 -5
- package/templates/command.yml +14 -7
- package/templates/config.jsonc +31 -39
- package/templates/coordinator.md +2 -2
- package/templates/shared.md +18 -3
- package/templates/workflow.yml +15 -7
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @ref LLP 0005#review-result-cache [implements] — automated CI reuses only a complete result whose full review input hashes identically
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { pathInside } from "./exec.js";
|
|
8
|
+
const packageVersion = createRequire(import.meta.url)("../../package.json")
|
|
9
|
+
.version;
|
|
10
|
+
/**
|
|
11
|
+
* Version the cache-key contract independently of the embedded comment-state shape.
|
|
12
|
+
* Bump this whenever review inputs gain a new source that is not represented below.
|
|
13
|
+
*/
|
|
14
|
+
const REVIEW_INPUT_HASH_VERSION = 1;
|
|
15
|
+
/** Canonical JSON: object-key order never turns the same input into a cache miss. */
|
|
16
|
+
function canonicalJson(value) {
|
|
17
|
+
if (value === null || typeof value !== "object") {
|
|
18
|
+
return JSON.stringify(value) ?? "null";
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
22
|
+
}
|
|
23
|
+
const record = value;
|
|
24
|
+
return `{${Object.keys(record)
|
|
25
|
+
.filter((key) => record[key] !== undefined)
|
|
26
|
+
.sort()
|
|
27
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
28
|
+
.join(",")}}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Hash every input that can change the code-review result while deliberately
|
|
32
|
+
* excluding checkout-local paths and commit OIDs. A restack changes OIDs; when the
|
|
33
|
+
* scoped changed-file contents, normalized patch, prompts/config and PR prose are
|
|
34
|
+
* byte-identical, that is the cache hit this key is meant to recognize.
|
|
35
|
+
*/
|
|
36
|
+
function normalizedPatch(patch, binary) {
|
|
37
|
+
return (patch
|
|
38
|
+
.split("\n")
|
|
39
|
+
// Blob ids are volatile across a restack. A binary patch has no textual content
|
|
40
|
+
// to hash, so retain its index line as the only content identity available.
|
|
41
|
+
.filter((line) => binary || !line.startsWith("index "))
|
|
42
|
+
// Unrelated lines landing earlier in the file move hunk coordinates without
|
|
43
|
+
// changing the code under review. Preserve any trailing function heading.
|
|
44
|
+
.map((line) => line.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$/, "@@ @@$1"))
|
|
45
|
+
.join("\n"));
|
|
46
|
+
}
|
|
47
|
+
async function fileContentIdentity(root, file) {
|
|
48
|
+
const abs = path.resolve(root, file.path);
|
|
49
|
+
if (!pathInside(abs, root)) {
|
|
50
|
+
throw new Error(`review cache path escapes the PR tree: ${file.path}`);
|
|
51
|
+
}
|
|
52
|
+
let stat;
|
|
53
|
+
try {
|
|
54
|
+
stat = await lstat(abs);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === "ENOENT") {
|
|
58
|
+
return { kind: "missing" };
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (stat.isSymbolicLink()) {
|
|
63
|
+
// Never follow a PR-controlled symlink out of the materialized tree.
|
|
64
|
+
return { kind: "symlink", target: await readlink(abs), mode: stat.mode & 0o777 };
|
|
65
|
+
}
|
|
66
|
+
if (!stat.isFile()) {
|
|
67
|
+
return { kind: "other", mode: stat.mode & 0o777 };
|
|
68
|
+
}
|
|
69
|
+
const hash = createHash("sha256");
|
|
70
|
+
for await (const chunk of createReadStream(abs)) {
|
|
71
|
+
hash.update(chunk);
|
|
72
|
+
}
|
|
73
|
+
return { kind: "file", digest: hash.digest("hex"), mode: stat.mode & 0o777 };
|
|
74
|
+
}
|
|
75
|
+
export async function reviewInputHash(options) {
|
|
76
|
+
const { configDir: _configDir, ...portableConfig } = options.config;
|
|
77
|
+
const sortedFiles = [...options.files].sort((a, b) => a.path.localeCompare(b.path) || (a.status ?? "").localeCompare(b.status ?? ""));
|
|
78
|
+
const files = [];
|
|
79
|
+
// Sequential reads avoid opening one descriptor per changed file on a very wide PR.
|
|
80
|
+
for (const file of sortedFiles) {
|
|
81
|
+
files.push({
|
|
82
|
+
path: file.path,
|
|
83
|
+
patch: normalizedPatch(file.patch, file.binary === true),
|
|
84
|
+
status: file.status ?? "",
|
|
85
|
+
binary: file.binary === true,
|
|
86
|
+
content: await fileContentIdentity(options.readRoot, file),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const input = {
|
|
90
|
+
version: REVIEW_INPUT_HASH_VERSION,
|
|
91
|
+
engineVersion: packageVersion,
|
|
92
|
+
files,
|
|
93
|
+
config: portableConfig,
|
|
94
|
+
metadata: options.metadata,
|
|
95
|
+
agents: options.agents ? [...options.agents].sort() : null,
|
|
96
|
+
route: options.route === true,
|
|
97
|
+
contextText: options.contextText ?? null,
|
|
98
|
+
};
|
|
99
|
+
return createHash("sha256").update(canonicalJson(input)).digest("hex");
|
|
100
|
+
}
|
|
101
|
+
/** Partial/failed reviews must be retried, never made durable by a cache hit. */
|
|
102
|
+
export function reviewCanBeReused(review) {
|
|
103
|
+
return review.couldNotComplete !== true && review.incomplete.length === 0;
|
|
104
|
+
}
|
|
105
|
+
/** Exact hash match plus the completeness guard used by every CI comment shape. */
|
|
106
|
+
export function reviewMatchesInput(review, storedHash, currentHash) {
|
|
107
|
+
return (storedHash === currentHash && /^[a-f0-9]{64}$/.test(storedHash) && reviewCanBeReused(review));
|
|
108
|
+
}
|
package/build/core/review.js
CHANGED
|
@@ -8,7 +8,7 @@ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencode
|
|
|
8
8
|
import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
|
|
9
9
|
import { routeAgents } from "./router.js";
|
|
10
10
|
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
|
|
11
|
-
import { fingerprintFinding, isOverallRiskHandoff, parseReviewerOutput } from "./schema.js";
|
|
11
|
+
import { fingerprintFinding, isOverallRiskHandoff, parseReviewerOutput, REVIEW_TRACE_AGENT_LIMIT, REVIEW_TRACE_BYTES_LIMIT, REVIEW_TRACE_CHECKED_LIMIT, REVIEW_TRACE_UNCERTAINTY_LIMIT, } from "./schema.js";
|
|
12
12
|
import { adjudicateFeedback } from "./adjudicate.js";
|
|
13
13
|
import { buildManifestMembership, manifestKey, normalizeManifestPath } from "./stack.js";
|
|
14
14
|
import { confirmStackRequalifications, patchConfirmer } from "./stack-confirm.js";
|
|
@@ -70,6 +70,12 @@ export function effectiveConcurrency(config, env = process.env) {
|
|
|
70
70
|
}
|
|
71
71
|
return 6;
|
|
72
72
|
}
|
|
73
|
+
/** Prefix live model activity with its stable agent bucket and optional pass label. */
|
|
74
|
+
export function formatAgentActivity(agent, label, line) {
|
|
75
|
+
const pass = label === agent ? "" : label.startsWith(`${agent} `) ? label.slice(agent.length).trim() : label;
|
|
76
|
+
const activity = line.replace(/[\r\n]+/g, " ").trim();
|
|
77
|
+
return ` [${agent}] ${pass ? `${pass}: ` : ""}${activity}`;
|
|
78
|
+
}
|
|
73
79
|
export async function runReview(source, options) {
|
|
74
80
|
const { config } = options;
|
|
75
81
|
const started = Date.now();
|
|
@@ -283,6 +289,10 @@ export async function runReview(source, options) {
|
|
|
283
289
|
// reviewers produced before the failure — partial findings are exactly what's
|
|
284
290
|
// needed to debug a run that died mid-way.
|
|
285
291
|
const agentFindings = {};
|
|
292
|
+
// Bounded, conclusion-only diagnostics for machine consumers of the hidden
|
|
293
|
+
// comment state. These are deliberately separate from findings: they never reach
|
|
294
|
+
// the coordinator, verification, policy, or decision paths.
|
|
295
|
+
const agentTrace = {};
|
|
286
296
|
// First reviewer (by scheduling order) that produced each fingerprint, so a finding's
|
|
287
297
|
// originating agent can be carried through the coordinator's merge/rewrite by matching
|
|
288
298
|
// on fingerprint. Kept separate from agentFindings so the coordinator prompt and the
|
|
@@ -324,7 +334,7 @@ export async function runReview(source, options) {
|
|
|
324
334
|
let selectedAgents = explicitAgents ?? config.agents;
|
|
325
335
|
if (!explicitAgents && options.route) {
|
|
326
336
|
progress("Routing: selecting relevant agents…");
|
|
327
|
-
const routed = await routeAgents(handle, config, workspace.files);
|
|
337
|
+
const routed = await routeAgents(handle, config, workspace.files, (line) => progress(formatAgentActivity("router", "router", line)));
|
|
328
338
|
selectedAgents = routed.agents;
|
|
329
339
|
progress(routed.routed
|
|
330
340
|
? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
|
|
@@ -467,6 +477,7 @@ export async function runReview(source, options) {
|
|
|
467
477
|
});
|
|
468
478
|
let completedPasses = 0;
|
|
469
479
|
let failedPasses = 0;
|
|
480
|
+
const taskProgress = (task, line) => progress(formatAgentActivity(task.bucket, task.label, line));
|
|
470
481
|
// promptAndParse already retries internally (same-session corrective, then a
|
|
471
482
|
// bounded fresh session). We do NOT wrap it in another retry loop. On a genuine
|
|
472
483
|
// TIMEOUT, instead of dropping the work we break it into units that converge:
|
|
@@ -480,7 +491,7 @@ export async function runReview(source, options) {
|
|
|
480
491
|
system: task.system,
|
|
481
492
|
text: buildTaskText(task),
|
|
482
493
|
title: task.title,
|
|
483
|
-
onActivity: (line) =>
|
|
494
|
+
onActivity: (line) => taskProgress(task, line),
|
|
484
495
|
maxWaitMs: task.maxWaitMs,
|
|
485
496
|
maxToolCalls: task.maxToolCalls,
|
|
486
497
|
finalizeOnTimeout: true,
|
|
@@ -489,6 +500,9 @@ export async function runReview(source, options) {
|
|
|
489
500
|
trackTokens(task.bucket, tokens);
|
|
490
501
|
trackModel(task.bucket, taskModel(task), model);
|
|
491
502
|
(agentFindings[task.bucket] ??= []).push(...value.findings);
|
|
503
|
+
if (value.trace) {
|
|
504
|
+
mergeTraceNotes(agentTrace, task.bucket, value.trace);
|
|
505
|
+
}
|
|
492
506
|
for (const finding of value.findings) {
|
|
493
507
|
const fp = fingerprintFinding(finding);
|
|
494
508
|
if (!agentByFp.has(fp)) {
|
|
@@ -497,7 +511,7 @@ export async function runReview(source, options) {
|
|
|
497
511
|
}
|
|
498
512
|
completedPasses++;
|
|
499
513
|
if (truncated) {
|
|
500
|
-
|
|
514
|
+
taskProgress(task, "hit its budget — returned partial findings");
|
|
501
515
|
incomplete.push(`${capitalize(task.coverageLabel)} ran out of time; its findings may be incomplete.`);
|
|
502
516
|
}
|
|
503
517
|
return;
|
|
@@ -506,7 +520,7 @@ export async function runReview(source, options) {
|
|
|
506
520
|
// Non-timeout errors are genuine failures — record and move on.
|
|
507
521
|
if (!(error instanceof AgentTimeoutError)) {
|
|
508
522
|
failedPasses++;
|
|
509
|
-
|
|
523
|
+
taskProgress(task, `FAILED (${errorMessage(error)})`);
|
|
510
524
|
// An auth/permission failure hits every pass identically; push one shared,
|
|
511
525
|
// actionable note (deduped into a single coverage line) instead of N generic
|
|
512
526
|
// per-pass failures that bury the real, fixable cause.
|
|
@@ -531,7 +545,7 @@ export async function runReview(source, options) {
|
|
|
531
545
|
const mid = Math.ceil(task.files.length / 2);
|
|
532
546
|
const left = task.files.slice(0, mid);
|
|
533
547
|
const right = task.files.slice(mid);
|
|
534
|
-
|
|
548
|
+
taskProgress(task, `exceeded ${minutes}m — splitting into 2 smaller passes (${left.length} + ${right.length} files)`);
|
|
535
549
|
const over = { depth: task.depth + 1, maxWaitMs: childCap };
|
|
536
550
|
enqueue(childTask(task, left, `↳${left.length}f`, over));
|
|
537
551
|
enqueue(childTask(task, right, `↳${right.length}f`, over));
|
|
@@ -543,7 +557,7 @@ export async function runReview(source, options) {
|
|
|
543
557
|
// without tools; it just can't open a caller outside the diff. A lighter
|
|
544
558
|
// cross-file review beats the coverage gap it used to report.
|
|
545
559
|
if (!task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
|
|
546
|
-
|
|
560
|
+
taskProgress(task, `exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
|
|
547
561
|
enqueue(childTask(task, task.files, "(no-tools fallback)", {
|
|
548
562
|
fallback: true,
|
|
549
563
|
maxWaitMs: FALLBACK_TIMEOUT_MS,
|
|
@@ -561,7 +575,7 @@ export async function runReview(source, options) {
|
|
|
561
575
|
failedPasses++;
|
|
562
576
|
const couldStillReduce = (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH) || !task.fallback;
|
|
563
577
|
if (error.reason === "stall") {
|
|
564
|
-
|
|
578
|
+
taskProgress(task, `its model requests went silent (stalled) and did not recover — ` +
|
|
565
579
|
`most likely provider rate limiting; reporting a coverage gap`);
|
|
566
580
|
// Name the likely cause. OpenCode retries a 429 internally without surfacing
|
|
567
581
|
// it, so provider throttling reaches us as pure silence — indistinguishable
|
|
@@ -573,11 +587,11 @@ export async function runReview(source, options) {
|
|
|
573
587
|
`those changes were not fully reviewed.`);
|
|
574
588
|
}
|
|
575
589
|
else if (couldStillReduce) {
|
|
576
|
-
|
|
590
|
+
taskProgress(task, `exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
|
|
577
591
|
incomplete.push(`${capitalize(task.coverageLabel)} timed out and the overall review budget was exhausted before it could be broken down further; those changes were not fully reviewed.`);
|
|
578
592
|
}
|
|
579
593
|
else {
|
|
580
|
-
|
|
594
|
+
taskProgress(task, `exceeded ${minutes}m even at its smallest reviewable unit — reporting a coverage gap`);
|
|
581
595
|
incomplete.push(`${capitalize(task.coverageLabel)} exceeded its time budget even after being reduced to its smallest reviewable unit; those changes were not fully reviewed.`);
|
|
582
596
|
}
|
|
583
597
|
}
|
|
@@ -634,7 +648,7 @@ export async function runReview(source, options) {
|
|
|
634
648
|
progress("Coordinating findings…");
|
|
635
649
|
let consolidated;
|
|
636
650
|
try {
|
|
637
|
-
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest);
|
|
651
|
+
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest, (line) => progress(formatAgentActivity("coordinator", "coordinator", line)));
|
|
638
652
|
agentCosts["coordinator"] = cost;
|
|
639
653
|
trackTokens("coordinator", coordinatorTokens);
|
|
640
654
|
trackModel("coordinator", config.coordinator.model, coordinatorModel);
|
|
@@ -709,7 +723,7 @@ export async function runReview(source, options) {
|
|
|
709
723
|
stackManifest &&
|
|
710
724
|
grounded.some((finding) => finding.requalifiedBy)) {
|
|
711
725
|
progress("Confirming stacked-PR requalifications against their patches…");
|
|
712
|
-
const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source), progress);
|
|
726
|
+
const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source, (line) => progress(formatAgentActivity(STACK_VERIFIER_AGENT, STACK_VERIFIER_AGENT, line))), progress);
|
|
713
727
|
grounded = confirmation.findings;
|
|
714
728
|
requalificationStrips.push(...confirmation.strippedFindings);
|
|
715
729
|
agentCosts[STACK_VERIFIER_AGENT] = confirmation.cost;
|
|
@@ -854,6 +868,7 @@ export async function runReview(source, options) {
|
|
|
854
868
|
}
|
|
855
869
|
progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
|
|
856
870
|
await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
|
|
871
|
+
const reviewTrace = buildReviewTrace(agentTrace);
|
|
857
872
|
await safeLog(logPath, {
|
|
858
873
|
...baseRecord,
|
|
859
874
|
agentCosts,
|
|
@@ -862,6 +877,7 @@ export async function runReview(source, options) {
|
|
|
862
877
|
agentTokens,
|
|
863
878
|
agentModels,
|
|
864
879
|
agentFindings,
|
|
880
|
+
reviewTrace,
|
|
865
881
|
coverageNotes,
|
|
866
882
|
verifierDropped,
|
|
867
883
|
requalificationStrips,
|
|
@@ -878,7 +894,14 @@ export async function runReview(source, options) {
|
|
|
878
894
|
});
|
|
879
895
|
// Engine-owned: overwrite whatever the coordinator may have emitted under this key,
|
|
880
896
|
// so setup advice is always the checker's, never model text.
|
|
881
|
-
|
|
897
|
+
// `CoordinatorOutputSchema` knows the engine field so cached/embedded reviews can
|
|
898
|
+
// parse it, but the coordinator must never author it. Strip any model-supplied
|
|
899
|
+
// value and attach only the trace assembled from reviewer pass outputs.
|
|
900
|
+
const outputWithTrace = attachReviewTrace(output, reviewTrace);
|
|
901
|
+
const reviewed = {
|
|
902
|
+
...outputWithTrace,
|
|
903
|
+
setupNotes,
|
|
904
|
+
};
|
|
882
905
|
return feedbackRecords ? { ...reviewed, feedback: feedbackRecords } : reviewed;
|
|
883
906
|
}
|
|
884
907
|
catch (error) {
|
|
@@ -889,6 +912,7 @@ export async function runReview(source, options) {
|
|
|
889
912
|
tokens: tokenTotals,
|
|
890
913
|
agentTokens,
|
|
891
914
|
agentFindings,
|
|
915
|
+
reviewTrace: buildReviewTrace(agentTrace),
|
|
892
916
|
durationMs: Date.now() - started,
|
|
893
917
|
decision: null,
|
|
894
918
|
findingCount: 0,
|
|
@@ -903,6 +927,50 @@ export async function runReview(source, options) {
|
|
|
903
927
|
await restoreCwd();
|
|
904
928
|
}
|
|
905
929
|
}
|
|
930
|
+
export function mergeTraceNotes(target, agent, notes) {
|
|
931
|
+
const current = target[agent] ?? { checked: [], uncertainties: [] };
|
|
932
|
+
const checked = [...new Set([...current.checked, ...notes.checked])].slice(0, REVIEW_TRACE_CHECKED_LIMIT);
|
|
933
|
+
const uncertainties = [...new Set([...current.uncertainties, ...notes.uncertainties])].slice(0, REVIEW_TRACE_UNCERTAINTY_LIMIT);
|
|
934
|
+
if (checked.length > 0 || uncertainties.length > 0) {
|
|
935
|
+
target[agent] = { checked, uncertainties };
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
export function buildReviewTrace(agents) {
|
|
939
|
+
// Sorting makes the cap deterministic even though concurrent passes finish in a
|
|
940
|
+
// nondeterministic order. The byte ceiling protects GitHub's ~65k comment limit;
|
|
941
|
+
// the trace shares that body with visible findings and their durable state.
|
|
942
|
+
const entries = Object.entries(agents).sort(([left], [right]) => left.localeCompare(right));
|
|
943
|
+
if (entries.length === 0) {
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
const kept = entries.slice(0, REVIEW_TRACE_AGENT_LIMIT);
|
|
947
|
+
let truncatedAgents = entries.length - kept.length;
|
|
948
|
+
for (;;) {
|
|
949
|
+
const trace = {
|
|
950
|
+
version: 1,
|
|
951
|
+
trust: "unverified-model-diagnostics",
|
|
952
|
+
agents: Object.fromEntries(kept),
|
|
953
|
+
...(truncatedAgents > 0 ? { truncatedAgents } : {}),
|
|
954
|
+
};
|
|
955
|
+
if (Buffer.byteLength(JSON.stringify(trace), "utf8") <= REVIEW_TRACE_BYTES_LIMIT) {
|
|
956
|
+
return trace;
|
|
957
|
+
}
|
|
958
|
+
if (kept.length === 0) {
|
|
959
|
+
return undefined;
|
|
960
|
+
}
|
|
961
|
+
kept.pop();
|
|
962
|
+
truncatedAgents++;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Replace any coordinator-authored trace with the engine-assembled value. The
|
|
967
|
+
* coordinator reads untrusted PR data, so its output can never populate this hidden
|
|
968
|
+
* machine-consumer channel even when it emits a locally schema-valid object.
|
|
969
|
+
*/
|
|
970
|
+
export function attachReviewTrace(output, reviewTrace) {
|
|
971
|
+
const { reviewTrace: _coordinatorTrace, ...withoutTrace } = output;
|
|
972
|
+
return { ...withoutTrace, ...(reviewTrace ? { reviewTrace } : {}) };
|
|
973
|
+
}
|
|
906
974
|
/**
|
|
907
975
|
* Policy backstop: strip the internal risk handoff, drop suggestions unless
|
|
908
976
|
* opted in, cap by count (most severe first), and downgrade
|
package/build/core/router.js
CHANGED
|
@@ -6,7 +6,7 @@ import { parseRouteOutput } from "./schema.js";
|
|
|
6
6
|
* `alwaysRun` are unioned in regardless. Falls back to ALL agents if the router
|
|
7
7
|
* returns nothing usable or errors — a review must never run with zero agents.
|
|
8
8
|
*/
|
|
9
|
-
export async function routeAgents(handle, config, files) {
|
|
9
|
+
export async function routeAgents(handle, config, files, onActivity) {
|
|
10
10
|
const always = config.agents.filter((agent) => agent.alwaysRun);
|
|
11
11
|
try {
|
|
12
12
|
const { value } = await promptAndParse(handle, {
|
|
@@ -14,6 +14,7 @@ export async function routeAgents(handle, config, files) {
|
|
|
14
14
|
system: buildRouterSystem(),
|
|
15
15
|
text: buildRouterTask(config.agents, files),
|
|
16
16
|
title: "route",
|
|
17
|
+
onActivity,
|
|
17
18
|
}, parseRouteOutput);
|
|
18
19
|
const byId = new Map(config.agents.map((agent) => [agent.id, agent]));
|
|
19
20
|
const picked = value.agents
|
package/build/core/schema.js
CHANGED
|
@@ -66,9 +66,6 @@ export const VerdictSchema = z.object({
|
|
|
66
66
|
verified: z.boolean(),
|
|
67
67
|
reason: z.string().default(""),
|
|
68
68
|
});
|
|
69
|
-
export function parseVerdict(text) {
|
|
70
|
-
return VerdictSchema.parse(extractJsonObject(text));
|
|
71
|
-
}
|
|
72
69
|
/**
|
|
73
70
|
* A stack verifier's verdict on whether a later stacked PR's patch actually
|
|
74
71
|
* addresses an absence-style finding (v2 patch confirmation). Fails toward
|
|
@@ -79,27 +76,72 @@ export const StackVerdictSchema = z.object({
|
|
|
79
76
|
addressed: z.boolean(),
|
|
80
77
|
reason: z.string().default(""),
|
|
81
78
|
});
|
|
82
|
-
export function parseStackVerdict(text) {
|
|
83
|
-
return StackVerdictSchema.parse(extractJsonObject(text));
|
|
84
|
-
}
|
|
85
79
|
// @ref LLP 0011#attribution-and-identity [implements] — `agent` is engine-populated, so the model-facing schema drops it at the parse boundary instead of trusting call sites to strip it
|
|
86
80
|
/**
|
|
87
81
|
* The finding shape a MODEL may emit: `FindingSchema` minus the engine-only `agent`.
|
|
88
82
|
* Both model outputs parse through this, so an `agent` a reviewer pass or the
|
|
89
83
|
* coordinator invented is dropped where model JSON becomes typed data — the engine's own
|
|
90
|
-
* fingerprint lookup is then the only thing that can set it.
|
|
91
|
-
*
|
|
84
|
+
* fingerprint lookup is then the only thing that can set it. This schema deliberately
|
|
85
|
+
* has no Zod transform: the Claude runtime converts it to JSON Schema so the provider
|
|
86
|
+
* can enforce the same contract before the local parse boundary checks it again.
|
|
87
|
+
*/
|
|
88
|
+
const ModelFindingSchema = FindingSchema.omit({ agent: true });
|
|
89
|
+
/**
|
|
90
|
+
* Bounded, non-finding diagnostics a reviewer may leave for machine consumers.
|
|
91
|
+
* These notes explain what a clean pass actually checked without exposing a raw
|
|
92
|
+
* transcript or chain-of-thought. They remain unverified model output, so the
|
|
93
|
+
* engine labels the assembled trace with an explicit trust classification.
|
|
94
|
+
*/
|
|
95
|
+
export const REVIEW_TRACE_AGENT_LIMIT = 12;
|
|
96
|
+
export const REVIEW_TRACE_CHECKED_LIMIT = 3;
|
|
97
|
+
export const REVIEW_TRACE_UNCERTAINTY_LIMIT = 2;
|
|
98
|
+
export const REVIEW_TRACE_NOTE_LIMIT = 240;
|
|
99
|
+
export const REVIEW_TRACE_BYTES_LIMIT = 6_000;
|
|
100
|
+
export const ReviewerTraceNotesSchema = z.object({
|
|
101
|
+
checked: z
|
|
102
|
+
.array(z.string().min(1).max(REVIEW_TRACE_NOTE_LIMIT))
|
|
103
|
+
.max(REVIEW_TRACE_CHECKED_LIMIT)
|
|
104
|
+
.default([]),
|
|
105
|
+
uncertainties: z
|
|
106
|
+
.array(z.string().min(1).max(REVIEW_TRACE_NOTE_LIMIT))
|
|
107
|
+
.max(REVIEW_TRACE_UNCERTAINTY_LIMIT)
|
|
108
|
+
.default([]),
|
|
109
|
+
});
|
|
110
|
+
/** Provider-facing shape each sub-reviewer is asked to emit. */
|
|
111
|
+
const ReviewerModelOutputSchema = z.object({
|
|
112
|
+
findings: z.array(ModelFindingSchema).default([]),
|
|
113
|
+
trace: ReviewerTraceNotesSchema.optional(),
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* Local trust boundary for reviewer output. Findings stay strict, while diagnostics
|
|
117
|
+
* fail soft: a malformed optional trace must never discard otherwise valid findings
|
|
118
|
+
* or turn a clean pass into a coverage gap.
|
|
92
119
|
*/
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
export const ReviewerOutputSchema = z.object({
|
|
120
|
+
export const ReviewerOutputSchema = z
|
|
121
|
+
.object({
|
|
96
122
|
findings: z.array(ModelFindingSchema).default([]),
|
|
123
|
+
trace: z.unknown().optional(),
|
|
124
|
+
})
|
|
125
|
+
.transform((output) => {
|
|
126
|
+
const trace = ReviewerTraceNotesSchema.safeParse(output.trace);
|
|
127
|
+
return {
|
|
128
|
+
findings: output.findings,
|
|
129
|
+
...(trace.success ? { trace: trace.data } : {}),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
export const ReviewTraceSchema = z.object({
|
|
133
|
+
version: z.literal(1),
|
|
134
|
+
trust: z.literal("unverified-model-diagnostics"),
|
|
135
|
+
agents: z.record(z.string(), ReviewerTraceNotesSchema),
|
|
136
|
+
truncatedAgents: z.number().int().nonnegative().optional(),
|
|
97
137
|
});
|
|
98
138
|
/** Mode-agnostic coordinator result; each Reporter decides how to render it. */
|
|
99
|
-
|
|
139
|
+
const CoordinatorModelOutputSchema = z.object({
|
|
100
140
|
decision: z.enum(DECISIONS),
|
|
101
141
|
findings: z.array(ModelFindingSchema).default([]),
|
|
102
142
|
summary: z.string(),
|
|
143
|
+
});
|
|
144
|
+
export const CoordinatorOutputSchema = CoordinatorModelOutputSchema.extend({
|
|
103
145
|
/**
|
|
104
146
|
* Human-readable notes about reduced coverage (e.g. a review pass that hit its
|
|
105
147
|
* time limit and returned partial findings, or was skipped). Populated by the
|
|
@@ -125,6 +167,14 @@ export const CoordinatorOutputSchema = z.object({
|
|
|
125
167
|
// Optional (like couldNotComplete) so every internal CoordinatorOutput literal stays
|
|
126
168
|
// valid without restating an engine-owned field.
|
|
127
169
|
setupNotes: z.array(z.string()).optional(),
|
|
170
|
+
/**
|
|
171
|
+
* Machine-readable reviewer diagnostics embedded in the hidden PR-comment state.
|
|
172
|
+
* Engine-owned and excluded from the coordinator's provider-side schema. It is not
|
|
173
|
+
* rendered as prose and must never affect the decision or finding set.
|
|
174
|
+
*/
|
|
175
|
+
// Fail soft here too: the coordinator cannot author this engine field, and a
|
|
176
|
+
// malformed injected value must not fail consolidation before the engine strips it.
|
|
177
|
+
reviewTrace: ReviewTraceSchema.optional().catch(undefined),
|
|
128
178
|
});
|
|
129
179
|
/** How an author's reply to a finding held up against the source. */
|
|
130
180
|
export const FEEDBACK_VERDICTS = ["accepted", "refuted", "unclear"];
|
|
@@ -271,9 +321,6 @@ export const AdjudicationSchema = z.object({
|
|
|
271
321
|
verdict: z.enum(FEEDBACK_VERDICTS),
|
|
272
322
|
reason: z.enum(FEEDBACK_REASONS).default("other"),
|
|
273
323
|
});
|
|
274
|
-
export function parseAdjudication(text) {
|
|
275
|
-
return AdjudicationSchema.parse(extractJsonObject(text));
|
|
276
|
-
}
|
|
277
324
|
/** Minimum normalized evidence length to key a fingerprint on the code (below
|
|
278
325
|
* this we fall back to the title). */
|
|
279
326
|
const MIN_FP_EVIDENCE_LEN = 12;
|
|
@@ -344,12 +391,20 @@ export function extractJsonObject(text) {
|
|
|
344
391
|
export const RouteOutputSchema = z.object({
|
|
345
392
|
agents: z.array(z.string()).default([]),
|
|
346
393
|
});
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Bind local Zod validation to the draft-07 JSON Schema Claude Code consumes.
|
|
396
|
+
* Local parsing remains authoritative; provider-side validation is a reliability
|
|
397
|
+
* layer that repairs malformed output before it reaches this trust boundary.
|
|
398
|
+
*/
|
|
399
|
+
// @ref LLP 0003#retry-taxonomy [implements] — Claude receives the same contract as the local parser and repairs mismatches in-session
|
|
400
|
+
function structuredParser(parseSchema, outputSchema = parseSchema) {
|
|
401
|
+
const parser = ((text) => parseSchema.parse(extractJsonObject(text)));
|
|
402
|
+
parser.jsonSchema = z.toJSONSchema(outputSchema, { target: "draft-7" });
|
|
403
|
+
return parser;
|
|
355
404
|
}
|
|
405
|
+
export const parseVerdict = structuredParser(VerdictSchema);
|
|
406
|
+
export const parseStackVerdict = structuredParser(StackVerdictSchema);
|
|
407
|
+
export const parseAdjudication = structuredParser(AdjudicationSchema);
|
|
408
|
+
export const parseRouteOutput = structuredParser(RouteOutputSchema);
|
|
409
|
+
export const parseReviewerOutput = structuredParser(ReviewerOutputSchema, ReviewerModelOutputSchema);
|
|
410
|
+
export const parseCoordinatorOutput = structuredParser(CoordinatorOutputSchema, CoordinatorModelOutputSchema);
|
|
@@ -103,7 +103,7 @@ export async function confirmStackRequalifications(findings, maxConfirmations, c
|
|
|
103
103
|
* it into the no-tools stack verifier, and require `addressed: true`. The patch is
|
|
104
104
|
* inlined, never materialized — there is no disk read and no tool use at all.
|
|
105
105
|
*/
|
|
106
|
-
export function patchConfirmer(handle, source) {
|
|
106
|
+
export function patchConfirmer(handle, source, onActivity) {
|
|
107
107
|
// Per-run patch memo: distinct findings citing the same (prNumber, file) each get
|
|
108
108
|
// their own verdict but share one gh fetch. The source fails open to null (never
|
|
109
109
|
// rejects), so memoizing the promise is safe.
|
|
@@ -131,6 +131,7 @@ export function patchConfirmer(handle, source) {
|
|
|
131
131
|
// A timeout here throws AgentTimeoutError, which the caller catches and STRIPS
|
|
132
132
|
// (fail toward blocking) — so no finalize salvage, unlike the main verifier.
|
|
133
133
|
finalizeOnTimeout: false,
|
|
134
|
+
onActivity: (line) => onActivity?.(`PR #${prNumber}: ${line}`),
|
|
134
135
|
}, parseStackVerdict);
|
|
135
136
|
return { addressed: value.addressed === true, cost, tokens, model };
|
|
136
137
|
};
|
package/build/core/verify.js
CHANGED
|
@@ -123,6 +123,7 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
|
123
123
|
title: `verify-${index}`,
|
|
124
124
|
maxWaitMs: VERIFY_TIMEOUT_MS,
|
|
125
125
|
finalizeOnTimeout: true,
|
|
126
|
+
onActivity: (line) => onProgress?.(` [verifier] #${index + 1}: ${line}`),
|
|
126
127
|
}, parseVerdict);
|
|
127
128
|
cost += verifyCost;
|
|
128
129
|
addTokenUsage(tokens, verifyTokens);
|
|
@@ -314,7 +314,7 @@ export class GitHubReporter {
|
|
|
314
314
|
* (annotate mode). Either way the feedback path fails soft — it never blocks the
|
|
315
315
|
* comment from being posted.
|
|
316
316
|
*/
|
|
317
|
-
async report(review, feedback) {
|
|
317
|
+
async report(review, feedback, inputHash) {
|
|
318
318
|
// Carry forward any per-PR dismissals recorded in the existing comment so they
|
|
319
319
|
// survive re-reviews (a dismissed finding stays in the collapsed section).
|
|
320
320
|
const existing = await this.findExistingComment();
|
|
@@ -332,7 +332,7 @@ export class GitHubReporter {
|
|
|
332
332
|
? applyPins(feedback, pinsIn)
|
|
333
333
|
: await this.computeFeedback(withFp, priorRecords, pinsIn);
|
|
334
334
|
const link = await this.linkContextAsync();
|
|
335
|
-
await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins));
|
|
335
|
+
await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins, inputHash));
|
|
336
336
|
}
|
|
337
337
|
/** Post/update the aggregate multi-scope comment (comment:'single' mode). */
|
|
338
338
|
async reportAggregate(results, unmatchedFiles, feedback) {
|
|
@@ -599,7 +599,7 @@ export class GitHubReporter {
|
|
|
599
599
|
const link = await this.linkContextAsync();
|
|
600
600
|
const body = isAggregate
|
|
601
601
|
? renderAggregateMarkdown(state.scopes, this.options.commentTag, next.dismissed, link, undefined, next.feedback, next.pins)
|
|
602
|
-
: renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins);
|
|
602
|
+
: renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins, state.inputHash);
|
|
603
603
|
await this.patchComment(existing.id, body);
|
|
604
604
|
return {
|
|
605
605
|
dismissedCount: next.dismissed.length,
|
package/package.json
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
|
|
4
4
|
alwaysRun: true
|
|
5
5
|
# Security is the highest-stakes agent and benefits most from stronger threat-model
|
|
6
|
-
# reasoning, so it runs on the
|
|
6
|
+
# reasoning, so it runs on the Opus tier even though the other specialists use the
|
|
7
7
|
# default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
|
|
8
|
-
# subdivide-on-timeout + the per-fetch deadline keep a slow
|
|
8
|
+
# subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
|
|
9
9
|
# @ref LLP 0009#config-and-prompt-templates [implements]
|
|
10
|
-
model:
|
|
10
|
+
model: anthropic/claude-opus-5
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Security & secrets
|
package/templates/atlantis.yml
CHANGED
|
@@ -81,6 +81,11 @@ jobs:
|
|
|
81
81
|
# disable setup-node's auto package-manager cache.
|
|
82
82
|
package-manager-cache: false
|
|
83
83
|
|
|
84
|
+
# The anthropic/… models run through the Claude Code CLI (claude-code
|
|
85
|
+
# engine). Pinned exactly — see workflow.yml (expo-code-review.yml).
|
|
86
|
+
- name: Install Claude Code CLI
|
|
87
|
+
run: npm install -g @anthropic-ai/claude-code@2.1.212
|
|
88
|
+
|
|
84
89
|
# SECURITY: the base-ref checkout includes every .expo-code-review/ config,
|
|
85
90
|
# whose auth.tokenEnv names the forwarded model credential. `ecr verify-config`
|
|
86
91
|
# sweeps every config and refuses unless tokenEnv appears exactly once, in a
|
|
@@ -89,7 +94,7 @@ jobs:
|
|
|
89
94
|
# @ref LLP 0009#guard-step-ordering-and-job-budgets [implements] — same $ECR_VERSION feeds guard and review
|
|
90
95
|
- name: Guard config tokenEnv (root + routing + all scopes)
|
|
91
96
|
env:
|
|
92
|
-
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || '
|
|
97
|
+
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
|
93
98
|
run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
|
|
94
99
|
|
|
95
100
|
# Running via `issue_comment` makes this a manual /review, which the CLI detects
|
|
@@ -101,10 +106,11 @@ jobs:
|
|
|
101
106
|
env:
|
|
102
107
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
103
108
|
# Layer-1 auth lock: keep in sync with the guard's EXPECTED.
|
|
104
|
-
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || '
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
|
110
|
+
# Anthropic review credential — the env var named by auth.tokenEnv in
|
|
111
|
+
# config.jsonc (see workflow.yml for the accepted token shapes).
|
|
112
|
+
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
|
|
113
|
+
# Optional: override the model for every agent.
|
|
108
114
|
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
|
109
115
|
run: |
|
|
110
116
|
# Build the flags as a bash array so the path never word-splits.
|