@expo/code-review-cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +151 -25
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +307 -36
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +170 -33
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +86 -11
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +99 -3
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +127 -10
  16. package/build/core/claude-code.js +691 -0
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +282 -9
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +117 -15
  24. package/build/core/prompts.js +330 -5
  25. package/build/core/render.js +274 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +447 -39
  28. package/build/core/schema.js +219 -3
  29. package/build/core/scrub.js +63 -1
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +12 -0
  35. package/build/core/util.js +18 -0
  36. package/build/core/verify.js +18 -1
  37. package/build/reporters/github.js +544 -44
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +286 -7
  40. package/build/sources/local-git.js +6 -2
  41. package/build/sources/source.js +35 -0
  42. package/package.json +4 -3
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +71 -4
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +124 -1
  54. package/templates/workflow.yml +5 -0
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0007#doctor-and-setup-auth — derives a plan from auth config, then guides local credential acquisition
1
2
  import { spawnSync } from "node:child_process";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import os from "node:os";
@@ -5,7 +6,8 @@ import path from "node:path";
5
6
  import readline from "node:readline/promises";
6
7
  import { hasConfig, loadReviewConfig } from "../config/load.js";
7
8
  import { jwtExpiryMs } from "../core/auth.js";
8
- import { opencodeBinSource } from "../core/opencode.js";
9
+ import { claudeSubscriptionActive, resolveClaudeCli } from "../core/claude-code.js";
10
+ import { resolveOpencodeCli } from "../core/opencode.js";
9
11
  import { errorMessage } from "../core/util.js";
10
12
  const USAGE = `ecr setup-auth — set up model credentials for local runs
11
13
 
@@ -15,6 +17,10 @@ getting each credential:
15
17
  \`opencode auth login\` (interactive; opens your browser), then prints the
16
18
  \`export <tokenEnv>=…\` line to add to your shell config. An existing
17
19
  OpenCode ChatGPT sign-in is reused instead of re-authenticating.
20
+ • a Claude Max/Team subscription (any anthropic/… model): reuses an active
21
+ \`claude\` login when present, or runs \`claude setup-token\` (interactive;
22
+ opens your browser) and prints the \`export <tokenEnv>=…\` line for
23
+ CI/headless runs.
18
24
  • an API key (api-key entries): prints where to create the key, the exact
19
25
  permissions it needs, and the export line to fill in.
20
26
 
@@ -24,10 +30,21 @@ with the default env name.
24
30
  Options:
25
31
  --yes Skip confirmation prompts (still interactive during the login itself).
26
32
  `;
27
- export function planFromAuth(auth) {
33
+ export function planFromAuth(auth, models = []) {
28
34
  const plan = { manualKeys: [], unsupported: [] };
35
+ // anthropic is always served by the Claude Code CLI — a `claude setup-token`
36
+ // subscription login covers it. Trigger on either an explicit anthropic auth
37
+ // entry OR any anthropic/… model in the roster (an entry is entirely optional).
38
+ const anthropicEntry = auth.find((entry) => entry.provider === "anthropic");
39
+ const usesAnthropicModel = models.some((model) => model === "anthropic" || model.startsWith("anthropic/"));
40
+ if (anthropicEntry || usesAnthropicModel) {
41
+ plan.claudeLogin = { tokenEnv: anthropicEntry?.tokenEnv ?? "CLAUDE_CODE_OAUTH_TOKEN" };
42
+ }
29
43
  for (const entry of auth) {
30
- if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
44
+ if (entry.provider === "anthropic") {
45
+ continue; // handled above (claude engine); mode is irrelevant here.
46
+ }
47
+ else if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
31
48
  plan.chatgptLogin = { tokenEnv: entry.tokenEnv };
32
49
  }
33
50
  else if (entry.mode === "api-key" && entry.tokenEnv) {
@@ -49,6 +66,7 @@ export function opencodeAuthJsonPath(env = process.env) {
49
66
  const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
50
67
  return path.join(dataHome, "opencode", "auth.json");
51
68
  }
69
+ // @ref LLP 0007#doctor-and-setup-auth [constrained-by] — refresh tokens are single-use; they never leave OpenCode's store
52
70
  /**
53
71
  * The stored ChatGPT sign-in's ACCESS token, if OpenCode has a live one. The
54
72
  * refresh token deliberately never leaves OpenCode's store: refresh tokens are
@@ -86,6 +104,7 @@ async function confirm(question, skip) {
86
104
  rl.close();
87
105
  }
88
106
  }
107
+ // @ref LLP 0007#doctor-and-setup-auth [constrained-by] — shell metacharacters in tokens never expand
89
108
  /** The line to paste into a shell config. Single-quoted: tokens never contain '. */
90
109
  export function exportLine(tokenEnv, value) {
91
110
  return `export ${tokenEnv}='${value}'`;
@@ -104,7 +123,10 @@ export async function setupAuthCommand(argv = []) {
104
123
  let plan;
105
124
  if (hasConfig(process.cwd())) {
106
125
  const config = await loadReviewConfig(process.cwd());
107
- plan = planFromAuth(config.auth);
126
+ plan = planFromAuth(config.auth, [
127
+ ...config.agents.map((agent) => agent.model),
128
+ config.coordinator.model,
129
+ ]);
108
130
  }
109
131
  else {
110
132
  err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
@@ -112,11 +134,55 @@ export async function setupAuthCommand(argv = []) {
112
134
  { provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
113
135
  ]);
114
136
  }
115
- if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
137
+ if (!plan.chatgptLogin &&
138
+ !plan.claudeLogin &&
139
+ plan.manualKeys.length === 0 &&
140
+ plan.unsupported.length === 0) {
116
141
  out("This repo's auth config needs no local credential setup (OpenCode's own login covers it).");
117
142
  return;
118
143
  }
119
144
  const exports = [];
145
+ if (plan.claudeLogin) {
146
+ const { tokenEnv } = plan.claudeLogin;
147
+ if (process.env[tokenEnv]) {
148
+ err(`✓ ${tokenEnv} is already set in this shell — skipping the Claude subscription login.`);
149
+ }
150
+ else {
151
+ // Resolve to a trusted absolute path (refusing an in-tree binary), never a
152
+ // bare `claude`: setup-auth may run inside a cloned untrusted repo, so a
153
+ // PR-committed shim must not be the `claude` we probe or hand the terminal to.
154
+ const claudeCliPath = await resolveClaudeCli();
155
+ // A live local `claude` login already covers interactive runs — only CI or
156
+ // a headless box needs the token in an env var.
157
+ const loggedIn = await claudeSubscriptionActive({ cliPath: claudeCliPath ?? undefined });
158
+ if (loggedIn) {
159
+ err("✓ A Claude Max/Team subscription login is active locally — `ecr review` works now. " +
160
+ `You only need ${tokenEnv} for CI/headless runs.`);
161
+ }
162
+ err("`claude setup-token` mints a 1-year subscription token (opens your browser).");
163
+ if (!(await confirm("Run it now?", yes))) {
164
+ err("Skipped `claude setup-token`.");
165
+ }
166
+ else if (!claudeCliPath) {
167
+ throw new Error("The `claude` CLI is not installed on this host (npm i -g " +
168
+ "@anthropic-ai/claude-code); nothing was changed.");
169
+ }
170
+ else {
171
+ const result = spawnSync(claudeCliPath, ["setup-token"], {
172
+ stdio: "inherit",
173
+ cwd: os.tmpdir(),
174
+ });
175
+ if (result.status !== 0) {
176
+ throw new Error(`\`claude setup-token\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
177
+ }
178
+ // setup-token prints the token to the terminal and persists it nowhere we
179
+ // can read back, so the user pastes it into the export line themselves.
180
+ err(`Copy the token \`claude setup-token\` just printed and paste it in place of the ` +
181
+ `placeholder below.`);
182
+ exports.push(exportLine(tokenEnv, "<paste the token setup-token printed>"));
183
+ }
184
+ }
185
+ }
120
186
  if (plan.chatgptLogin) {
121
187
  const { tokenEnv } = plan.chatgptLogin;
122
188
  if (process.env[tokenEnv]) {
@@ -141,9 +207,20 @@ export async function setupAuthCommand(argv = []) {
141
207
  err("Skipped the ChatGPT sign-in.");
142
208
  }
143
209
  else {
144
- const binDir = opencodeBinSource().dir;
145
- const opencode = binDir ? path.join(binDir, "opencode") : "opencode";
146
- const result = spawnSync(opencode, ["auth", "login"], { stdio: "inherit" });
210
+ // Resolve to a trusted absolute path (our bundled shim, else PATH with an
211
+ // in-tree refusal), never a bare `opencode`: setup-auth may run inside a
212
+ // cloned untrusted repo, so a PR-committed shim must not be the CLI we hand
213
+ // the terminal to. Run from tmpdir(), never the (possibly untrusted) cwd —
214
+ // the login writes to OpenCode's global auth store, not the working dir.
215
+ const opencodeCli = await resolveOpencodeCli();
216
+ if (!opencodeCli) {
217
+ throw new Error("The `opencode` CLI is not available (install `opencode-ai`, or add " +
218
+ "node_modules/.bin to PATH); nothing was changed.");
219
+ }
220
+ const result = spawnSync(opencodeCli, ["auth", "login"], {
221
+ stdio: "inherit",
222
+ cwd: os.tmpdir(),
223
+ });
147
224
  if (result.status !== 0) {
148
225
  throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
149
226
  }
@@ -190,9 +267,7 @@ export async function setupAuthCommand(argv = []) {
190
267
  for (const entry of plan.unsupported) {
191
268
  err("");
192
269
  err(`auth for "${entry.provider}" is mode "oauth", which has no automated setup flow here` +
193
- (entry.provider === "anthropic"
194
- ? " — and cannot work: Anthropic prohibits subscription tokens in third-party tools. Use an API key instead."
195
- : `. Set ${entry.tokenEnv ?? "its token env"} manually.`));
270
+ `. Set ${entry.tokenEnv ?? "its token env"} manually.`);
196
271
  }
197
272
  if (exports.length > 0) {
198
273
  const rc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "your shell config";
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0007#verify-config-the-config-guard — the CI trust guard that runs before the loaders are trusted, so it deliberately does not use them
1
2
  import { readdir, readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
@@ -28,6 +29,7 @@ Options:
28
29
  --json Emit {ok, findings:[{file, problem}]} on stdout.
29
30
  `;
30
31
  const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
32
+ // @ref LLP 0007#verify-config-the-config-guard [implements] — on-disk sweep, not git index, not the manifest; CONFIG_FILENAMES must mirror load.ts
31
33
  /**
32
34
  * Discover every config the CLI could ever read via a plain recursive walk (not
33
35
  * `git ls-files`): a PR can't hide an unreferenced/untracked config dir from an
@@ -183,6 +185,7 @@ export async function verifyConfig(root, options = {}) {
183
185
  }
184
186
  seen.add(occurrence.value);
185
187
  }
188
+ // @ref LLP 0007#verify-config-the-config-guard [implements] — exact set equality; adding a credential is refused like repointing one
186
189
  // With an expectation set, the declared names must equal the expected SET
187
190
  // exactly (comma-separated; order-insensitive). A missing name is as much a
188
191
  // finding as an extra one — a PR must not add, drop, or repoint credentials.
@@ -1,9 +1,31 @@
1
+ // @ref LLP 0006#loading-and-the-config-dir-escape-hatch — shared root/scope loader; ECR_CONFIG_DIR escape hatch
2
+ // @ref LLP 0006#model-resolution — REVIEWER_MODEL env override resolution
3
+ // @ref LLP 0006#auth-config-shapes — auth normalization (normalizeAuth, tokenEnvMismatch, loadAuthFromRoot)
4
+ // @ref LLP 0006#root-vs-scope-config — scope config loading, commentTag derivation, enforceAgents injection
1
5
  import { readdir, readFile } from "node:fs/promises";
2
6
  import { existsSync } from "node:fs";
3
7
  import path from "node:path";
4
8
  import { ReviewConfigSchema, ScopeReviewConfigSchema } from "./schema.js";
5
9
  import { toolMap } from "../core/tools.js";
6
10
  export const CONFIG_DIRNAME = ".expo-code-review";
11
+ /** Stack config for a scope load (where `stack` is schema-rejected and absent). */
12
+ const STACK_CONFIG_DEFAULTS = {
13
+ enabled: false,
14
+ maxDepth: 4,
15
+ maxPrs: 8,
16
+ maxFilesPerPr: 100,
17
+ requireSameAuthor: true,
18
+ confirmWithPatch: false,
19
+ maxConfirmations: 10,
20
+ };
21
+ /** Feedback config for a scope load (where `feedback` is schema-rejected and absent). */
22
+ const FEEDBACK_CONFIG_DEFAULTS = {
23
+ mode: "annotate",
24
+ match: "both",
25
+ dismiss: "never",
26
+ protectedCategories: ["secrets", "security"],
27
+ maxAdjudications: 10,
28
+ };
7
29
  /** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
8
30
  const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
9
31
  export function configDirFor(repoRoot) {
@@ -61,6 +83,7 @@ async function loadConfigDir(dir, schema) {
61
83
  // agent and the coordinator then ran on whatever OpenCode picked by default, so a
62
84
  // config saying `anthropic/claude-sonnet-5` reviewed with something else entirely and
63
85
  // nothing anywhere said so. Trim too: a stray newline is the same class of accident.
86
+ // @ref LLP 0006#model-resolution [constrained-by] — never ??; GitHub Actions passes an unset var as empty string, not undefined
64
87
  const override = process.env.REVIEWER_MODEL?.trim() || undefined;
65
88
  const defaultModel = override ?? parsed.model;
66
89
  const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
@@ -121,6 +144,14 @@ async function loadConfigDir(dir, schema) {
121
144
  commentTag: parsed.commentTag ?? "expo-ai-code-reviewer",
122
145
  auth: normalizeAuth(parsed.auth),
123
146
  review: parsed.review,
147
+ // Root-only: the scope schema rejects `stack`, so parsed.stack is absent for a
148
+ // scope config and the defaults stand in (unused — the command layer reads the
149
+ // ROOT config's stack values to drive the walk).
150
+ stack: parsed.stack ?? STACK_CONFIG_DEFAULTS,
151
+ // Root-only: the scope schema rejects `feedback`, so parsed.feedback is absent
152
+ // for a scope config and the defaults stand in (unused — the command layer
153
+ // reads the ROOT config's feedback values; the comment lifecycle is global).
154
+ feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
124
155
  };
125
156
  return { config, raw: rawObject };
126
157
  }
@@ -185,6 +216,7 @@ export function loadAuthFromRoot(rootConfig, manifest) {
185
216
  * with the root config; the scope config activates after merge) instead of
186
217
  * failing the run on exactly the PR that introduces the scope.
187
218
  */
219
+ // @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — deliberately bypasses ECR_CONFIG_DIR; scope subtrees stay repo-root-relative
188
220
  export function hasScopeConfig(root, scope) {
189
221
  if (scope.config === ".") {
190
222
  return true;
@@ -224,6 +256,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
224
256
  // loaded value here is only the manifest default, for display/doctor.
225
257
  commentTag = manifest.defaults.commentTag;
226
258
  }
259
+ // @ref LLP 0006#root-vs-scope-config [implements] — ROOT enforced agent always wins a same-id scope agent (risk 11)
227
260
  // Inject the enforced agents from the ROOT roster with alwaysRun, replacing any
228
261
  // same-id agent the scope defines (the enforced one wins — risk 11).
229
262
  const agents = base.agents.map((agent) => ({ ...agent }));
@@ -247,6 +280,12 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
247
280
  auth: loadAuthFromRoot(rootConfig, manifest),
248
281
  breakGlassMarker: rootConfig.breakGlassMarker,
249
282
  commentTag,
283
+ // Root-only, like stack: a non-default scope's `base` carries only the
284
+ // hardcoded placeholder (the scope schema rejects `feedback`), so re-derive
285
+ // from the root here or consumers of a nested scope's config would silently
286
+ // run the default policy instead of the repo's real one.
287
+ stack: rootConfig.stack,
288
+ feedback: rootConfig.feedback,
250
289
  scopeName: scope.name,
251
290
  };
252
291
  }
@@ -1,3 +1,6 @@
1
+ // @ref LLP 0006#routing-manifest — routing.jsonc parsing, scope resolution (last-match-wins), overlaps/unmatched
2
+ // @ref LLP 0006#loading-and-the-config-dir-escape-hatch — routing.jsonc travels with config.jsonc via resolveConfigDir
3
+ // @ref LLP 0006#budgets-and-chunking-defaults — per-scope budget split (scopePassesBudgetMs)
1
4
  import { readFile } from "node:fs/promises";
2
5
  import { existsSync } from "node:fs";
3
6
  import path from "node:path";
@@ -15,6 +18,7 @@ export const ROUTING_FILENAME = "routing.jsonc";
15
18
  * Scope `config` paths stay repo-root-relative (see `loadScopeConfig`): an
16
19
  * override relocates only the ROOT artifacts, never the scopes' own subtrees.
17
20
  */
21
+ // @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — travels with config.jsonc; a real bug once let them split
18
22
  export async function loadRoutingManifest(root, options = {}) {
19
23
  const manifestPath = path.join(resolveConfigDir(root, options.configDir), ROUTING_FILENAME);
20
24
  if (!existsSync(manifestPath)) {
@@ -30,6 +34,7 @@ export async function loadRoutingManifest(root, options = {}) {
30
34
  // silently miss root-level files (README.md, package.json). To give the double-star +
31
35
  // slash its conventional "zero or more directories" meaning, we also test the variant
32
36
  // with each such prefix removed, so the catch-all matches both `a.ts` and `src/b.ts`.
37
+ // @ref LLP 0006#routing-manifest [constrained-by] — workaround for matchesIgnore's **-needs-a-slash limitation (LLP 0004 dialect)
33
38
  function patternVariants(pattern) {
34
39
  const collapsed = pattern.replace(/\*\*\//g, "");
35
40
  return collapsed !== pattern && collapsed.length > 0 ? [pattern, collapsed] : [pattern];
@@ -44,6 +49,7 @@ function scopeMatches(paths, file) {
44
49
  * matchesIgnore (supports ** across / and * within a segment — the manifest
45
50
  * documents this dialect). Deterministic, no filesystem access.
46
51
  */
52
+ // @ref LLP 0006#routing-manifest [implements] — last-match-wins (CODEOWNERS discipline); each file lands in exactly one scope
47
53
  export function resolveScopes(manifest, changedFiles) {
48
54
  const buckets = new Map();
49
55
  const unmatched = [];
@@ -86,6 +92,7 @@ export function scopedCommentTag(rootTag, scopeName) {
86
92
  * starting — and `overshoot` flags that the run will exceed the total budget so
87
93
  * the caller can warn. Pure so the math is unit-testable.
88
94
  */
95
+ // @ref LLP 0006#budgets-and-chunking-defaults [implements] — floor(total/active) clamped to a 5-min floor; overshoot flags the clamp
89
96
  export function scopePassesBudgetMs(totalMs, minMs, activeCount) {
90
97
  const count = Math.max(1, activeCount);
91
98
  const evenSplit = Math.floor(totalMs / count);
@@ -1,5 +1,10 @@
1
+ // @ref LLP 0006#root-vs-scope-config — schema for root vs. scope-overridable config keys
2
+ // @ref LLP 0006#auth-config-shapes — auth union schema (legacy single credential + per-provider map)
3
+ // @ref LLP 0006#routing-manifest — routing.jsonc manifest schema (scopes, budgets, traversal guard)
4
+ // @ref LLP 0006#budgets-and-chunking-defaults — chunk/budget default values and re-tuning heuristics
1
5
  import path from "node:path";
2
6
  import { z } from "zod";
7
+ import { CATEGORIES } from "../core/schema.js";
3
8
  export const ReviewConfigSchema = z.object({
4
9
  /** Default model for every agent + the coordinator. Override per-agent via
5
10
  * frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
@@ -68,6 +73,7 @@ export const ReviewConfigSchema = z.object({
68
73
  // Union order matters: the map form must be tried FIRST — the legacy object's keys
69
74
  // all have defaults, so a non-strict legacy parse would accept (and gut) a
70
75
  // { providers } object by stripping the unknown key.
76
+ // @ref LLP 0006#auth-config-shapes [constrained-by] — map-first order is load-bearing; reordering silently guts multi-provider auth
71
77
  auth: z
72
78
  .union([
73
79
  z.object({
@@ -76,9 +82,13 @@ export const ReviewConfigSchema = z.object({
76
82
  // "oauth": tokenEnv holds an OAuth token, injected into an isolated
77
83
  // OpenCode auth.json. For "openai" this is the REFRESH token from a
78
84
  // ChatGPT/Codex sign-in (OpenCode's codex plugin mints access tokens
79
- // from it). NOTE: anthropic oauth cannot work — OpenCode has no
80
- // anthropic OAuth plugin and Anthropic prohibits subscription tokens
81
- // in third-party tools.
85
+ // from it).
86
+ // NOTE: provider "anthropic" is ALWAYS served by the Claude Code CLI
87
+ // (the engine is inferred from the `anthropic/…` model, not this mode) —
88
+ // for anthropic, mode is irrelevant; tokenEnv optionally names the
89
+ // credential env (an "sk-ant-oat…" subscription token or an Anthropic
90
+ // API key), and no entry at all falls back to the machine's `claude`
91
+ // login. See core/claude-code.ts.
82
92
  mode: z.enum(["api-key", "oauth"]).default("api-key"),
83
93
  tokenEnv: z.string().optional(),
84
94
  // Set ⇒ this provider id is an ALIAS synthesized into the OpenCode
@@ -111,6 +121,77 @@ export const ReviewConfigSchema = z.object({
111
121
  skipLabel: z.string().default("ai-review:skip"),
112
122
  })
113
123
  .default({ trigger: "all", label: "ai-review", skipLabel: "ai-review:skip" }),
124
+ // Stack-aware requalification: walk the OPEN PRs stacked on top of this one and
125
+ // let the coordinator mark absence-style findings a later PR already addresses.
126
+ // ROOT-ONLY (one PR has one stack) and off by default — a suppression-adjacent
127
+ // feature earns trust with field data first. Under `ecr ci` it auto-enables from
128
+ // this trusted-base value; `ecr review --pr` needs an explicit --stack-aware.
129
+ // @ref LLP 0010#config-and-cli-surface [implements] — root-only + off-by-default; head config can never enable, widen, or disable it
130
+ stack: z
131
+ .object({
132
+ enabled: z.boolean().default(false),
133
+ maxDepth: z.number().int().positive().default(4),
134
+ // Children per level (per parent branch) the walk will follow.
135
+ maxPrs: z.number().int().positive().default(8),
136
+ maxFilesPerPr: z.number().int().positive().default(100),
137
+ // Only children whose author is the current PR's author enter the manifest —
138
+ // closes cross-author poisoning (a push-access colleague opening a child PR on
139
+ // the victim's branch). Set false from the trusted base for genuine team stacks.
140
+ requireSameAuthor: z.boolean().default(true),
141
+ // v2: confirm each requalification against the addressing PR's actual patch
142
+ // before believing it (a no-tools LLM reads the inlined patch). Default false so
143
+ // v2 ships dark until flipped; maxConfirmations bounds that cost.
144
+ confirmWithPatch: z.boolean().default(false),
145
+ maxConfirmations: z.number().int().positive().default(10),
146
+ })
147
+ .default({
148
+ enabled: false,
149
+ maxDepth: 4,
150
+ maxPrs: 8,
151
+ maxFilesPerPr: 100,
152
+ requireSameAuthor: true,
153
+ confirmWithPatch: false,
154
+ maxConfirmations: 10,
155
+ }),
156
+ // Author replies to findings: match them to the finding they answer, record
157
+ // them in the comment's embedded state, and (optionally) let a model judge the
158
+ // rebuttal against the source. ROOT-ONLY: the comment lifecycle is global.
159
+ // Defaults are deliberately ASYMMETRIC: `annotate` is on but `dismiss` is off.
160
+ // An adopting repo has its own config.jsonc and never re-copies this template,
161
+ // so a key it never set must still resolve to the safe, useful default via
162
+ // zod — annotating is safe and useful out of the box; suppressing a finding is
163
+ // not, so it stays opt-in.
164
+ // @ref LLP 0011#asymmetric-defaults [implements] — annotate on, dismiss off; adopting repos never re-copy the template
165
+ feedback: z
166
+ .object({
167
+ // "off" — ignore replies entirely.
168
+ // "annotate" — match + record + show "author replied" (no decision effect).
169
+ // "adjudicate" — also run a source-grounded judgment of the rebuttal and
170
+ // record its verdict. Dismissal still obeys `dismiss`.
171
+ mode: z.enum(["off", "annotate", "adjudicate"]).default("annotate"),
172
+ // How a reply is MATCHED to a finding. Clearing one additionally requires the
173
+ // reply to cite its `id:` token in the replier's own words, whatever this says.
174
+ match: z.enum(["quote", "id", "both"]).default("both"),
175
+ // Who/what may actually remove a finding from the blocking set (always on a
176
+ // reply citing the finding's `id:` token — a quote only annotates):
177
+ // "never" — nothing does (default: adjudication ships dark).
178
+ // "maintainers" — a maintainer reply dismisses, no model involved.
179
+ // "adjudicated" — a maintainer reply, or an author reply the adjudicator
180
+ // confirmed against the source.
181
+ dismiss: z.enum(["never", "maintainers", "adjudicated"]).default("never"),
182
+ // Categories a reply can NEVER clear, whatever the verdict. Also hard-coded
183
+ // as a floor in code — this only widens the set, never narrows it.
184
+ protectedCategories: z.array(z.enum(CATEGORIES)).default(["secrets", "security"]),
185
+ // Cap on adjudication model calls per run.
186
+ maxAdjudications: z.number().int().positive().default(10),
187
+ })
188
+ .default({
189
+ mode: "annotate",
190
+ match: "both",
191
+ dismiss: "never",
192
+ protectedCategories: ["secrets", "security"],
193
+ maxAdjudications: 10,
194
+ }),
114
195
  });
115
196
  /** One routing scope: ordered globs → a directory containing .expo-code-review/. */
116
197
  export const RoutingScopeSchema = z.object({
@@ -122,6 +203,7 @@ export const RoutingScopeSchema = z.object({
122
203
  * routing.jsonc is read from the PR-head checkout, so this field is
123
204
  * PR-controllable input: absolute paths and `..` traversal are rejected so a
124
205
  * scope config can never resolve outside the repo. */
206
+ // @ref LLP 0006#routing-manifest [implements] — traversal guard; load.ts re-checks at runtime (defense in depth)
125
207
  config: z
126
208
  .string()
127
209
  .min(1)
@@ -155,6 +237,7 @@ export const RoutingManifestSchema = z
155
237
  * chain still fires the default when the key is absent, which would make
156
238
  * `defaults.auth` a phantom `{mode:'api-key',provider:'openai'}` for every
157
239
  * manifest that omits auth and silently override the root config's real auth. */
240
+ // @ref LLP 0006#routing-manifest [constrained-by] — zod v4 default().optional() trap; unwrap avoids a phantom auth stub
158
241
  auth: ReviewConfigSchema.shape.auth.unwrap().optional(),
159
242
  /** Agent ids injected into every scope with alwaysRun, from the ROOT roster. */
160
243
  enforceAgents: z.array(z.string()).default([]),
@@ -202,10 +285,13 @@ export const RoutingManifestSchema = z
202
285
  * standalone `ecr review --scope --post` always target the same marker — an
203
286
  * honored per-scope tag would let the two halves strand each other's comments.
204
287
  */
288
+ // @ref LLP 0006#root-vs-scope-config [implements] — one of three enforcement layers; z.never fails at parse, not runtime
205
289
  export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
206
290
  auth: true,
207
291
  breakGlass: true,
208
292
  commentTag: true,
293
+ stack: true,
294
+ feedback: true,
209
295
  }).extend({
210
296
  auth: z
211
297
  .never({ error: "auth is locked to the root config; remove it from this scope config" })
@@ -216,4 +302,14 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
216
302
  error: "commentTag is locked: per-scope comment markers are derived as <rootTag>:<scope>; remove it from this scope config",
217
303
  })
218
304
  .optional(),
305
+ stack: z
306
+ .never({
307
+ error: "stack is locked to the root config (one PR has one stack); remove it from this scope config",
308
+ })
309
+ .optional(),
310
+ feedback: z
311
+ .never({
312
+ error: "feedback is locked to the root config (the comment lifecycle is global); remove it from this scope config",
313
+ })
314
+ .optional(),
219
315
  });