@expo/code-review-cli 0.11.0 → 0.12.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
@@ -71,6 +71,9 @@ ecr review
71
71
  ecr review --pr 123
72
72
  # …and post it as the PR comment
73
73
  ecr review --pr 123 --post
74
+ # Preview once, save the exact result, and post it later without another model run
75
+ ecr review --repo owner/repo --pr 123 --save-review --json
76
+ ecr post-review --artifact .expo-code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
74
77
  ```
75
78
 
76
79
  Options (most to least common):
@@ -79,6 +82,7 @@ Options (most to least common):
79
82
  | --- | --- |
80
83
  | `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
81
84
  | `--post` | With `--pr`, also post the result as the PR comment (needs `gh` auth). Omit to preview only; re-run with `--post` to publish. |
85
+ | `--save-review` | With explicit `--repo` + `--pr`, save the exact preview as a private postable artifact. Mutually exclusive with `--post`. |
82
86
  | `--staged` | Review only staged changes (index vs HEAD; not combinable with `--base`/`--head`). |
83
87
  | `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
84
88
  | `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
@@ -111,6 +115,7 @@ is a ready example to adapt.
111
115
  | `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. |
112
116
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
113
117
  | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
118
+ | `ecr post-review --artifact <path> --repo <owner/repo> --pr <n>` | Post an exact saved PR preview without re-running models; refuses target, head, config, or break-glass drift. |
114
119
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
115
120
  | `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
116
121
  | `ecr feedback [--repo <owner/repo>]` | Report which findings PR authors pushed back on, across history. See below. |
@@ -168,6 +173,83 @@ Two run points:
168
173
 
169
174
  ---
170
175
 
176
+ ## Providing context and research capabilities
177
+
178
+ `@expo/code-review-cli` includes `review-research-mcp` and can run it as a trusted
179
+ host-side prepass. This is deliberately not an agent-visible MCP: Claude Code
180
+ keeps `--safe-mode`, `--strict-mcp-config`, path-scoped read tools, and its
181
+ execution/network/write deny list. Before model startup, ECR extracts only short
182
+ API identifiers from added native code, asks the documentation MCP for bounded evidence,
183
+ and appends the results to reviewer and cross-file prompts as explicitly untrusted
184
+ reference text.
185
+
186
+ Enable it only in the root config, which CI loads from the PR's trusted base:
187
+
188
+ ```jsonc
189
+ {
190
+ "research": {
191
+ "enabled": true,
192
+ "indexPath": "/opt/expo-review/docs-index.json",
193
+ "maxQueries": 8,
194
+ "resultsPerQuery": 2,
195
+ "timeoutMs": 15000
196
+ }
197
+ }
198
+ ```
199
+
200
+ ECR resolves the MCP entry point inside its own installed package and starts it
201
+ with the current absolute Node executable, so a PR-owned `PATH` entry cannot replace
202
+ either component. Build the index in a separate networked job and mount it read-only
203
+ in review jobs. The child runs from the OS temp directory with a minimal environment, a 2 MB output
204
+ cap, and a hard timeout. It receives no model credentials, source snippets, string
205
+ literals, comments, removed lines, or repository paths—only normalized identifiers,
206
+ the platform, and named provider filters. MCP failures are visible in the job log
207
+ but fail open to an ordinary review; they never skip or weaken review passes.
208
+
209
+ Research is root-only in routed monorepos because it starts a host process; scope
210
+ configs cannot change its index path or limits. Result-cache reuse is disabled while
211
+ research is enabled because an index can change at the same mounted path. For CI,
212
+ pin the ECR package/Node version and verify a signed index checksum before invoking
213
+ ECR. A simpler initial deployment may build the index in an earlier, secretless
214
+ workflow step using the same pinned published package, with no PR code executed and
215
+ failure allowed so review can continue without research. Keep `update` out of the
216
+ credential-bearing `ecr ci` process itself; the review pipeline always starts `serve`.
217
+
218
+ The built-in query router recognizes Apple/Android APIs plus Media3, Glide, OkHttp,
219
+ Kotlin coroutines, Gradle/AGP, Swift concurrency/evolution, platform release/API
220
+ availability, Expo, React Native, Reanimated, Gesture Handler, Screens, and Worklets.
221
+ Queries are short exact symbols plus at most one useful member or behavior term. For
222
+ example, `CameraView barcodeScannerSettings` is useful; a source snippet, import path,
223
+ or natural-language question is not. The MCP publishes the same guidance in its tool
224
+ metadata for direct clients. An empty result stays empty; it is not replaced with a
225
+ loose semantic guess.
226
+
227
+ For a query routed to the `expo` provider, `serve` POSTs the already-sanitized query
228
+ directly to Expo's public Algolia search endpoint and prefers the returned canonical
229
+ `docs.expo.dev` hits.
230
+ The endpoint, application id, and browser-visible search-only key are fixed in the
231
+ package; redirects are rejected; response size, timeout, hit count, and returned URL
232
+ host are bounded. Algolia receives the query text; a failed request falls back to the
233
+ mounted local index. The local index is still required for that fallback and every
234
+ other provider. Direct MCP clients can omit the `expo` provider; ECR installations
235
+ requiring a fully offline review should leave research disabled until provider
236
+ selection becomes installation-configurable.
237
+
238
+ The MCP and its trusted updater ship with ECR. From this repository, build an index
239
+ with `bun run research:update`; an installed package exposes the equivalent
240
+ `review-research-mcp update`. The built-in seed catalog lives in
241
+ `research/sources.json`, while the generated `research/data/` directory is ignored
242
+ and is not published. `seedUrls` are deterministic starting pages for the bounded
243
+ crawler; ordinary link extraction, parsing, indexing, and searching use no LLM.
244
+ Installation-specific provider configuration is intentionally
245
+ deferred: when added, it should follow the trusted root-config model used for agents
246
+ without permitting PR-controlled URLs, commands, or executable parsers. Expo skills
247
+ are complementary, not another search corpus: their pinned procedural guidance can
248
+ later be supplied to review agents as separately labeled trusted context, while
249
+ documentation search continues to return citable API evidence. Dynamic skills or
250
+ instructions retrieved from documentation must never become executable reviewer
251
+ instructions.
252
+
171
253
  ## Monorepos (routing manifest)
172
254
 
173
255
  A monorepo can route different subtrees to different reviewer rosters from a single
@@ -250,10 +332,11 @@ your-monorepo/
250
332
 
251
333
  ### Security
252
334
 
253
- - **auth is locked to the root.** `tokenEnv` (which env var becomes the model
335
+ - **auth and research are locked to the root.** `tokenEnv` (which env var becomes the model
254
336
  credential) is honored in exactly one place: the root `config.jsonc` or
255
- `routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`
256
- **fails to parse** (Zod-level rejection), and the CI guard step independently
337
+ `routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`/`research`
338
+ **fails to parse** (Zod-level rejection). A scope declaring `research` also fails,
339
+ so PR-controlled routing cannot select a different host index. The CI guard step independently
257
340
  sweeps every `.expo-code-review/config.jsonc`/`routing.jsonc` repo-wide and refuses
258
341
  to run unless `tokenEnv` appears exactly once, in a root-owned file, equal to
259
342
  `ECR_EXPECTED_TOKEN_ENV`. A routing manifest can never widen exposure — globs only
@@ -661,6 +744,12 @@ that the PR comment embeds for machine consumers. The same totals are printed as
661
744
  one-line summary to the terminal / CI job log at the end of each run, so cache reuse
662
745
  is visible even in CI (where the run log is ephemeral).
663
746
 
747
+ `ecr review --save-review` additionally writes a versioned artifact under
748
+ `.expo-code-review/.runs/deferred/` with owner-only permissions. It contains the
749
+ verified final review and bounded feedback metadata, but no credential. `ecr
750
+ post-review` schema-validates it and refuses to post if its explicit repo/PR, live
751
+ head commit, or local comment-policy fingerprint no longer matches.
752
+
664
753
  </details>
665
754
 
666
755
  <a id="other-providers"></a>
package/build/cli.js CHANGED
@@ -5,6 +5,7 @@ import { dismissCommand } from "./commands/dismiss.js";
5
5
  import { doctorCommand } from "./commands/doctor.js";
6
6
  import { feedbackCommand } from "./commands/feedback.js";
7
7
  import { initCommand } from "./commands/init.js";
8
+ import { postReviewCommand } from "./commands/post-review.js";
8
9
  import { refCheckCommand } from "./commands/ref-check.js";
9
10
  import { reviewCommand } from "./commands/review.js";
10
11
  import { setupAuthCommand } from "./commands/setup-auth.js";
@@ -13,6 +14,7 @@ const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
13
14
 
14
15
  Usage:
15
16
  ecr review [options] Review local changes (default). See \`ecr review --help\`.
17
+ ecr post-review --artifact <path> --repo <owner/repo> --pr <n> Post an exact saved preview.
16
18
  ecr ci Review the current PR and post a comment (GitHub Actions).
17
19
  ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
18
20
  ecr undismiss --pr <n> <id...> Restore a dismissed finding.
@@ -43,6 +45,9 @@ async function main() {
43
45
  case "review":
44
46
  await reviewCommand(rest);
45
47
  break;
48
+ case "post-review":
49
+ await postReviewCommand(rest);
50
+ break;
46
51
  case "ci":
47
52
  await ciCommand(rest);
48
53
  break;
@@ -463,7 +463,10 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
463
463
  // Dynamic stack context and model-backed reply adjudication have inputs outside
464
464
  // the scoped diff. Keep those paths fresh until their inputs join the cache key.
465
465
  // A maintainer's explicit /review is also always a real rerun.
466
- const cacheAllowed = !bypassTriggerGate && !stack && !feedback && metadata !== undefined;
466
+ // Research output depends on the mounted index contents, not merely its configured
467
+ // path. Until a signed index digest joins the cache key, a researched review must
468
+ // run fresh rather than reuse evidence from an older artifact at the same path.
469
+ const cacheAllowed = !bypassTriggerGate && !stack && !feedback && !config.research.enabled && metadata !== undefined;
467
470
  let inputHash;
468
471
  try {
469
472
  if (cacheAllowed) {
@@ -701,6 +704,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
701
704
  const cacheAllowed = !bypassTriggerGate &&
702
705
  !stackWalk &&
703
706
  !feedbackNeedsRunSeam(rootConfig.feedback) &&
707
+ !rootConfig.research.enabled &&
704
708
  metadata !== undefined;
705
709
  let cacheReadRoot;
706
710
  if (cacheAllowed) {
@@ -0,0 +1,147 @@
1
+ // @ref LLP 0007#deferred-review-posting [implements] — no-model post of an exact, target-bound preview artifact
2
+ import path from "node:path";
3
+ import { loadReviewConfig } from "../config/load.js";
4
+ import { assertDeferredReviewCurrent, readDeferredReviewArtifact, reviewPostingConfigFingerprint, } from "../core/deferred-review.js";
5
+ import { repoRoot } from "../core/exec.js";
6
+ import { errorMessage } from "../core/util.js";
7
+ import { GitHubReporter } from "../reporters/github.js";
8
+ import { GitHubPRSource, isCommitOid } from "../sources/github-pr.js";
9
+ const USAGE = `ecr post-review — post an exact saved PR review without re-running models
10
+
11
+ Usage:
12
+ ecr post-review --artifact <path> --repo <owner/repo> --pr <n>
13
+
14
+ The artifact must come from \`ecr review --save-review --repo <owner/repo> --pr <n>\`.
15
+ Before writing to GitHub, this command verifies the explicit repo/PR, the live PR
16
+ head commit, the local posting policy, and the PR's break-glass marker.
17
+ `;
18
+ function requireValue(flag, value) {
19
+ if (value === undefined || value.startsWith("--")) {
20
+ throw new Error(`${flag} requires a value`);
21
+ }
22
+ return value;
23
+ }
24
+ export function parsePostReviewArgs(argv) {
25
+ const args = { help: false };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const arg = argv[i];
28
+ switch (arg) {
29
+ case "--artifact":
30
+ args.artifact = requireValue(arg, argv[++i]);
31
+ break;
32
+ case "--repo":
33
+ args.repo = requireValue(arg, argv[++i]);
34
+ break;
35
+ case "--pr": {
36
+ const value = requireValue(arg, argv[++i]);
37
+ const number = Number(value);
38
+ if (!Number.isSafeInteger(number) || number <= 0) {
39
+ throw new Error(`--pr requires a positive safe integer (got "${value}")`);
40
+ }
41
+ args.pr = number;
42
+ break;
43
+ }
44
+ case "-h":
45
+ case "--help":
46
+ args.help = true;
47
+ break;
48
+ default:
49
+ throw new Error(`Unknown argument: ${arg}`);
50
+ }
51
+ }
52
+ return args;
53
+ }
54
+ function validatePostReviewArgs(args) {
55
+ if (!args.artifact || !args.repo || args.pr == null) {
56
+ throw new Error("--artifact, --repo, and --pr are all required");
57
+ }
58
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(args.repo)) {
59
+ throw new Error(`--repo requires owner/repo (got "${args.repo}")`);
60
+ }
61
+ }
62
+ /** Break-glass errors fail closed: no report call occurs unless the check says false. */
63
+ export async function publishDeferredReview(poster, artifact, assertReadyToPost = async () => { }) {
64
+ if (await poster.checkBreakGlass()) {
65
+ return "break-glass";
66
+ }
67
+ await assertReadyToPost();
68
+ await poster.report(artifact.review, artifact.feedback);
69
+ return "posted";
70
+ }
71
+ export async function postReviewCommand(argv) {
72
+ let args;
73
+ try {
74
+ args = parsePostReviewArgs(argv);
75
+ if (args.help) {
76
+ process.stdout.write(USAGE);
77
+ return;
78
+ }
79
+ validatePostReviewArgs(args);
80
+ }
81
+ catch (error) {
82
+ process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
83
+ process.exitCode = 2;
84
+ return;
85
+ }
86
+ // Resolve before chdir so a relative artifact path keeps the caller's meaning.
87
+ const artifactPath = path.resolve(process.cwd(), args.artifact);
88
+ try {
89
+ const root = await repoRoot();
90
+ if (root && root !== process.cwd()) {
91
+ process.chdir(root);
92
+ }
93
+ const cwd = process.cwd();
94
+ const [artifact, config] = await Promise.all([
95
+ readDeferredReviewArtifact(artifactPath),
96
+ loadReviewConfig(cwd),
97
+ ]);
98
+ const source = new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd });
99
+ const headSha = (await source.getMetadata()).headOid;
100
+ if (!isCommitOid(headSha)) {
101
+ throw new Error(`could not resolve a full head commit for ${args.repo}#${args.pr}`);
102
+ }
103
+ assertDeferredReviewCurrent(artifact, {
104
+ repo: args.repo,
105
+ pr: args.pr,
106
+ headSha,
107
+ configFingerprint: reviewPostingConfigFingerprint(config),
108
+ });
109
+ const reporter = new GitHubReporter({
110
+ prNumber: args.pr,
111
+ repo: args.repo,
112
+ commentTag: config.commentTag,
113
+ breakGlassMarker: config.breakGlassMarker,
114
+ cwd,
115
+ feedback: config.feedback,
116
+ headSha,
117
+ });
118
+ const result = await publishDeferredReview(reporter, artifact, async () => {
119
+ // Re-fetch rather than reusing GitHubPRSource's memoized metadata, and reload
120
+ // config after the break-glass API call. This narrows the unavoidable remote
121
+ // TOCTOU window and prevents a head/policy change during setup from reaching
122
+ // the reporter.
123
+ const [latestMetadata, latestConfig] = await Promise.all([
124
+ new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd }).getMetadata(),
125
+ loadReviewConfig(cwd),
126
+ ]);
127
+ if (!isCommitOid(latestMetadata.headOid)) {
128
+ throw new Error(`could not re-resolve a full head commit for ${args.repo}#${args.pr}`);
129
+ }
130
+ assertDeferredReviewCurrent(artifact, {
131
+ repo: args.repo,
132
+ pr: args.pr,
133
+ headSha: latestMetadata.headOid,
134
+ configFingerprint: reviewPostingConfigFingerprint(latestConfig),
135
+ });
136
+ });
137
+ if (result === "break-glass") {
138
+ process.stderr.write(`Not posting: ${config.breakGlassMarker} is set on ${args.repo}#${args.pr}.\n`);
139
+ return;
140
+ }
141
+ process.stderr.write(`Posted saved review to ${args.repo}#${args.pr}.\n`);
142
+ }
143
+ catch (error) {
144
+ process.stderr.write(`Saved review was not posted: ${errorMessage(error)}\n`);
145
+ process.exitCode = 2;
146
+ }
147
+ }
@@ -4,10 +4,11 @@ import { loadRoutingManifest, resolveScopes, scopedCommentTag } from "../config/
4
4
  import { repoRoot, resolveRepo } from "../core/exec.js";
5
5
  import { errorMessage } from "../core/util.js";
6
6
  import { readContextFile } from "../core/context-file.js";
7
+ import { writeDeferredReviewArtifact } from "../core/deferred-review.js";
7
8
  import { feedbackNeedsRunSeam } from "../core/adjudicate.js";
8
9
  import { runReview } from "../core/review.js";
9
10
  import { LocalGitSource } from "../sources/local-git.js";
10
- import { GitHubPRSource } from "../sources/github-pr.js";
11
+ import { GitHubPRSource, isCommitOid } from "../sources/github-pr.js";
11
12
  import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
12
13
  import { TerminalReporter } from "../reporters/terminal.js";
13
14
  import { GitHubReporter } from "../reporters/github.js";
@@ -15,7 +16,8 @@ const USAGE = `ecr review — AI code review, printed to your terminal
15
16
 
16
17
  Usage:
17
18
  ecr review [options] review local changes
18
- ecr review --pr <n> [--post] review a GitHub PR by number
19
+ ecr review --pr <n> [--post | --save-review]
20
+ review a GitHub PR by number
19
21
 
20
22
  Source (pick one):
21
23
  (default) diff the working tree against the merge-base
@@ -31,6 +33,8 @@ Options:
31
33
  --post with --pr: also post the result as the PR comment (needs
32
34
  \`gh\` auth). Omit to only preview here; re-run with --post
33
35
  to publish.
36
+ --save-review with explicit --repo + --pr: save this exact preview as a
37
+ postable artifact for a later \`ecr post-review\` command.
34
38
  --agents <a,b> run only these agents (comma-separated ids); default: all
35
39
  --route let the router pick relevant agents from the diff
36
40
  --scope <name> review only this routing scope (needs a routing.jsonc);
@@ -60,10 +64,11 @@ function requireValue(flag, value) {
60
64
  }
61
65
  return value;
62
66
  }
63
- function parseArgs(argv) {
67
+ export function parseReviewArgs(argv) {
64
68
  const args = {
65
69
  staged: false,
66
70
  post: false,
71
+ saveReview: false,
67
72
  route: false,
68
73
  stackAware: false,
69
74
  json: false,
@@ -85,8 +90,8 @@ function parseArgs(argv) {
85
90
  case "--pr": {
86
91
  const value = requireValue(arg, argv[++i]);
87
92
  const number = Number(value);
88
- if (!Number.isInteger(number) || number <= 0) {
89
- throw new Error(`--pr requires a positive PR number (got "${value}")`);
93
+ if (!Number.isSafeInteger(number) || number <= 0) {
94
+ throw new Error(`--pr requires a positive safe integer (got "${value}")`);
90
95
  }
91
96
  args.pr = number;
92
97
  break;
@@ -97,6 +102,9 @@ function parseArgs(argv) {
97
102
  case "--post":
98
103
  args.post = true;
99
104
  break;
105
+ case "--save-review":
106
+ args.saveReview = true;
107
+ break;
100
108
  case "--agents":
101
109
  args.agents = requireValue(arg, argv[++i])
102
110
  .split(",")
@@ -137,7 +145,7 @@ function parseArgs(argv) {
137
145
  export async function reviewCommand(argv) {
138
146
  let args;
139
147
  try {
140
- args = parseArgs(argv);
148
+ args = parseReviewArgs(argv);
141
149
  }
142
150
  catch (error) {
143
151
  process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
@@ -149,7 +157,7 @@ export async function reviewCommand(argv) {
149
157
  return;
150
158
  }
151
159
  try {
152
- validateArgs(args);
160
+ validateReviewArgs(args);
153
161
  }
154
162
  catch (error) {
155
163
  process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
@@ -292,11 +300,13 @@ export async function reviewCommand(argv) {
292
300
  }
293
301
  const config = await loadReviewConfig(cwd, { configDir: args.configDir });
294
302
  const source = makeSource();
295
- // Build the PR reporter up front when posting, so adjudicate mode can judge the
303
+ // Build the PR reporter up front when posting OR saving a postable preview, so
304
+ // adjudicate mode can judge the
296
305
  // PR's replies against the source before the result is rendered. Feedback only
297
306
  // has replies to match when reviewing a PR and posting (the terminal preview never
298
- // renders annotations), so it is wired only on the --post --pr path.
299
- const { repo: postRepo, error: postRepoError } = await resolvePostRepo(args, cwd);
307
+ // renders annotations), so it is wired only when a later/current post is possible.
308
+ const { repo: postRepo, error: postRepoError } = await resolvePostRepo({ ...args, post: args.post || args.saveReview }, cwd);
309
+ const headSha = postRepo != null && args.pr != null ? await reviewedHeadSha(source) : undefined;
300
310
  const reporter = postRepo != null && args.pr != null
301
311
  ? new GitHubReporter({
302
312
  prNumber: args.pr,
@@ -305,7 +315,7 @@ export async function reviewCommand(argv) {
305
315
  breakGlassMarker: config.breakGlassMarker,
306
316
  cwd,
307
317
  feedback: config.feedback,
308
- headSha: await reviewedHeadSha(source),
318
+ headSha,
309
319
  })
310
320
  : null;
311
321
  const review = await runReview(source, {
@@ -325,6 +335,19 @@ export async function reviewCommand(argv) {
325
335
  });
326
336
  // Always print the result here first.
327
337
  await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
338
+ if (args.saveReview) {
339
+ if (postRepo == null || args.pr == null || !isCommitOid(headSha)) {
340
+ throw new Error(`could not save a postable review: ${postRepoError ? errorMessage(postRepoError) : "the PR head commit could not be resolved"}`);
341
+ }
342
+ const artifactPath = await writeDeferredReviewArtifact(config, {
343
+ repo: postRepo,
344
+ pr: args.pr,
345
+ headSha,
346
+ review,
347
+ feedback: review.feedback,
348
+ });
349
+ process.stderr.write(`\nSaved postable review artifact: ${artifactPath}\n`);
350
+ }
328
351
  // Then, only if asked, publish the same result to the PR.
329
352
  if (args.pr != null && args.post) {
330
353
  if (reporter) {
@@ -403,12 +426,21 @@ export async function resolvePostRepo(args, cwd, resolve = resolveRepo) {
403
426
  }
404
427
  // @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — mutually exclusive flags rejected outright, never silently ignored
405
428
  /** Reject flag combinations that don't make sense together. */
406
- function validateArgs(args) {
429
+ export function validateReviewArgs(args) {
407
430
  if (args.pr != null && (args.base || args.head || args.staged)) {
408
431
  throw new Error("--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.");
409
432
  }
410
- if (args.pr == null && (args.repo || args.post)) {
411
- throw new Error("--repo/--post only apply together with --pr.");
433
+ if (args.pr == null && (args.repo || args.post || args.saveReview)) {
434
+ throw new Error("--repo/--post/--save-review only apply together with --pr.");
435
+ }
436
+ if (args.saveReview && !args.repo) {
437
+ throw new Error("--save-review requires explicit --repo owner/repo.");
438
+ }
439
+ if (args.saveReview && args.post) {
440
+ throw new Error("--save-review and --post are mutually exclusive.");
441
+ }
442
+ if (args.saveReview && (args.scope || args.configDir)) {
443
+ throw new Error("--save-review does not support --scope or --config-dir.");
412
444
  }
413
445
  // Same rule as --repo/--post: the stack walk needs a PR to walk from, so a bare
414
446
  // --stack-aware would be silently ignored — reject it instead.
@@ -26,6 +26,13 @@ const FEEDBACK_CONFIG_DEFAULTS = {
26
26
  protectedCategories: ["secrets", "security"],
27
27
  maxAdjudications: 10,
28
28
  };
29
+ /** Research defaults for a scope load (where `research` is schema-rejected). */
30
+ const RESEARCH_CONFIG_DEFAULTS = {
31
+ enabled: false,
32
+ maxQueries: 8,
33
+ resultsPerQuery: 2,
34
+ timeoutMs: 15_000,
35
+ };
29
36
  /** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
30
37
  const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
31
38
  export function configDirFor(repoRoot) {
@@ -136,6 +143,9 @@ async function loadConfigDir(dir, schema) {
136
143
  policy: parsed.policy,
137
144
  chunk: parsed.chunk,
138
145
  noise: parsed.noise,
146
+ // Root-only: scope schemas reject research configuration, so an untrusted
147
+ // subtree cannot select the index or alter the network-facing runtime.
148
+ research: parsed.research ?? RESEARCH_CONFIG_DEFAULTS,
139
149
  // parsed.breakGlass/auth are always present for the root schema (defaults) and
140
150
  // absent for the scope schema; loadScopeConfig overrides both afterwards.
141
151
  breakGlassMarker: parsed.breakGlass?.marker ?? "/skip-review",
@@ -286,6 +296,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
286
296
  // run the default policy instead of the repo's real one.
287
297
  stack: rootConfig.stack,
288
298
  feedback: rootConfig.feedback,
299
+ research: rootConfig.research,
289
300
  scopeName: scope.name,
290
301
  };
291
302
  }
@@ -58,6 +58,33 @@ export const ReviewConfigSchema = z.object({
58
58
  additionalMarkers: z.array(z.string()).default([]),
59
59
  })
60
60
  .default({ additionalIgnores: [], additionalMarkers: [] }),
61
+ research: z
62
+ .object({
63
+ enabled: z.boolean().default(false),
64
+ indexPath: z
65
+ .string()
66
+ .min(1)
67
+ .refine((value) => path.isAbsolute(value), "research.indexPath must be an absolute path")
68
+ .optional(),
69
+ maxQueries: z.number().int().min(1).max(20).default(8),
70
+ resultsPerQuery: z.number().int().min(1).max(3).default(2),
71
+ timeoutMs: z.number().int().min(1000).max(60_000).default(15_000),
72
+ })
73
+ .superRefine((value, context) => {
74
+ if (value.enabled && !value.indexPath) {
75
+ context.addIssue({
76
+ code: "custom",
77
+ path: ["indexPath"],
78
+ message: "research.indexPath is required when research.enabled is true",
79
+ });
80
+ }
81
+ })
82
+ .default({
83
+ enabled: false,
84
+ maxQueries: 8,
85
+ resultsPerQuery: 2,
86
+ timeoutMs: 15_000,
87
+ }),
61
88
  breakGlass: z
62
89
  .object({ marker: z.string().default("/skip-review") })
63
90
  .default({ marker: "/skip-review" }),
@@ -278,7 +305,7 @@ export const RoutingManifestSchema = z
278
305
  * Scope config = root config MINUS the centrally locked keys. Allowlist of
279
306
  * scope-overridable keys (Turborepo-style, graft 6): model, policy, chunk,
280
307
  * noise (+ the prompt files living beside it: shared.md, coordinator.md,
281
- * agents/). NEVER auth or breakGlass — declaring either fails parsing at the
308
+ * agents/). NEVER auth, breakGlass, or research — declaring one fails parsing at the
282
309
  * Zod level so IDE/doctor catch it before CI. commentTag is also locked: a
283
310
  * scope's comment marker is always DERIVED (`<rootTag>:<scope>`; the default
284
311
  * scope keeps the root tag) so `ecr ci`'s post/clear/reconcile paths and a
@@ -292,6 +319,7 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
292
319
  commentTag: true,
293
320
  stack: true,
294
321
  feedback: true,
322
+ research: true,
295
323
  }).extend({
296
324
  auth: z
297
325
  .never({ error: "auth is locked to the root config; remove it from this scope config" })
@@ -312,4 +340,9 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
312
340
  error: "feedback is locked to the root config (the comment lifecycle is global); remove it from this scope config",
313
341
  })
314
342
  .optional(),
343
+ research: z
344
+ .never({
345
+ error: "research is locked to the root config because it starts a trusted host process; remove it from this scope config",
346
+ })
347
+ .optional(),
315
348
  });