@expo/code-review-cli 0.9.2 → 0.10.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 +67 -56
- 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 +18 -11
- package/build/core/router.js +2 -1
- package/build/core/schema.js +23 -21
- 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/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
|
@@ -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();
|
|
@@ -324,7 +330,7 @@ export async function runReview(source, options) {
|
|
|
324
330
|
let selectedAgents = explicitAgents ?? config.agents;
|
|
325
331
|
if (!explicitAgents && options.route) {
|
|
326
332
|
progress("Routing: selecting relevant agents…");
|
|
327
|
-
const routed = await routeAgents(handle, config, workspace.files);
|
|
333
|
+
const routed = await routeAgents(handle, config, workspace.files, (line) => progress(formatAgentActivity("router", "router", line)));
|
|
328
334
|
selectedAgents = routed.agents;
|
|
329
335
|
progress(routed.routed
|
|
330
336
|
? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
|
|
@@ -467,6 +473,7 @@ export async function runReview(source, options) {
|
|
|
467
473
|
});
|
|
468
474
|
let completedPasses = 0;
|
|
469
475
|
let failedPasses = 0;
|
|
476
|
+
const taskProgress = (task, line) => progress(formatAgentActivity(task.bucket, task.label, line));
|
|
470
477
|
// promptAndParse already retries internally (same-session corrective, then a
|
|
471
478
|
// bounded fresh session). We do NOT wrap it in another retry loop. On a genuine
|
|
472
479
|
// TIMEOUT, instead of dropping the work we break it into units that converge:
|
|
@@ -480,7 +487,7 @@ export async function runReview(source, options) {
|
|
|
480
487
|
system: task.system,
|
|
481
488
|
text: buildTaskText(task),
|
|
482
489
|
title: task.title,
|
|
483
|
-
onActivity: (line) =>
|
|
490
|
+
onActivity: (line) => taskProgress(task, line),
|
|
484
491
|
maxWaitMs: task.maxWaitMs,
|
|
485
492
|
maxToolCalls: task.maxToolCalls,
|
|
486
493
|
finalizeOnTimeout: true,
|
|
@@ -497,7 +504,7 @@ export async function runReview(source, options) {
|
|
|
497
504
|
}
|
|
498
505
|
completedPasses++;
|
|
499
506
|
if (truncated) {
|
|
500
|
-
|
|
507
|
+
taskProgress(task, "hit its budget — returned partial findings");
|
|
501
508
|
incomplete.push(`${capitalize(task.coverageLabel)} ran out of time; its findings may be incomplete.`);
|
|
502
509
|
}
|
|
503
510
|
return;
|
|
@@ -506,7 +513,7 @@ export async function runReview(source, options) {
|
|
|
506
513
|
// Non-timeout errors are genuine failures — record and move on.
|
|
507
514
|
if (!(error instanceof AgentTimeoutError)) {
|
|
508
515
|
failedPasses++;
|
|
509
|
-
|
|
516
|
+
taskProgress(task, `FAILED (${errorMessage(error)})`);
|
|
510
517
|
// An auth/permission failure hits every pass identically; push one shared,
|
|
511
518
|
// actionable note (deduped into a single coverage line) instead of N generic
|
|
512
519
|
// per-pass failures that bury the real, fixable cause.
|
|
@@ -531,7 +538,7 @@ export async function runReview(source, options) {
|
|
|
531
538
|
const mid = Math.ceil(task.files.length / 2);
|
|
532
539
|
const left = task.files.slice(0, mid);
|
|
533
540
|
const right = task.files.slice(mid);
|
|
534
|
-
|
|
541
|
+
taskProgress(task, `exceeded ${minutes}m — splitting into 2 smaller passes (${left.length} + ${right.length} files)`);
|
|
535
542
|
const over = { depth: task.depth + 1, maxWaitMs: childCap };
|
|
536
543
|
enqueue(childTask(task, left, `↳${left.length}f`, over));
|
|
537
544
|
enqueue(childTask(task, right, `↳${right.length}f`, over));
|
|
@@ -543,7 +550,7 @@ export async function runReview(source, options) {
|
|
|
543
550
|
// without tools; it just can't open a caller outside the diff. A lighter
|
|
544
551
|
// cross-file review beats the coverage gap it used to report.
|
|
545
552
|
if (!task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
|
|
546
|
-
|
|
553
|
+
taskProgress(task, `exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
|
|
547
554
|
enqueue(childTask(task, task.files, "(no-tools fallback)", {
|
|
548
555
|
fallback: true,
|
|
549
556
|
maxWaitMs: FALLBACK_TIMEOUT_MS,
|
|
@@ -561,7 +568,7 @@ export async function runReview(source, options) {
|
|
|
561
568
|
failedPasses++;
|
|
562
569
|
const couldStillReduce = (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH) || !task.fallback;
|
|
563
570
|
if (error.reason === "stall") {
|
|
564
|
-
|
|
571
|
+
taskProgress(task, `its model requests went silent (stalled) and did not recover — ` +
|
|
565
572
|
`most likely provider rate limiting; reporting a coverage gap`);
|
|
566
573
|
// Name the likely cause. OpenCode retries a 429 internally without surfacing
|
|
567
574
|
// it, so provider throttling reaches us as pure silence — indistinguishable
|
|
@@ -573,11 +580,11 @@ export async function runReview(source, options) {
|
|
|
573
580
|
`those changes were not fully reviewed.`);
|
|
574
581
|
}
|
|
575
582
|
else if (couldStillReduce) {
|
|
576
|
-
|
|
583
|
+
taskProgress(task, `exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
|
|
577
584
|
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
585
|
}
|
|
579
586
|
else {
|
|
580
|
-
|
|
587
|
+
taskProgress(task, `exceeded ${minutes}m even at its smallest reviewable unit — reporting a coverage gap`);
|
|
581
588
|
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
589
|
}
|
|
583
590
|
}
|
|
@@ -634,7 +641,7 @@ export async function runReview(source, options) {
|
|
|
634
641
|
progress("Coordinating findings…");
|
|
635
642
|
let consolidated;
|
|
636
643
|
try {
|
|
637
|
-
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest);
|
|
644
|
+
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
645
|
agentCosts["coordinator"] = cost;
|
|
639
646
|
trackTokens("coordinator", coordinatorTokens);
|
|
640
647
|
trackModel("coordinator", config.coordinator.model, coordinatorModel);
|
|
@@ -709,7 +716,7 @@ export async function runReview(source, options) {
|
|
|
709
716
|
stackManifest &&
|
|
710
717
|
grounded.some((finding) => finding.requalifiedBy)) {
|
|
711
718
|
progress("Confirming stacked-PR requalifications against their patches…");
|
|
712
|
-
const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source), progress);
|
|
719
|
+
const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source, (line) => progress(formatAgentActivity(STACK_VERIFIER_AGENT, STACK_VERIFIER_AGENT, line))), progress);
|
|
713
720
|
grounded = confirmation.findings;
|
|
714
721
|
requalificationStrips.push(...confirmation.strippedFindings);
|
|
715
722
|
agentCosts[STACK_VERIFIER_AGENT] = confirmation.cost;
|
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,27 @@ 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.
|
|
92
87
|
*/
|
|
93
|
-
const ModelFindingSchema = FindingSchema.omit({ agent: true })
|
|
88
|
+
const ModelFindingSchema = FindingSchema.omit({ agent: true });
|
|
94
89
|
/** Shape each sub-reviewer must emit. */
|
|
95
90
|
export const ReviewerOutputSchema = z.object({
|
|
96
91
|
findings: z.array(ModelFindingSchema).default([]),
|
|
97
92
|
});
|
|
98
93
|
/** Mode-agnostic coordinator result; each Reporter decides how to render it. */
|
|
99
|
-
|
|
94
|
+
const CoordinatorModelOutputSchema = z.object({
|
|
100
95
|
decision: z.enum(DECISIONS),
|
|
101
96
|
findings: z.array(ModelFindingSchema).default([]),
|
|
102
97
|
summary: z.string(),
|
|
98
|
+
});
|
|
99
|
+
export const CoordinatorOutputSchema = CoordinatorModelOutputSchema.extend({
|
|
103
100
|
/**
|
|
104
101
|
* Human-readable notes about reduced coverage (e.g. a review pass that hit its
|
|
105
102
|
* time limit and returned partial findings, or was skipped). Populated by the
|
|
@@ -271,9 +268,6 @@ export const AdjudicationSchema = z.object({
|
|
|
271
268
|
verdict: z.enum(FEEDBACK_VERDICTS),
|
|
272
269
|
reason: z.enum(FEEDBACK_REASONS).default("other"),
|
|
273
270
|
});
|
|
274
|
-
export function parseAdjudication(text) {
|
|
275
|
-
return AdjudicationSchema.parse(extractJsonObject(text));
|
|
276
|
-
}
|
|
277
271
|
/** Minimum normalized evidence length to key a fingerprint on the code (below
|
|
278
272
|
* this we fall back to the title). */
|
|
279
273
|
const MIN_FP_EVIDENCE_LEN = 12;
|
|
@@ -344,12 +338,20 @@ export function extractJsonObject(text) {
|
|
|
344
338
|
export const RouteOutputSchema = z.object({
|
|
345
339
|
agents: z.array(z.string()).default([]),
|
|
346
340
|
});
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Bind local Zod validation to the draft-07 JSON Schema Claude Code consumes.
|
|
343
|
+
* Local parsing remains authoritative; provider-side validation is a reliability
|
|
344
|
+
* layer that repairs malformed output before it reaches this trust boundary.
|
|
345
|
+
*/
|
|
346
|
+
// @ref LLP 0003#retry-taxonomy [implements] — Claude receives the same contract as the local parser and repairs mismatches in-session
|
|
347
|
+
function structuredParser(parseSchema, outputSchema = parseSchema) {
|
|
348
|
+
const parser = ((text) => parseSchema.parse(extractJsonObject(text)));
|
|
349
|
+
parser.jsonSchema = z.toJSONSchema(outputSchema, { target: "draft-7" });
|
|
350
|
+
return parser;
|
|
355
351
|
}
|
|
352
|
+
export const parseVerdict = structuredParser(VerdictSchema);
|
|
353
|
+
export const parseStackVerdict = structuredParser(StackVerdictSchema);
|
|
354
|
+
export const parseAdjudication = structuredParser(AdjudicationSchema);
|
|
355
|
+
export const parseRouteOutput = structuredParser(RouteOutputSchema);
|
|
356
|
+
export const parseReviewerOutput = structuredParser(ReviewerOutputSchema);
|
|
357
|
+
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.
|
package/templates/command.yml
CHANGED
|
@@ -111,6 +111,12 @@ jobs:
|
|
|
111
111
|
# to save an empty cache and error).
|
|
112
112
|
package-manager-cache: false
|
|
113
113
|
|
|
114
|
+
# The anthropic/… models run through the Claude Code CLI (claude-code
|
|
115
|
+
# engine). Pinned exactly — see workflow.yml (expo-code-review.yml).
|
|
116
|
+
- name: Install Claude Code CLI
|
|
117
|
+
if: steps.cmd.outputs.run == 'true'
|
|
118
|
+
run: npm install -g @anthropic-ai/claude-code@2.1.212
|
|
119
|
+
|
|
114
120
|
# SECURITY: the base-ref checkout above includes every .expo-code-review/
|
|
115
121
|
# config.jsonc + routing.jsonc, whose auth.tokenEnv names the env var the CLI
|
|
116
122
|
# forwards as the model credential. The canonical guard ships with the CLI:
|
|
@@ -126,7 +132,7 @@ jobs:
|
|
|
126
132
|
if: steps.cmd.outputs.run == 'true'
|
|
127
133
|
env:
|
|
128
134
|
# (Comma-separated set for a multi-credential auth.providers config.)
|
|
129
|
-
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || '
|
|
135
|
+
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
|
130
136
|
run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
|
|
131
137
|
|
|
132
138
|
- name: Run AI review
|
|
@@ -136,12 +142,13 @@ jobs:
|
|
|
136
142
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
137
143
|
# Layer-1 auth lock: the CLI refuses to run when the tokenEnv it would honor
|
|
138
144
|
# differs from this. Keep it in sync with the guard's EXPECTED.
|
|
139
|
-
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || '
|
|
140
|
-
#
|
|
141
|
-
# Store it as a repo secret
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
|
146
|
+
# Anthropic review credential — the env var named by auth.tokenEnv in
|
|
147
|
+
# config.jsonc. Store it as a repo secret: an `sk-ant-oat…` token minted
|
|
148
|
+
# by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude
|
|
149
|
+
# Code CLI reads either).
|
|
150
|
+
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
|
|
151
|
+
# Optional: override the model for every agent.
|
|
145
152
|
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
|
146
153
|
AGENTS: ${{ steps.cmd.outputs.agents }}
|
|
147
154
|
ROUTE: ${{ steps.cmd.outputs.route }}
|
package/templates/config.jsonc
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// @ref LLP 0009#config-and-prompt-templates — root config: agent roster by filename, phase-1 defaults, auth
|
|
2
2
|
{
|
|
3
|
-
// Default model for every agent.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// Default model for every agent. Anthropic models run through the Claude Code
|
|
4
|
+
// CLI engine (`claude -p`), inferred per agent from the model id — see `auth`
|
|
5
|
+
// below for the credential. Override per-agent via frontmatter in the agent's
|
|
6
|
+
// markdown, or at runtime with the REVIEWER_MODEL env var
|
|
7
|
+
// (e.g. REVIEWER_MODEL=anthropic/claude-haiku-4-5).
|
|
8
|
+
"model": "anthropic/claude-sonnet-5",
|
|
7
9
|
|
|
8
10
|
// Agents: every markdown file in agents/ is one reviewer (id = filename).
|
|
9
11
|
// Add or remove files to change the roster — no list needed here.
|
|
10
12
|
// shared.md (prepended to every agent + coordinator) and coordinator.md are
|
|
11
13
|
// reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
|
|
12
|
-
// e.g. `---\nmodel:
|
|
14
|
+
// e.g. `---\nmodel: anthropic/claude-opus-5\n---`.
|
|
13
15
|
|
|
14
16
|
// @ref LLP 0009#config-and-prompt-templates [implements] — suggestions off by default, not a schema limit
|
|
15
17
|
"policy": {
|
|
@@ -46,49 +48,39 @@
|
|
|
46
48
|
// HTML marker used to find + update the single PR comment. Keep it stable.
|
|
47
49
|
"commentTag": "expo-ai-code-reviewer",
|
|
48
50
|
|
|
49
|
-
// How model credentials are provided. Default:
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
51
|
+
// How model credentials are provided. Default: Anthropic via the Claude Code
|
|
52
|
+
// CLI. Anthropic models always run through that CLI — the engine is inferred
|
|
53
|
+
// per agent from the `anthropic/…` model id, so this entry only supplies the
|
|
54
|
+
// credential. In CI the tokenEnv holds the review credential (an `sk-ant-oat…`
|
|
55
|
+
// token minted by `claude setup-token`, or an `sk-ant-api…` Console key — the
|
|
56
|
+
// CLI reads either); locally an active `claude` login is enough, and the entry
|
|
57
|
+
// could even be omitted. ECR_EXPECTED_TOKEN_ENV (repo variable / workflow
|
|
58
|
+
// fallback) must equal this tokenEnv. `ecr setup-auth` walks you through it.
|
|
57
59
|
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// workflow to the comma-separated set of both env names.
|
|
63
|
-
// "auth": { "providers": {
|
|
64
|
-
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
65
|
-
// "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
66
|
-
// } }
|
|
67
|
-
// (openai oauth: tokenEnv holds the ACCESS token from an `opencode auth login`
|
|
68
|
-
// ChatGPT sign-in — `ecr setup-auth` extracts it. NEVER share the refresh
|
|
69
|
-
// token: it is single-use and dies on first rotation.)
|
|
60
|
+
// Other providers run through OpenCode:
|
|
61
|
+
// "api-key": tokenEnv names the env var holding a provider API key. In CI,
|
|
62
|
+
// store the key as a repo secret and pass it under that env var.
|
|
63
|
+
// "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" }
|
|
70
64
|
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// or rely on your local `claude` login. An auth entry is OPTIONAL (tokenEnv just
|
|
75
|
-
// names the credential env); `ecr setup-auth` walks you through it.
|
|
65
|
+
// ChatGPT/Codex subscription ("oauth"): tokenEnv holds the ACCESS token from
|
|
66
|
+
// an `opencode auth login` ChatGPT sign-in — `ecr setup-auth` extracts it.
|
|
67
|
+
// NEVER share the refresh token: it is single-use and dies on first rotation.
|
|
76
68
|
// "auth": { "providers": {
|
|
77
|
-
// "
|
|
69
|
+
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" }
|
|
78
70
|
// } }
|
|
79
71
|
//
|
|
80
|
-
// MIXING engines is supported: the engine is inferred per agent from its model,
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
72
|
+
// MIXING engines is supported: the engine is inferred per agent from its model,
|
|
73
|
+
// so this anthropic entry may coexist with an openai (or any other) OpenCode
|
|
74
|
+
// provider — an `anthropic/…` agent runs through the Claude Code CLI while an
|
|
75
|
+
// `openai/…` agent runs through OpenCode, in the SAME run:
|
|
84
76
|
// "auth": { "providers": {
|
|
85
|
-
// "anthropic": { "tokenEnv": "
|
|
77
|
+
// "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" },
|
|
86
78
|
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" }
|
|
87
79
|
// } }
|
|
88
80
|
"auth": {
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
"providers": {
|
|
82
|
+
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
|
|
83
|
+
}
|
|
92
84
|
},
|
|
93
85
|
|
|
94
86
|
// Stack-aware requalification (ROOT-ONLY; off by default). When on, `ecr ci` walks
|
package/templates/coordinator.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<!-- @ref LLP 0009#config-and-prompt-templates — pro tier pinned on purpose: consolidation quality over serial-tail latency -->
|
|
2
2
|
---
|
|
3
3
|
# The coordinator makes the final call — de-duping, re-judging severity, and
|
|
4
|
-
# deciding — so it runs on the
|
|
4
|
+
# deciding — so it runs on the Opus tier: consolidation quality matters more here
|
|
5
5
|
# than the small serial-tail latency it adds (no repo tools, one bounded pass).
|
|
6
6
|
# Override with a cheaper model if you'd rather trade decision quality for latency.
|
|
7
7
|
# @ref LLP 0009#config-and-prompt-templates [implements]
|
|
8
|
-
model:
|
|
8
|
+
model: anthropic/claude-opus-5
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# Coordinator — consolidation & decision
|