@expo/code-review-cli 0.5.2 → 0.7.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 CHANGED
@@ -110,7 +110,7 @@ is a ready example to adapt.
110
110
  | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
111
111
  | `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
112
112
  | `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
113
- | `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT sign-in and/or API keys), printing the `export` lines for your shell config. |
113
+ | `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT/Claude sign-in and/or API keys), printing the `export` lines for your shell config. |
114
114
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
115
115
  | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
116
116
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
@@ -218,16 +218,31 @@ your-monorepo/
218
218
  (e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
219
219
  root roster — a scope defining a same-id agent gets the root one, so a team can't
220
220
  shadow the enforced reviewer with a weaker version on its own subtree.
221
- - **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
222
- auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
223
- and routing globs for its own advisory review that only steers what the
224
- comment-only reviewer says about that PR. What a PR can **never** do is touch
225
- credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
226
- `ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
227
- against whatever ref is checked out. A `/review`-command workflow that checks out
228
- only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
229
- resolving config from the base ref on the auto path as well is on the
230
- [roadmap](./ROADMAP.md).
221
+ - **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
222
+ policy and reviewer configuration `config.jsonc`, `routing.jsonc`, prompts,
223
+ models, and the auth mapping load from the PR's immutable **base** commit,
224
+ materialized via the GitHub API. The PR head is untrusted data: it is
225
+ materialized separately (pinned to its immutable OID) purely as source content
226
+ to read and verify against. A PR editing rosters, prompts, or routing is
227
+ reviewed under the **previous** config; its changes activate after merge. If
228
+ the base commit can't be materialized, the run fails closed (one terminal
229
+ comment) it never falls back to the checkout. A scope config that is new in
230
+ a PR is reviewed with the root config until it merges.
231
+ - **The model runtime never sees PR-owned ambient config.** The head worktree the
232
+ agents read from is scrubbed of runtime configuration before the OpenCode
233
+ server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
234
+ `CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
235
+ A PR can't install a plugin, MCP server, instruction file, or `.env` into the
236
+ process that holds the model credential and the comment token. (Changes to
237
+ those files are still reviewed — their diffs are inlined in the prompt — but a
238
+ finding citing one can't be re-read during verification; that's the tradeoff.)
239
+ - **The scaffolded workflows check out only the base commit** with
240
+ `persist-credentials: false`; the CLI's own git fetches authenticate through
241
+ `gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
242
+ CLI enforces the trust model itself, so a custom workflow that checks out the
243
+ PR head still gets base-commit configuration. The temporary escape hatch
244
+ `ecr ci --unsafe-config-from-head` restores the old behavior with a loud
245
+ security warning and will be removed on a minor boundary.
231
246
 
232
247
  Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
233
248
  (the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
@@ -530,18 +545,36 @@ set in `config.auth` (credentials come from OpenCode):
530
545
  One caveat: OpenCode can't price alias models (they're config-declared), so
531
546
  pro passes report `$0` in the run log's cost column — token counts are
532
547
  correct, and the OpenAI project dashboard is the source of truth for spend.
533
- - **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
534
- `tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
535
- and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
536
- Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
537
- them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
538
- API key works.
548
+ - **Anthropic / Claude** — use `anthropic/...` model ids and every anthropic pass
549
+ runs through the **Claude Code CLI** (`claude -p --output-format json`), inferred
550
+ from the model. The credential is (in order) a `tokenEnv` you name, an ambient
551
+ `CLAUDE_CODE_OAUTH_TOKEN`, or your local `claude` login an `auth` entry is
552
+ entirely optional. Run `claude setup-token` for a Max/Team subscription token
553
+ (forwarded as `CLAUDE_CODE_OAUTH_TOKEN`) or point `tokenEnv` at an Anthropic
554
+ Console API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
555
+ either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
556
+ read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
557
+ `--strict-mcp-config`, `--permission-mode dontAsk`, and only the
558
+ `Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
559
+ The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
560
+ (only the configured credential is re-injected).
561
+ ```jsonc
562
+ // Optional — no anthropic entry at all falls back to your `claude` login.
563
+ "auth": { "providers": {
564
+ "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
565
+ } }
566
+ ```
539
567
  - **Another provider** — the current path is the `REVIEWER_MODEL`
540
568
  env override: `opencode auth login` once (pick the provider), then run with
541
569
  e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
542
- and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
543
- provider mixing beyond the alias mechanism above is on the
544
- [roadmap](./ROADMAP.md).)*
570
+ and uses your OpenCode login, so no `auth` block is needed.
571
+
572
+ Engines are inferred **per agent** from that agent's resolved model alone: an
573
+ `anthropic/…` agent runs through the Claude Code CLI while other agents run through
574
+ OpenCode — in the SAME run. So an anthropic model may coexist with an `openai` (or
575
+ any other) OpenCode provider, and each agent's `model` selects its engine.
576
+ `REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
577
+ engine), converging the whole run onto one engine.
545
578
 
546
579
  There is no shared fallback key; if a run fails for lack of credentials, authenticate
547
580
  a provider in OpenCode. `ecr doctor` diagnoses setup.
@@ -555,8 +588,11 @@ rediscovering one fixable thing, then reports N coverage gaps. So before any pas
555
588
  model, with nothing pointing at the credential. A truncated value, surrounding
556
589
  whitespace, or a token that can't work for the configured `auth.mode` is rejected
557
590
  by name.
558
- - **Configured model ids are checked against the running server**, so a typo or an id
559
- the provider doesn't have is reported once, up front, with the close matches.
591
+ - **Configured model ids for OpenCode-routed providers are checked against the running
592
+ server**, so a typo or an id the provider doesn't have is reported once, up front,
593
+ with the close matches. `anthropic/…` (Claude Code) model ids aren't checked up
594
+ front — Claude validates them per-request, so a typo there surfaces as a per-pass
595
+ error instead.
560
596
  - **`ecr doctor` reports the `opencode` version actually in use** and warns when a
561
597
  different one is first on your `PATH` — runs use the version this package pins.
562
598
 
@@ -1,8 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
2
+ import path from "node:path";
3
+ import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
3
4
  import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
4
- import { repoRoot, run } from "../core/exec.js";
5
- import { errorMessage } from "../core/util.js";
5
+ import { repoRoot, resolveTrustedTool, run } from "../core/exec.js";
6
+ import { errorMessage, publicFailureReason } from "../core/util.js";
6
7
  import { buildDiffLineIndex } from "../core/render.js";
7
8
  import { runReview } from "../core/review.js";
8
9
  import { GitHubPRSource } from "../sources/github-pr.js";
@@ -32,6 +33,15 @@ For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
32
33
  via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
33
34
  and non-blocking (a reviewer failure never fails the PR's checks).
34
35
 
36
+ Trust model: review policy and reviewer configuration (config.jsonc, routing,
37
+ prompts, models, auth mapping) load from the PR's immutable BASE commit,
38
+ materialized via the GitHub API — never from the PR head — so a PR cannot change
39
+ the reviewer that evaluates it; config changes activate after merge. The PR head
40
+ is materialized separately (pinned to its immutable OID, scrubbed of ambient
41
+ runtime config) purely as source content to read and verify against. If the
42
+ trusted base cannot be materialized, the run fails closed with one terminal
43
+ comment; it never falls back to the checkout.
44
+
35
45
  Monorepos: when .expo-code-review/routing.jsonc exists, ci fans out INTERNALLY —
36
46
  it assigns each changed file to exactly one scope (last-match-wins) and reviews
37
47
  each active scope over only its files, then renders one aggregated comment (or one
@@ -42,8 +52,17 @@ Options:
42
52
  --route Let the router pick relevant agents from the diff
43
53
  --scopes <a,b> Limit the fan-out to these named scopes (routing only)
44
54
  --config-dir <dir> Load the ROOT config.jsonc + routing.jsonc from <dir>
45
- instead of .expo-code-review/ (also ECR_CONFIG_DIR). Scope
46
- subtrees stay repo-root-relative.
55
+ instead of .expo-code-review/ (also ECR_CONFIG_DIR). A
56
+ RELATIVE dir resolves beneath the trusted base commit; an
57
+ ABSOLUTE dir is an explicit operator trust decision. Scope
58
+ subtrees always resolve beneath the trusted base commit.
59
+ --unsafe-config-from-head
60
+ COMPATIBILITY ESCAPE HATCH: load configuration from the
61
+ current checkout instead of the PR's trusted base commit.
62
+ This lets a same-repo PR change the reviewer (policy,
63
+ prompts, model, auth mapping) that evaluates itself.
64
+ Never scaffolded; prints a security warning; will be
65
+ removed on a scheduled minor boundary.
47
66
  --comment <mode> Override manifest comment mode: single | per-scope
48
67
  --force Manual override: review even if the trigger policy (label
49
68
  trigger / ai-review:skip) would skip. Break-glass and the
@@ -78,6 +97,7 @@ export async function ciCommand(argv = []) {
78
97
  // A maintainer's explicit `/review` (comment command or --force) is a manual
79
98
  // escape hatch that bypasses the trigger-policy gate only (see passesTriggerGate).
80
99
  const bypassTriggerGate = shouldBypassTriggerGate(argv);
100
+ const unsafeConfigFromHead = argv.includes("--unsafe-config-from-head");
81
101
  const root = await repoRoot();
82
102
  if (root && root !== process.cwd()) {
83
103
  process.chdir(root);
@@ -89,35 +109,120 @@ export async function ciCommand(argv = []) {
89
109
  process.stderr.write("CI reviewer: could not determine repository or PR number from the environment. Skipping.\n");
90
110
  return;
91
111
  }
92
- // A malformed manifest is a loud, non-blocking error (never a silent fallback).
93
- let manifest;
94
- try {
95
- manifest = await loadRoutingManifest(cwd, { configDir });
112
+ // ONE source for the whole run: metadata (incl. immutable OIDs), the diff, and
113
+ // the PR-head read root are each fetched once and shared across scopes.
114
+ const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
115
+ const source = memoizeSource(ghSource);
116
+ // Trusted configuration root: review policy and reviewer config load from the
117
+ // PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
118
+ // when the base can't be materialized, post the one terminal comment and stop —
119
+ // silently reading the checkout would let a head checkout smuggle config in.
120
+ let trustedRoot = null;
121
+ let configRoot = cwd;
122
+ if (unsafeConfigFromHead) {
123
+ process.stderr.write("CI reviewer: ⚠ SECURITY — --unsafe-config-from-head is set: reviewer configuration " +
124
+ "(policy, prompts, models, auth mapping) is being loaded from the current checkout, so " +
125
+ "a same-repository PR can change the reviewer that evaluates it. This escape hatch will " +
126
+ "be removed in a future minor release.\n");
96
127
  }
97
- catch (error) {
98
- process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
99
- return;
128
+ else {
129
+ try {
130
+ trustedRoot = await ghSource.prepareTrustedConfigRootAsync();
131
+ configRoot = trustedRoot.dir;
132
+ }
133
+ catch (error) {
134
+ const reason = errorMessage(error);
135
+ process.stderr.write(`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
136
+ `(failing closed, not reviewing): ${reason}\n`);
137
+ await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${publicFailureReason(error)}). ` +
138
+ `This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`);
139
+ return;
140
+ }
100
141
  }
101
- if (manifest == null) {
102
- await runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir);
103
- return;
142
+ try {
143
+ // A malformed manifest is a loud, non-blocking error (never a silent fallback).
144
+ let manifest;
145
+ try {
146
+ manifest = await loadRoutingManifest(configRoot, { configDir });
147
+ }
148
+ catch (error) {
149
+ process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
150
+ return;
151
+ }
152
+ if (manifest == null) {
153
+ await runLegacyCi(source, repo, prNumber, cwd, configRoot, {
154
+ agents,
155
+ route,
156
+ bypassTriggerGate,
157
+ configDir,
158
+ });
159
+ return;
160
+ }
161
+ try {
162
+ await runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, {
163
+ agents,
164
+ route,
165
+ scopesFilter,
166
+ commentOverride,
167
+ bypassTriggerGate,
168
+ configDir,
169
+ });
170
+ }
171
+ catch (error) {
172
+ // Fan-out failures stay non-blocking (single-writer property is the point).
173
+ process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
174
+ }
175
+ }
176
+ finally {
177
+ await source.dispose();
178
+ await trustedRoot?.cleanup();
104
179
  }
180
+ }
181
+ /**
182
+ * The one terminal "this PR was NOT reviewed" comment for failures that happen
183
+ * before any configuration is loaded (trusted-root materialization). Uses the
184
+ * DEFAULT comment tag/break-glass marker because the config that could customize
185
+ * them is exactly what failed to load; a repo with a custom tag gets a fresh
186
+ * comment rather than an upsert, which is the acceptable degraded case.
187
+ */
188
+ async function postTerminalFailureNote(repo, prNumber, cwd, reason) {
105
189
  try {
106
- await runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir);
190
+ const reporter = new GitHubReporter({
191
+ prNumber,
192
+ repo,
193
+ commentTag: "expo-ai-code-reviewer",
194
+ breakGlassMarker: "/skip-review",
195
+ cwd,
196
+ });
197
+ await reporter.report({
198
+ decision: "approve_with_comments",
199
+ findings: [],
200
+ summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed: ${reason}.`,
201
+ incomplete: [],
202
+ couldNotComplete: true,
203
+ });
107
204
  }
108
- catch (error) {
109
- // Fan-out failures stay non-blocking (single-writer property is the point).
110
- process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
205
+ catch (postError) {
206
+ process.stderr.write(`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`);
111
207
  }
112
208
  }
209
+ /**
210
+ * Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
211
+ * (temporary, removed-on-exit) trusted config root — the workflow uploads
212
+ * `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
213
+ */
214
+ function workspaceRunsDir(cwd) {
215
+ return path.join(cwd, CONFIG_DIRNAME, ".runs");
216
+ }
113
217
  /**
114
218
  * The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
115
219
  * routing.jsonc the CLI behaves exactly as before (backcompat invariant).
116
220
  */
117
- async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir) {
221
+ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
222
+ const { agents, route, bypassTriggerGate, configDir } = options;
118
223
  let config;
119
224
  try {
120
- config = await loadReviewConfig(cwd, { configDir });
225
+ config = await loadReviewConfig(configRoot, { configDir });
121
226
  }
122
227
  catch (error) {
123
228
  process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
@@ -158,11 +263,12 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
158
263
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
159
264
  }
160
265
  try {
161
- const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd }), {
266
+ const review = await runReview(source, {
162
267
  config,
163
268
  mode: "ci",
164
269
  agents,
165
270
  route,
271
+ runsDir: workspaceRunsDir(cwd),
166
272
  onProgress: (message) => process.stderr.write(`${message}\n`),
167
273
  });
168
274
  await reporter.report(review);
@@ -179,7 +285,7 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
179
285
  await reporter.report({
180
286
  decision: "approve_with_comments",
181
287
  findings: [],
182
- summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${reason}`,
288
+ summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
183
289
  incomplete: [],
184
290
  });
185
291
  }
@@ -198,10 +304,12 @@ function failureReview(scopeName, reason) {
198
304
  };
199
305
  }
200
306
  /** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
201
- async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir) {
307
+ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, options) {
308
+ const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
202
309
  // The root config + manifest follow the override; scope configs stay
203
- // repo-root-relative (loadScopeConfig reads <root>/<scope.config>/.expo-code-review).
204
- const rootConfig = await loadReviewConfig(cwd, { configDir });
310
+ // relative to the TRUSTED root (loadScopeConfig reads
311
+ // <configRoot>/<scope.config>/.expo-code-review).
312
+ const rootConfig = await loadReviewConfig(configRoot, { configDir });
205
313
  // The root/aggregate marker is the ACTUAL root-owned comment tag so the
206
314
  // pre-routing comment and its dismissal state upsert in place, not stranded
207
315
  // under a new marker (risk 8/9). manifest.defaults.commentTag is the
@@ -240,7 +348,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
240
348
  if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), rootConfig.review, bypassTriggerGate)) {
241
349
  return;
242
350
  }
243
- const source = memoizeSource(new GitHubPRSource({ prNumber, repo, cwd }));
244
351
  const changed = await source.getChangedFiles();
245
352
  const resolution = resolveScopes(manifest, changed.map((file) => file.path));
246
353
  process.stderr.write("CI reviewer: scope ownership —\n");
@@ -276,7 +383,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
276
383
  if (await bgReporter.checkBreakGlass()) {
277
384
  process.stderr.write(`CI reviewer: ${rootConfig.breakGlassMarker} detected; skipping.\n`);
278
385
  await bgReporter.postSkipNote();
279
- await source.dispose();
280
386
  return;
281
387
  }
282
388
  }
@@ -284,17 +390,17 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
284
390
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
285
391
  }
286
392
  // Build ONE link context for all scopes (rate-limit hygiene): diff lines from the
287
- // already-fetched changed files, base SHA via a single `gh pr view`.
393
+ // already-fetched changed files, base OID from the memoized PR metadata (the same
394
+ // immutable OID the trusted config root was materialized from).
288
395
  const link = {
289
396
  repo,
290
397
  prNumber,
291
398
  diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
292
399
  };
293
400
  try {
294
- const { stdout } = await run("gh", ["pr", "view", String(prNumber), "--repo", repo, "--json", "baseRefOid"], { cwd });
295
- const oid = JSON.parse(stdout).baseRefOid;
296
- if (oid) {
297
- link.baseSha = oid;
401
+ const { baseOid } = await source.getMetadata();
402
+ if (baseOid) {
403
+ link.baseSha = baseOid;
298
404
  }
299
405
  }
300
406
  catch {
@@ -317,7 +423,18 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
317
423
  const isDefault = scope.configDir === ".";
318
424
  let review;
319
425
  try {
320
- const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
426
+ // A scope whose config dir doesn't exist at the TRUSTED base commit is a
427
+ // scope this PR introduces: review it with the root config rather than
428
+ // failing the run on exactly that PR. The scope's own config (PR-owned,
429
+ // untrusted for this run) activates once it merges.
430
+ let effectiveScopeDef = scopeDef;
431
+ if (!hasScopeConfig(configRoot, scopeDef)) {
432
+ process.stderr.write(`CI reviewer: [${scope.name}] no config at "${scopeDef.config}" in the PR's base ` +
433
+ `commit (new in this PR?); reviewing with the root config — the scope's config ` +
434
+ `takes effect after merge.\n`);
435
+ effectiveScopeDef = { ...scopeDef, config: "." };
436
+ }
437
+ const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
321
438
  review = await runReview(source, {
322
439
  config,
323
440
  mode: "ci",
@@ -325,17 +442,16 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
325
442
  route,
326
443
  includePaths: scope.files,
327
444
  passesBudgetMs: budget,
445
+ runsDir: workspaceRunsDir(cwd),
328
446
  onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
329
447
  });
330
448
  }
331
449
  catch (error) {
332
- const reason = errorMessage(error);
333
- process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${reason}\n`);
334
- review = failureReview(scope.name, reason);
450
+ process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${errorMessage(error)}\n`);
451
+ review = failureReview(scope.name, publicFailureReason(error));
335
452
  }
336
453
  results.push({ scope: scope.name, isDefault, review });
337
454
  }
338
- await source.dispose();
339
455
  const reporterFor = (tag, withLink = false) => new GitHubReporter({
340
456
  prNumber,
341
457
  repo,
@@ -408,7 +524,8 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
408
524
  */
409
525
  async function fetchPrLabels(repo, prNumber, cwd) {
410
526
  try {
411
- const { stdout } = await run("gh", [
527
+ const gh = await resolveTrustedTool("gh");
528
+ const { stdout } = await run(gh, [
412
529
  "pr",
413
530
  "view",
414
531
  String(prNumber),