@expo/code-review-cli 0.14.0 → 0.15.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
@@ -4,7 +4,10 @@ A config-driven, multi-agent AI code reviewer. Specialist agents review a diff i
4
4
  parallel; a coordinator consolidates their findings into one structured review.
5
5
  The same engine runs locally (advisory) and in CI (posts one PR comment). The CLI
6
6
  is the **engine** — each repo supplies its own agents and settings under
7
- `.expo-code-review/`, so behavior is configured per-repo, not baked in.
7
+ `.expo-agents/code-review/`, so behavior is configured per-repo, not baked in.
8
+ (Repos set up before 0.15 keep their `.expo-code-review/` directory: every command
9
+ looks for the new name first and falls back to the old one, and `ecr init` reuses
10
+ an existing legacy directory rather than starting a second one.)
8
11
 
9
12
  > **Status: experimental.** Comment-only and non-blocking — it never blocks a merge
10
13
  > and never auto-approves. See [`ROADMAP.md`](./ROADMAP.md).
@@ -94,7 +97,7 @@ here ([LLP 0001](./llp/0001-trust-model.principles.md)) is built around that:
94
97
  ## Usage
95
98
 
96
99
  Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
97
- binary once installed). On a repo that already has `.expo-code-review/` set up,
100
+ binary once installed). On a repo that already has `.expo-agents/code-review/` set up,
98
101
  getting model credentials for local runs is one command:
99
102
 
100
103
  ```bash
@@ -108,7 +111,7 @@ package.
108
111
  ### First-time setup
109
112
 
110
113
  ```bash
111
- # 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
114
+ # 1. Scaffold .expo-agents/code-review/ + a CI workflow (--no-workflow to skip)
112
115
  npx @expo/code-review-cli init
113
116
  # 2. Get model credentials — guided; prints the export lines for your shell config
114
117
  npx @expo/code-review-cli setup-auth
@@ -142,7 +145,7 @@ ecr review --pr 123
142
145
  ecr review --pr 123 --post
143
146
  # Preview once, save the exact result, and post it later without another model run
144
147
  ecr review --repo owner/repo --pr 123 --save-review --json
145
- ecr post-review --artifact .expo-code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
148
+ ecr post-review --artifact .expo-agents/code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
146
149
  ```
147
150
 
148
151
  Options (most to least common):
@@ -178,7 +181,7 @@ is a ready example to adapt.
178
181
 
179
182
  | Command | What it does |
180
183
  | --- | --- |
181
- | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
184
+ | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-agents/code-review/` (config, agents, prompts) + a CI workflow. |
182
185
  | `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
183
186
  | `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
184
187
  | `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. |
@@ -215,7 +218,7 @@ Markdown, `//` in JSONC):
215
218
 
216
219
  A target is a file, a `dir/`, `glob:<pattern>`, `file#symbol`, or `doc.md#heading` —
217
220
  never a line number (line numbers rot without any signal). The check is strict on
218
- purpose: any backticked token in `.expo-code-review/` that names something that
221
+ purpose: any backticked token in `.expo-agents/code-review/` that names something that
219
222
  exists in the repo **must** be a ref, because stale citations are exactly the ones
220
223
  nobody annotated. Mark the rare false positive once with
221
224
  `<!-- @ref-ignore knex.raw() -->`. Refs are always repo-root-relative, including in
@@ -274,8 +277,8 @@ The boundary, in brief:
274
277
  many discovery and page fetches, so each call reports its own request ledger and
275
278
  the run reports totals.
276
279
  - **Root-only in routed monorepos** (it starts a host process); scope configs
277
- cannot alter it. Result-cache reuse is disabled while research is enabled,
278
- because web content can change without a config change.
280
+ cannot alter it. Research results are fetched and audited per run; cached review
281
+ results remain keyed by the ordinary trusted review inputs and make no network call.
279
282
 
280
283
  Full detail — providers, query grammar, `fetch_platform_doc` modes, provenance and
281
284
  citation grounding: [LLP 0013](./llp/0013-platform-research.explainer.md).
@@ -291,18 +294,18 @@ and no locking.
291
294
 
292
295
  ```
293
296
  your-monorepo/
294
- .expo-code-review/
297
+ .expo-agents/code-review/
295
298
  routing.jsonc # the manifest — infra-owned, ordered scope list + locked defaults
296
299
  config.jsonc # the default/root scope; the ONLY place auth/tokenEnv lives
297
300
  shared.md coordinator.md agents/
298
301
  apps/
299
- api/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # api team
300
- web/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # web team
302
+ api/.expo-agents/code-review/{config.jsonc(NO auth),coordinator.md,agents/} # api team
303
+ web/.expo-agents/code-review/{config.jsonc(NO auth),coordinator.md,agents/} # web team
301
304
  .github/workflows/expo-code-review.yml # unchanged shape: one workflow, one `ecr ci`
302
305
  ```
303
306
 
304
307
  ```jsonc
305
- // .expo-code-review/routing.jsonc
308
+ // .expo-agents/code-review/routing.jsonc
306
309
  {
307
310
  // Central guardrails every scope inherits and CANNOT override.
308
311
  "defaults": {
@@ -322,7 +325,7 @@ your-monorepo/
322
325
  ```
323
326
 
324
327
  ```jsonc
325
- // apps/api/.expo-code-review/config.jsonc (the api team owns this)
328
+ // apps/api/.expo-agents/code-review/config.jsonc (the api team owns this)
326
329
  {
327
330
  // NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
328
331
  "model": "anthropic/claude-sonnet-5",
@@ -378,8 +381,8 @@ Enforced in code and by an independent CI guard step, not by convention:
378
381
  itself, so even a custom workflow that checks out the head still gets
379
382
  base-commit configuration.
380
383
 
381
- Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`,
382
- `/apps/api/.expo-code-review/ @your-api-team`. Full detail:
384
+ Enforce ownership with CODEOWNERS: `/.expo-agents/code-review/routing.jsonc @your-infra`,
385
+ `/apps/api/.expo-agents/code-review/ @your-api-team`. Full detail:
383
386
  [LLP 0006](./llp/0006-config-schema-loading-routing.explainer.md) (routing) and
384
387
  [LLP 0001](./llp/0001-trust-model.principles.md) (trust model).
385
388
 
@@ -396,7 +399,7 @@ Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra
396
399
  - **Chunking** — small PRs run in a single pass; large PRs are split into chunks
397
400
  bounded by changed lines, plus one combined **cross-cutting pass** that looks
398
401
  for issues spanning multiple changed files across every agent's concern.
399
- - **Agents** — every `.md` file in `.expo-code-review/agents/` is an agent. They
402
+ - **Agents** — every `.md` file in `.expo-agents/code-review/agents/` is an agent. They
400
403
  run in parallel with read-only repo tools (`read`/`grep`/`glob`/`list`).
401
404
  - **Coordinator** — a single pass that dedupes, re-judges severity, and produces
402
405
  the final `{ decision, findings, summary }`.
@@ -426,7 +429,7 @@ Every run reports what it spent and how much was served from the prompt cache, i
426
429
  three places: one `Token usage — …` line in the job log, a per-pass table + cache
427
430
  hit rate in the GitHub Actions step summary (which also preserves each run's posted
428
431
  comment, since the PR comment is updated in place), and one JSON line per run in
429
- `.expo-code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
432
+ `.expo-agents/code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
430
433
  tokens, raw per-agent findings, bounded reviewer traces, and coverage notes.
431
434
 
432
435
  Each reviewer can also return a compact trace (up to three concrete checks and two
@@ -452,10 +455,10 @@ cacheable size (~1–4K tokens) show `cache read 0` — expected, not a bug.
452
455
  </details>
453
456
 
454
457
  <details>
455
- <summary><b>Configuration — <code>.expo-code-review/</code></b></summary>
458
+ <summary><b>Configuration — <code>.expo-agents/code-review/</code></b></summary>
456
459
 
457
460
  ```
458
- .expo-code-review/
461
+ .expo-agents/code-review/
459
462
  config.jsonc # model, policy, noise, auth, break-glass, comment tag
460
463
  shared.md # instructions prepended to every agent (optional)
461
464
  coordinator.md # the consolidation prompt (required)
@@ -480,7 +483,7 @@ temperature: 0.1
480
483
  ```
481
484
 
482
485
  For a real-world example, see eas-cli's
483
- [`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
486
+ [`.expo-agents/code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
484
487
  — correctness/security/consistency agents, a stronger model for security, and
485
488
  per-repo `noise.additionalIgnores`.
486
489
 
@@ -651,6 +654,44 @@ the defaults are asymmetric: [LLP 0011](./llp/0011-author-feedback.explainer.md)
651
654
 
652
655
  </details>
653
656
 
657
+ <details>
658
+ <summary><b>Inline PR comments (findings on the diff line)</b></summary>
659
+
660
+ Off by default. When enabled, each finding anchored to a line that is actually in
661
+ the PR's diff is posted as an inline review comment on that line, carrying the
662
+ full rationale, sources, and suggestion. The main comment still lists every
663
+ finding, but an inlined one renders short — title, links, category, id, and a
664
+ one-line rationale — plus a `💬 inline comment` link to the thread. The main
665
+ comment remains the single durable state store: `/dismiss`, embedded state, and
666
+ reply adjudication are unchanged.
667
+
668
+ ```jsonc
669
+ "inline": {
670
+ "enabled": false,
671
+ "maxComments": 20 // per PR (single comment mode) / per scope (per-scope mode)
672
+ }
673
+ ```
674
+
675
+ Behavior notes:
676
+
677
+ - **Threads converge, never churn.** Each inline comment carries a per-finding
678
+ marker and is patched in place across re-reviews. The cap is sticky: a live
679
+ thread is never evicted to create a new one. A finding that leaves the set is
680
+ stubbed if humans replied in its thread (never deleted), deleted only when bare.
681
+ - **Fail-safe teardown.** A failed run, a `--scopes` partial run, or a truncated
682
+ aggregate never tears threads down — teardown only runs when the target set is
683
+ provably complete. Creates are skipped (and logged) when the PR head moved
684
+ during the review.
685
+ - **Replies on inline threads count as author feedback.** Replying in a thread
686
+ matches the reply to that finding structurally; clearing still requires the
687
+ `id:…` token in the replier's own words, same as main-comment replies.
688
+ - **Notification fan-out.** The first enabled run on a PR sends one notification
689
+ per created inline comment; `maxComments` bounds it.
690
+ - **Disabling later** leaves existing threads up (the sync no longer runs); a
691
+ comment-mode switch — or the reporter's clear path — stubs/removes them.
692
+
693
+ </details>
694
+
654
695
  <details>
655
696
  <summary><b>External context (--context-file)</b></summary>
656
697
 
@@ -680,7 +721,7 @@ variables: `ATLANTIS_BOT_LOGIN` (the Atlantis bot's comment login, e.g.
680
721
  <details>
681
722
  <summary><b>Run logs</b></summary>
682
723
 
683
- Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
724
+ Each run appends a JSON line to `.expo-agents/code-review/.runs/reviews.jsonl` with the
684
725
  inputs, decision, finding count, duration, per-agent cost, and aggregate token
685
726
  usage (incl. prompt-cache read/write counts) — for auditing and measuring
686
727
  cost/latency/cache reuse over time. It also records the same bounded `reviewTrace`
@@ -689,7 +730,7 @@ one-line summary to the terminal / CI job log at the end of each run, so cache r
689
730
  is visible even in CI (where the run log is ephemeral).
690
731
 
691
732
  `ecr review --save-review` additionally writes a versioned artifact under
692
- `.expo-code-review/.runs/deferred/` with owner-only permissions. It contains the
733
+ `.expo-agents/code-review/.runs/deferred/` with owner-only permissions. It contains the
693
734
  verified final review and bounded feedback metadata, but no credential. `ecr
694
735
  post-review` schema-validates it and refuses to post if its explicit repo/PR, live
695
736
  head commit, or local comment-policy fingerprint no longer matches.
@@ -1,7 +1,7 @@
1
1
  // @ref LLP 0007#ecr-ci-the-trusted-root-run — fails CLOSED: trusted-root materialization failure never falls back to reading the checkout
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
4
+ import { configDirFor, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
5
5
  import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
6
6
  import { repoRoot, resolveTrustedTool, run } from "../core/exec.js";
7
7
  import { errorMessage, publicFailureReason } from "../core/util.js";
@@ -390,7 +390,7 @@ function resolveStackConfirm(stack, noStackAware) {
390
390
  * `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
391
391
  */
392
392
  function workspaceRunsDir(cwd) {
393
- return path.join(cwd, CONFIG_DIRNAME, ".runs");
393
+ return path.join(configDirFor(cwd), ".runs");
394
394
  }
395
395
  /**
396
396
  * The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
@@ -446,6 +446,7 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
446
446
  breakGlassMarker: config.breakGlassMarker,
447
447
  cwd,
448
448
  feedback: config.feedback,
449
+ inline: config.inline,
449
450
  headSha,
450
451
  });
451
452
  try {
@@ -554,6 +555,10 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
554
555
  findings: [],
555
556
  summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
556
557
  incomplete: [],
558
+ // A failed run's empty findings list proves nothing: this flag is what keeps
559
+ // the inline sync additive-only (no thread teardown) — and it renders the
560
+ // accurate "No review" decision label.
561
+ couldNotComplete: true,
557
562
  });
558
563
  }
559
564
  catch (postError) {
@@ -568,6 +573,10 @@ function failureReview(scopeName, reason) {
568
573
  findings: [],
569
574
  summary: `⚠️ The AI reviewer failed to run for scope **${scopeName}**, so those changes were **not** reviewed:\n\n> ${reason}`,
570
575
  incomplete: [],
576
+ // Empty-but-not-clean: keeps the inline sync additive-only for the whole
577
+ // aggregate (a failed scope's findings may still be live on the PR) and renders
578
+ // the accurate "No review" label in the scope table.
579
+ couldNotComplete: true,
571
580
  };
572
581
  }
573
582
  // @ref LLP 0007#routed-ci-fan-out [implements] — root tag wins; sequential per-scope budgets; partial --scopes merges prior state
@@ -704,6 +713,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
704
713
  // Root-only feedback config (see loadScopeConfig): lets a reporter posting with
705
714
  // no explicit records match replies itself (annotate mode) at report time.
706
715
  feedback: rootConfig.feedback,
716
+ inline: rootConfig.inline,
707
717
  headSha,
708
718
  });
709
719
  // comment:'single' mode: every active scope's feedback seam AND the final
@@ -900,7 +910,12 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
900
910
  const aggFeedback = feedbackNeedsRunSeam(rootConfig.feedback)
901
911
  ? mergeAggregateFeedback(results, finalResults, aggState?.feedback ?? [], rootConfig.feedback, headSha, aggState?.pins)
902
912
  : undefined;
903
- await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback);
913
+ // A --scopes partial run carries the other scopes out of (possibly truncated)
914
+ // embedded state, so its target set is incomplete: inline teardown must not
915
+ // run (failed scopes are handled inside reportAggregate itself).
916
+ await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback, {
917
+ inlineTeardown: !scopesFilter,
918
+ });
904
919
  }
905
920
  // Clean up any per-scope comments from a previous per-scope run. A partial run
906
921
  // only ever touches the named scopes' comments.
@@ -84,6 +84,8 @@ export async function dismissCommand(argv, mode) {
84
84
  // feedback policy in force now — without it a reply-cleared finding would be
85
85
  // un-hidden by an unrelated /dismiss.
86
86
  feedback: config.feedback,
87
+ // Lets the dismissal stub/revive the finding's inline thread (additive-only).
88
+ inline: config.inline,
87
89
  });
88
90
  const result = await reporter.applyDismissal(mode === "add" ? args.ids : [], mode === "remove" ? args.ids : [], args.by, args.reason);
89
91
  if (mode === "add") {
@@ -1,5 +1,5 @@
1
1
  // @ref LLP 0007#doctor-and-setup-auth — a preflight that must never disagree with a real run
2
- import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
2
+ import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, CONFIG_DIRNAME, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
3
3
  import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
4
4
  import readline from "node:readline/promises";
5
5
  import { setupAuthCommand } from "./setup-auth.js";
@@ -198,7 +198,7 @@ export async function doctorCommand(argv = []) {
198
198
  info("gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)");
199
199
  }
200
200
  if (!hasConfig(root)) {
201
- line(false, `no ${".expo-code-review"}/config.jsonc (run \`ecr init\`)`);
201
+ line(false, `no ${CONFIG_DIRNAME}/config.jsonc (run \`ecr init\`)`);
202
202
  }
203
203
  else {
204
204
  try {
@@ -394,7 +394,7 @@ async function listScopes(root) {
394
394
  return;
395
395
  }
396
396
  if (!manifest) {
397
- process.stdout.write(`No ${".expo-code-review"}/routing.jsonc — run \`ecr init --monorepo\`.\n`);
397
+ process.stdout.write(`No ${CONFIG_DIRNAME}/routing.jsonc — run \`ecr init --monorepo\`.\n`);
398
398
  process.exitCode = 0;
399
399
  return;
400
400
  }
@@ -3,14 +3,14 @@ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { CONFIG_DIRNAME } from "../config/load.js";
6
+ import { CONFIG_DIRNAME, configDirFor } from "../config/load.js";
7
7
  import { ROUTING_FILENAME } from "../config/routing.js";
8
8
  import { RoutingScopeSchema } from "../config/schema.js";
9
9
  import { FORBIDDEN_TOKEN_ENVS } from "../core/auth.js";
10
10
  import { repoRoot } from "../core/exec.js";
11
11
  import { errorMessage } from "../core/util.js";
12
12
  const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
13
- const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
13
+ const USAGE = `ecr init — scaffold .expo-agents/code-review/ in the current repo (a legacy .expo-code-review/ is reused)
14
14
 
15
15
  Usage:
16
16
  ecr init [--no-workflow] [--force] Scaffold the root config (+ CI workflow)
@@ -19,8 +19,8 @@ Usage:
19
19
  register it in the root routing.jsonc
20
20
 
21
21
  Options:
22
- --monorepo Also write .expo-code-review/routing.jsonc (routing manifest)
23
- --scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
22
+ --monorepo Also write routing.jsonc in the setup dir (routing manifest)
23
+ --scope <dir> Scaffold <dir>/.expo-agents/code-review/ (no auth) + add a scope entry
24
24
  --no-workflow Skip writing the CI workflows (review, command, and dismiss
25
25
  under .github/workflows/)
26
26
  --token-env <name[,name…]>
@@ -76,7 +76,7 @@ async function scaffold(argv) {
76
76
  throw new Error("--token-env customizes the CI workflows; drop it or remove --no-workflow");
77
77
  }
78
78
  const root = (await repoRoot()) ?? process.cwd();
79
- const configDir = path.join(root, CONFIG_DIRNAME);
79
+ const configDir = configDirFor(root); // an existing legacy .expo-code-review/ is kept, never doubled
80
80
  // A non-default --token-env only takes effect through the review workflows,
81
81
  // but existing workflow files are skipped (not rewritten) without --force —
82
82
  // the flag would silently never reach CI while the next steps claim the
@@ -201,7 +201,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
201
201
  throw new Error(`scope dir "${scopeDir}" yields an invalid scope entry (${parsed.error.issues[0]?.message}); ` +
202
202
  `rename the directory or add the scope to ${CONFIG_DIRNAME}/${ROUTING_FILENAME} manually`);
203
203
  }
204
- const configDir = path.join(root, scopeDir, CONFIG_DIRNAME);
204
+ const configDir = configDirFor(path.join(root, scopeDir));
205
205
  await mkdir(configDir, { recursive: true });
206
206
  const created = [];
207
207
  const skipped = [];
@@ -113,6 +113,7 @@ export async function postReviewCommand(argv) {
113
113
  breakGlassMarker: config.breakGlassMarker,
114
114
  cwd,
115
115
  feedback: config.feedback,
116
+ inline: config.inline,
116
117
  headSha,
117
118
  });
118
119
  const result = await publishDeferredReview(reporter, artifact, async () => {
@@ -242,6 +242,8 @@ export async function reviewCommand(argv) {
242
242
  cwd,
243
243
  // Root-only feedback config (loadScopeConfig inherits it from the root).
244
244
  feedback: config.feedback,
245
+ // Root-only too (loadScopeConfig inherits it from the root).
246
+ inline: config.inline,
245
247
  headSha: await reviewedHeadSha(source),
246
248
  })
247
249
  : null;
@@ -315,6 +317,7 @@ export async function reviewCommand(argv) {
315
317
  breakGlassMarker: config.breakGlassMarker,
316
318
  cwd,
317
319
  feedback: config.feedback,
320
+ inline: config.inline,
318
321
  headSha,
319
322
  })
320
323
  : null;
@@ -1,7 +1,7 @@
1
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
2
2
  import { readdir, readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
4
+ import { CONFIG_DIRNAME, configDirFor, isConfigDirPath, stripJsonComments, stripTrailingCommas, } from "../config/load.js";
5
5
  import { ROUTING_FILENAME } from "../config/routing.js";
6
6
  import { repoRoot } from "../core/exec.js";
7
7
  import { errorMessage } from "../core/util.js";
@@ -52,9 +52,7 @@ async function discoverConfigFiles(root) {
52
52
  }
53
53
  await walk(path.join(dir, entry.name));
54
54
  }
55
- else if (entry.isFile() &&
56
- path.basename(dir) === CONFIG_DIRNAME &&
57
- CONFIG_FILENAMES.has(entry.name)) {
55
+ else if (entry.isFile() && isConfigDirPath(dir) && CONFIG_FILENAMES.has(entry.name)) {
58
56
  found.push(path.join(dir, entry.name));
59
57
  }
60
58
  }
@@ -112,7 +110,7 @@ function extractFacts(file, parsed) {
112
110
  */
113
111
  export async function verifyConfig(root, options = {}) {
114
112
  const findings = [];
115
- const rootConfigDir = path.join(root, CONFIG_DIRNAME);
113
+ const rootConfigDir = configDirFor(root);
116
114
  const rel = (file) => path.relative(root, file) || path.basename(file);
117
115
  const files = await discoverConfigFiles(root);
118
116
  const tokenEnvOccurrences = [];
@@ -7,7 +7,22 @@ import { existsSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { ReviewConfigSchema, ScopeReviewConfigSchema } from "./schema.js";
9
9
  import { toolMap } from "../core/tools.js";
10
- export const CONFIG_DIRNAME = ".expo-code-review";
10
+ /**
11
+ * The setup directory. `.expo-agents/code-review/` is the home from 0.15.0 —
12
+ * one `.expo-agents/` directory holds every Expo agent tool's per-repo files
13
+ * (code-review, verify) instead of one dot directory per tool at the root.
14
+ * The pre-0.15 `.expo-code-review/` keeps working unchanged: every lookup
15
+ * takes whichever of the two exists (new first), and scaffolding writes into
16
+ * an existing legacy directory rather than starting a second one.
17
+ */
18
+ export const CONFIG_DIRNAME = path.join(".expo-agents", "code-review");
19
+ export const LEGACY_CONFIG_DIRNAME = ".expo-code-review";
20
+ export const CONFIG_DIRNAMES = [CONFIG_DIRNAME, LEGACY_CONFIG_DIRNAME];
21
+ /** Does this directory path name a setup dir (either spelling)? */
22
+ export function isConfigDirPath(dir) {
23
+ const normalized = path.normalize(dir);
24
+ return CONFIG_DIRNAMES.some((name) => normalized === name || normalized.endsWith(path.sep + name));
25
+ }
11
26
  /** Stack config for a scope load (where `stack` is schema-rejected and absent). */
12
27
  const STACK_CONFIG_DEFAULTS = {
13
28
  enabled: false,
@@ -26,6 +41,11 @@ const FEEDBACK_CONFIG_DEFAULTS = {
26
41
  protectedCategories: ["secrets", "security"],
27
42
  maxAdjudications: 10,
28
43
  };
44
+ /** Inline-comment config for a scope load (where `inline` is schema-rejected). */
45
+ const INLINE_CONFIG_DEFAULTS = {
46
+ enabled: false,
47
+ maxComments: 20,
48
+ };
29
49
  /** Research defaults for a scope load (where `research` is schema-rejected). */
30
50
  const RESEARCH_CONFIG_DEFAULTS = {
31
51
  enabled: false,
@@ -35,21 +55,33 @@ const RESEARCH_CONFIG_DEFAULTS = {
35
55
  };
36
56
  /** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
37
57
  const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
38
- export function configDirFor(repoRoot) {
39
- return path.join(repoRoot, CONFIG_DIRNAME);
58
+ /**
59
+ * The setup dir under `base`: `.expo-agents/code-review/` when it exists, else
60
+ * the legacy `.expo-code-review/` when THAT exists, else the new name (for
61
+ * messages and for scaffolding a fresh repo). Works for the repo root and for
62
+ * scope directories alike.
63
+ */
64
+ export function configDirFor(base) {
65
+ for (const name of CONFIG_DIRNAMES) {
66
+ const candidate = path.join(base, name);
67
+ if (existsSync(candidate)) {
68
+ return candidate;
69
+ }
70
+ }
71
+ return path.join(base, CONFIG_DIRNAME);
40
72
  }
41
73
  /**
42
74
  * Resolve the config directory: an explicit override (absolute or repo-relative)
43
- * → the ECR_CONFIG_DIR env var → the repo's default `.expo-code-review/`. This is
44
- * the escape hatch (graft 1); with neither override present the result is exactly
45
- * `configDirFor(repoRoot)`, so the default behavior is byte-identical.
75
+ * → the ECR_CONFIG_DIR env var → the repo's default setup dir (new name, then
76
+ * legacy). This is the escape hatch (graft 1); with neither override present the
77
+ * result is exactly `configDirFor(repoRoot)`.
46
78
  */
47
79
  export function resolveConfigDir(repoRoot, override) {
48
80
  const chosen = override ?? process.env.ECR_CONFIG_DIR;
49
81
  if (chosen) {
50
82
  return path.isAbsolute(chosen) ? chosen : path.join(repoRoot, chosen);
51
83
  }
52
- return path.join(repoRoot, CONFIG_DIRNAME);
84
+ return configDirFor(repoRoot);
53
85
  }
54
86
  export function hasConfig(repoRoot, options = {}) {
55
87
  // Resolve the same way loadReviewConfig does (incl. the ECR_CONFIG_DIR escape
@@ -162,6 +194,8 @@ async function loadConfigDir(dir, schema) {
162
194
  // for a scope config and the defaults stand in (unused — the command layer
163
195
  // reads the ROOT config's feedback values; the comment lifecycle is global).
164
196
  feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
197
+ // Root-only, same reasoning as feedback.
198
+ inline: parsed.inline ?? INLINE_CONFIG_DEFAULTS,
165
199
  };
166
200
  return { config, raw: rawObject };
167
201
  }
@@ -231,7 +265,7 @@ export function hasScopeConfig(root, scope) {
231
265
  if (scope.config === ".") {
232
266
  return true;
233
267
  }
234
- const dir = path.join(root, scope.config, CONFIG_DIRNAME);
268
+ const dir = configDirFor(path.join(root, scope.config));
235
269
  return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
236
270
  }
237
271
  /**
@@ -258,7 +292,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
258
292
  if (resolvedScope !== resolvedRoot && !resolvedScope.startsWith(resolvedRoot + path.sep)) {
259
293
  throw new Error(`scope "${scope.name}": config "${scope.config}" resolves outside the repo checkout`);
260
294
  }
261
- const dir = path.join(root, scope.config, CONFIG_DIRNAME);
295
+ const dir = configDirFor(path.join(root, scope.config));
262
296
  const { config } = await loadConfigDir(dir, ScopeReviewConfigSchema);
263
297
  base = config;
264
298
  // Non-default scopes never carry their own marker (the scope schema rejects
@@ -296,6 +330,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
296
330
  // run the default policy instead of the repo's real one.
297
331
  stack: rootConfig.stack,
298
332
  feedback: rootConfig.feedback,
333
+ inline: rootConfig.inline,
299
334
  research: rootConfig.research,
300
335
  scopeName: scope.name,
301
336
  };
@@ -216,6 +216,20 @@ export const ReviewConfigSchema = z.object({
216
216
  protectedCategories: ["secrets", "security"],
217
217
  maxAdjudications: 10,
218
218
  }),
219
+ // Inline PR review comments for findings anchored to a line in the diff: the
220
+ // main comment stays the durable state store and renders such findings short,
221
+ // linking to the inline thread. ROOT-ONLY (the comment lifecycle is global) and
222
+ // off by default — a feature that mutates N comments per run earns trust with
223
+ // field data first. Turning it off later leaves existing threads up (documented);
224
+ // clear() still sweeps them on comment-mode switches.
225
+ inline: z
226
+ .object({
227
+ enabled: z.boolean().default(false),
228
+ // Cap on inline comments PER REPORTER: per PR in single/legacy comment mode,
229
+ // per scope in per-scope mode. Bounds create-notification fan-out and API use.
230
+ maxComments: z.number().int().positive().default(20),
231
+ })
232
+ .default({ enabled: false, maxComments: 20 }),
219
233
  });
220
234
  /** One routing scope: ordered globs → a directory containing .expo-code-review/. */
221
235
  export const RoutingScopeSchema = z.object({
@@ -317,6 +331,7 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
317
331
  stack: true,
318
332
  feedback: true,
319
333
  research: true,
334
+ inline: true,
320
335
  }).extend({
321
336
  auth: z
322
337
  .never({ error: "auth is locked to the root config; remove it from this scope config" })
@@ -342,4 +357,9 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
342
357
  error: "research is locked to the root config because it starts a trusted host process; remove it from this scope config",
343
358
  })
344
359
  .optional(),
360
+ inline: z
361
+ .never({
362
+ error: "inline is locked to the root config (the comment lifecycle is global); remove it from this scope config",
363
+ })
364
+ .optional(),
345
365
  });
@@ -3,7 +3,7 @@
3
3
  // @ref LLP 0012#unannotated-citations-are-broken-refs — a path-like backtick token that is not a ref fails the check
4
4
  import { readdir, readFile, stat } from "node:fs/promises";
5
5
  import path from "node:path";
6
- import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
6
+ import { CONFIG_DIRNAME, LEGACY_CONFIG_DIRNAME, configDirFor, stripJsonComments, stripTrailingCommas, } from "../config/load.js";
7
7
  import { ROUTING_FILENAME } from "../config/routing.js";
8
8
  import { git, pathInside } from "./exec.js";
9
9
  import { matchesIgnore } from "./noise.js";
@@ -448,10 +448,18 @@ export async function discoverSetupDirs(root) {
448
448
  continue;
449
449
  }
450
450
  const child = path.join(dir, entry.name);
451
- if (entry.name === CONFIG_DIRNAME) {
451
+ if (entry.name === LEGACY_CONFIG_DIRNAME) {
452
452
  found.push(child);
453
453
  continue; // a setup dir never nests another
454
454
  }
455
+ if (entry.name === ".expo-agents") {
456
+ // The shared Expo agent-tools dir: only its code-review/ child is ours.
457
+ const setup = path.join(dir, CONFIG_DIRNAME);
458
+ if ((await pathKind(setup)) === "dir") {
459
+ found.push(setup);
460
+ }
461
+ continue;
462
+ }
455
463
  // Other dot dirs are skipped wholesale: `.claude/worktrees/` holds checkouts of
456
464
  // this same repo, whose setup dirs would otherwise be swept as if they were scopes.
457
465
  if (entry.name === "node_modules" || entry.name.startsWith(".")) {
@@ -542,7 +550,7 @@ async function checkRoutingManifest(file, index, rootAgentIds) {
542
550
  }
543
551
  const configDir = typeof entry.config === "string" ? entry.config : null;
544
552
  if (configDir) {
545
- const setupDir = path.resolve(index.root, configDir, CONFIG_DIRNAME);
553
+ const setupDir = configDirFor(path.resolve(index.root, configDir));
546
554
  if (!pathInside(setupDir, index.root)) {
547
555
  problems.push({
548
556
  file: relative,
@@ -556,7 +564,7 @@ async function checkRoutingManifest(file, index, rootAgentIds) {
556
564
  file: relative,
557
565
  line: 1,
558
566
  kind: "structural",
559
- problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ directory`,
567
+ problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ (or ${LEGACY_CONFIG_DIRNAME}/) directory`,
560
568
  });
561
569
  }
562
570
  }
@@ -33,6 +33,10 @@ export function reviewPostingConfigFingerprint(config) {
33
33
  commentTag: config.commentTag,
34
34
  breakGlassMarker: config.breakGlassMarker,
35
35
  feedback: config.feedback,
36
+ // Inline comments change what posting does (N review-comment mutations), so a
37
+ // saved review must not post under a different inline policy than it was
38
+ // saved with.
39
+ inline: config.inline,
36
40
  }))
37
41
  .digest("hex");
38
42
  }