Compare commits

...
6 Commits
Author SHA1 Message Date
Brent Vatne 75fccfe3f4 fix: strip HTML tags from the inline-finding excerpt in the main comment
The short-form excerpt escaped every <, so a model-written <br> showed as
literal text. Remove HTML formatting tags first, then escape what is left.
2026-10-01 16:28:09 -07:00
Brent VatneandClaude Opus 5.5 621c354568 fix: load only the granted read tools in Claude Code review passes
`--permission-mode dontAsk` rejects only tools that ask for permission. Against
Claude Code 2.1.280, a review pass could still run EnterWorktree (it created a
git worktree), RemoteTrigger (it listed the logged-in account's cloud
routines), SendMessage, and Skill, because none of them is on the deny list and
none asks for permission.

buildClaudeArgs now passes `--tools` with exactly the granted read tools
(`Read,Grep,Glob` or fewer, and "" for the coordinator). The CLI loads only
those built-ins, so a tool a future CLI version adds is never loaded. MCP tools
and the StructuredOutput tool used by --json-schema stay loaded. The scoped
allow rules, dontAsk, and ALWAYS_DENIED_TOOLS stay as inner layers.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 10:03:50 -07:00
Brent VatneandClaude Opus 5.5 3af45ebf84 feat: default to Claude Opus 5.5 and install Claude Code 2.1.280
The scaffolded default model moves from anthropic/claude-sonnet-5 to
anthropic/claude-opus-5-5, and the security and coordinator pins move from
claude-opus-5 to claude-opus-5-5. Claude Code 2.1.212 rejects Opus 5.5
("version 2.1.280 or newer is required"), so every workflow template installs
2.1.280.

The CLI bump re-checked the deny list: 2.1.280 adds ListAgents, which is now
in ALWAYS_DENIED_TOOLS; every other denied name stays hidden.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-23 11:34:00 -07:00
Peter Abbondanzo 65ffc81014 feat: add Muse Spark 1.3 support (#77) 2026-09-06 15:52:03 -07:00
Brent Vatne 92257f9bc9 Setup dir moves to .expo-agents/code-review; .expo-code-review stays supported
One .expo-agents/ directory now holds every Expo agent tool's per-repo
files (this tool's and @expo/verify's) instead of one root dot directory
per tool. Every lookup — root, scopes, the config-file sweep, the reference
index, the .runs log — takes the new name first and falls back to the
legacy .expo-code-review/, and ecr init reuses an existing legacy directory
rather than doubling it. The CI templates upload reviews.jsonl from either
location. This repo's own setup dir moved.
2026-08-25 23:06:35 -07:00
Brent Vatne 047d360b1b test: deflake the process-group kill test on loaded CI runners
kill(pid, 0) succeeds while the killed grandchild is still a zombie awaiting
init's reap — the direct sh dies first, the orphaned sleep reparents, and
reaping is asynchronous. Fast machines won that race; loaded release runners
lost it twice (v0.14.1, v0.14.2). Poll until the pid is gone or its ps state
is Z, bounded at 5s — a sleep 30 that genuinely survived the group kill still
fails the assertion.
2026-08-21 13:09:44 -07:00
35 changed files with 389 additions and 147 deletions

No files matched your search

File renamed without changes.
@@ -3,7 +3,7 @@ description: Security and secrets. Injection, credential or secret leakage, unsa
alwaysRun: true
# Security is the highest-stakes agent, so it runs on the Opus tier (included in
# the Max/Team subscription the claude-code engine authenticates with).
model: anthropic/claude-opus-5
model: anthropic/claude-opus-5-5
---
# Security & secrets
@@ -2,13 +2,13 @@
// Default model for every agent. Override per-agent via frontmatter in the
// agent's markdown, or at runtime with the REVIEWER_MODEL env var
// (e.g. REVIEWER_MODEL=anthropic/claude-haiku-4-5).
"model": "anthropic/claude-sonnet-5",
"model": "anthropic/claude-opus-5-5",
// Agents: every markdown file in agents/ is one reviewer (id = filename).
// Add or remove files to change the roster — no list needed here.
// shared.md (prepended to every agent + coordinator) and coordinator.md are
// reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
// e.g. `---\nmodel: anthropic/claude-opus-5\n---`.
// e.g. `---\nmodel: anthropic/claude-opus-5-5\n---`.
"policy": {
// Phase 1: keep signal high by surfacing only critical/warning.
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
# like `opencode-ai` in package.json — an unpinned CLI would silently drift
# on every CI run; bump it deliberately.
- name: Install Claude Code CLI
run: bun install -g @anthropic-ai/[email protected]12
run: bun install -g @anthropic-ai/[email protected]80
# SECURITY: the TRUSTED BASE checkout above includes .expo-code-review/
# config.jsonc, whose auth.tokenEnv names the env var the CLI forwards as the
+1
View File
@@ -9,3 +9,4 @@ build/
# Claude Code local worktrees
.claude/worktrees/
.expo-agents/code-review/.runs/
+43 -33
View File
@@ -4,7 +4,10 @@ A config-driven, multi-agent AI code reviewer. Specialist agents review a diff i
parallel; a coordinator consolidates their findings into one structured review.
The same engine runs locally (advisory) and in CI (posts one PR comment). The CLI
is the **engine** — each repo supplies its own agents and settings under
`.expo-code-review/`, so behavior is configured per-repo, not baked in.
`.expo-agents/code-review/`, so behavior is configured per-repo, not baked in.
(Repos set up before 0.15 keep their `.expo-code-review/` directory: every command
looks for the new name first and falls back to the old one, and `ecr init` reuses
an existing legacy directory rather than starting a second one.)
> **Status: experimental.** Comment-only and non-blocking — it never blocks a merge
> 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:
## Usage
Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
binary once installed). On a repo that already has `.expo-code-review/` set up,
binary once installed). On a repo that already has `.expo-agents/code-review/` set up,
getting model credentials for local runs is one command:
```bash
@@ -108,7 +111,7 @@ package.
### First-time setup
```bash
# 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
# 1. Scaffold .expo-agents/code-review/ + a CI workflow (--no-workflow to skip)
npx @expo/code-review-cli init
# 2. Get model credentials — guided; prints the export lines for your shell config
npx @expo/code-review-cli setup-auth
@@ -142,7 +145,7 @@ ecr review --pr 123
ecr review --pr 123 --post
# Preview once, save the exact result, and post it later without another model run
ecr review --repo owner/repo --pr 123 --save-review --json
ecr post-review --artifact .expo-code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
ecr post-review --artifact .expo-agents/code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
```
Options (most to least common):
@@ -178,7 +181,7 @@ is a ready example to adapt.
| Command | What it does |
| --- | --- |
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-agents/code-review/` (config, agents, prompts) + a CI workflow. |
| `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
| `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
| `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):
A target is a file, a `dir/`, `glob:<pattern>`, `file#symbol`, or `doc.md#heading` —
never a line number (line numbers rot without any signal). The check is strict on
purpose: any backticked token in `.expo-code-review/` that names something that
purpose: any backticked token in `.expo-agents/code-review/` that names something that
exists in the repo **must** be a ref, because stale citations are exactly the ones
nobody annotated. Mark the rare false positive once with
`<!-- @ref-ignore knex.raw() -->`. Refs are always repo-root-relative, including in
@@ -291,18 +294,18 @@ and no locking.
```
your-monorepo/
.expo-code-review/
.expo-agents/code-review/
routing.jsonc # the manifest — infra-owned, ordered scope list + locked defaults
config.jsonc # the default/root scope; the ONLY place auth/tokenEnv lives
shared.md coordinator.md agents/
apps/
api/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # api team
web/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # web team
api/.expo-agents/code-review/{config.jsonc(NO auth),coordinator.md,agents/} # api team
web/.expo-agents/code-review/{config.jsonc(NO auth),coordinator.md,agents/} # web team
.github/workflows/expo-code-review.yml # unchanged shape: one workflow, one `ecr ci`
```
```jsonc
// .expo-code-review/routing.jsonc
// .expo-agents/code-review/routing.jsonc
{
// Central guardrails every scope inherits and CANNOT override.
"defaults": {
@@ -322,10 +325,10 @@ your-monorepo/
```
```jsonc
// apps/api/.expo-code-review/config.jsonc (the api team owns this)
// apps/api/.expo-agents/code-review/config.jsonc (the api team owns this)
{
// NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
"model": "anthropic/claude-sonnet-5",
"model": "anthropic/claude-opus-5-5",
"policy": { "includeSuggestions": false },
"noise": { "additionalIgnores": ["apps/api/**/__generated__/**"] }
// shared.md, coordinator.md, agents/*.md live beside this file — the api team's roster.
@@ -378,8 +381,8 @@ Enforced in code and by an independent CI guard step, not by convention:
itself, so even a custom workflow that checks out the head still gets
base-commit configuration.
Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`,
`/apps/api/.expo-code-review/ @your-api-team`. Full detail:
Enforce ownership with CODEOWNERS: `/.expo-agents/code-review/routing.jsonc @your-infra`,
`/apps/api/.expo-agents/code-review/ @your-api-team`. Full detail:
[LLP 0006](./llp/0006-config-schema-loading-routing.explainer.md) (routing) and
[LLP 0001](./llp/0001-trust-model.principles.md) (trust model).
@@ -396,7 +399,7 @@ Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra
- **Chunking** — small PRs run in a single pass; large PRs are split into chunks
bounded by changed lines, plus one combined **cross-cutting pass** that looks
for issues spanning multiple changed files across every agent's concern.
- **Agents** — every `.md` file in `.expo-code-review/agents/` is an agent. They
- **Agents** — every `.md` file in `.expo-agents/code-review/agents/` is an agent. They
run in parallel with read-only repo tools (`read`/`grep`/`glob`/`list`).
- **Coordinator** — a single pass that dedupes, re-judges severity, and produces
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
three places: one `Token usage — …` line in the job log, a per-pass table + cache
hit rate in the GitHub Actions step summary (which also preserves each run's posted
comment, since the PR comment is updated in place), and one JSON line per run in
`.expo-code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
`.expo-agents/code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
tokens, raw per-agent findings, bounded reviewer traces, and coverage notes.
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.
</details>
<details>
<summary><b>Configuration — <code>.expo-code-review/</code></b></summary>
<summary><b>Configuration — <code>.expo-agents/code-review/</code></b></summary>
```
.expo-code-review/
.expo-agents/code-review/
config.jsonc # model, policy, noise, auth, break-glass, comment tag
shared.md # instructions prepended to every agent (optional)
coordinator.md # the consolidation prompt (required)
@@ -472,7 +475,7 @@ cacheable size (~1–4K tokens) show `cache read 0` — expected, not a bug.
---
description: One line the router uses to decide relevance.
alwaysRun: true # run even when the router would skip this agent
model: anthropic/claude-opus-5 # override the default model
model: anthropic/claude-haiku-4-5 # override the default model
temperature: 0.1
---
@@ -480,7 +483,7 @@ temperature: 0.1
```
For a real-world example, see eas-cli's
[`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
[`.expo-agents/code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
— correctness/security/consistency agents, a stronger model for security, and
per-repo `noise.additionalIgnores`.
@@ -488,7 +491,7 @@ per-repo `noise.additionalIgnores`.
```jsonc
{
"model": "anthropic/claude-sonnet-5", // default model for the specialists
"model": "anthropic/claude-opus-5-5", // default model for the specialists
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
"chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
"noise": { "additionalIgnores": ["packages/*/build/**"] },
@@ -511,15 +514,17 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
`model:`** → **`config.jsonc` `model`** (the default). So a repo can run a mixed
setup, and a developer can override everything locally.
- **Specialist agents** (correctness/security/consistency) benefit from a
reasoning-tier model — **`anthropic/claude-sonnet-5`** is the quality/speed
sweet spot (the scaffolded default). The **Opus tier** finds more but is slower
and more expensive, so scope it to the highest-stakes agent: **security runs on
`anthropic/claude-opus-5`** (set in `security.md` frontmatter), the rest on the
default.
- **Specialist agents** (correctness/security/consistency) run on
**`anthropic/claude-opus-5-5`**, the scaffolded default. It finds more than the
Sonnet tier but is slower and costs about twice as much per token, so
`anthropic/claude-sonnet-5` is the step down when a repo needs faster or cheaper
reviews. **Security stays pinned to `anthropic/claude-opus-5-5`** in `security.md`
frontmatter, so a lower default does not move the highest-stakes agent.
- **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
strong model; the scaffold pins it to `anthropic/claude-opus-5` in
strong model; the scaffold pins it to `anthropic/claude-opus-5-5` in
`coordinator.md` frontmatter.
- **Opus 5.5 needs Claude Code 2.1.280 or newer.** Older CLIs reject it with a 400,
so keep the workflow's `@anthropic-ai/claude-code` pin at 2.1.280 or later.
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
(e.g. `anthropic/claude-haiku-4-5`) is the most direct lever (a real recall
tradeoff — measure it).
@@ -718,7 +723,7 @@ variables: `ATLANTIS_BOT_LOGIN` (the Atlantis bot's comment login, e.g.
<details>
<summary><b>Run logs</b></summary>
Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
Each run appends a JSON line to `.expo-agents/code-review/.runs/reviews.jsonl` with the
inputs, decision, finding count, duration, per-agent cost, and aggregate token
usage (incl. prompt-cache read/write counts) — for auditing and measuring
cost/latency/cache reuse over time. It also records the same bounded `reviewTrace`
@@ -727,7 +732,7 @@ one-line summary to the terminal / CI job log at the end of each run, so cache r
is visible even in CI (where the run log is ephemeral).
`ecr review --save-review` additionally writes a versioned artifact under
`.expo-code-review/.runs/deferred/` with owner-only permissions. It contains the
`.expo-agents/code-review/.runs/deferred/` with owner-only permissions. It contains the
verified final review and bounded feedback metadata, but no credential. `ecr
post-review` schema-validates it and refuses to post if its explicit repo/PR, live
head commit, or local comment-policy fingerprint no longer matches.
@@ -760,20 +765,25 @@ fallback key; `ecr doctor` diagnoses setup.
} }
```
- **Meta / Muse Spark 1.2 (public Model API).** Set the default model and any
model-pinned agent frontmatter to `meta/muse-spark-1.2`. ECR supplies the fixed
- **Meta / Muse Spark 1.3 (public Model API).** Set the default model and any
model-pinned agent frontmatter to `meta/muse-spark-1.3`. ECR supplies the fixed
public Responses endpoint and the `@ai-sdk/openai` adapter; the generic
Chat-Completions compatibility adapter is intentionally not used because it
loses Muse's reasoning continuity across tool turns. Standard and Contributor
model ids are supported.
```jsonc
"model": "meta/muse-spark-1.2",
"model": "meta/muse-spark-1.3",
"auth": { "providers": {
"meta": { "mode": "api-key", "tokenEnv": "META_API_KEY" }
} }
```
Standard and Contributor 1.2/1.3 model ids have known capability metadata. Any
future model explicitly named as `meta/<model>` is still routed to the fixed Meta
endpoint with neutral metadata, so a renamed family such as `meta/muse-foo` does
not require an ECR release; Meta validates an unknown id on its first request.
Create the key in the [Meta AI developer portal](https://developer.meta.com/ai/).
Locally, export it as `META_API_KEY`. In each review-running workflow, replace
the Anthropic credential line with
@@ -109,12 +109,15 @@ the Responses API and preserve encrypted reasoning across tool turns. The generi
Completions path loses that continuity [observed:
`opencode.ts` `META_MODEL_API_BASE_URL` / `META_MUSE_MODELS` / `buildOpencodeConfig`].
The declared model catalog is an allowlist (`muse-spark-1.2` and
`muse-spark-1.2-contributor`), not a reflection of arbitrary config strings. This keeps
the existing preflight honest: a misspelled or not-yet-supported Meta model remains
absent from the server's provider model list and fails once before any pass. Both models
declare their public context/output limits, multimodal inputs, high reasoning, and
encrypted reasoning inclusion [observed: `opencode.ts` `META_MUSE_MODELS`].
Provider selection is forward-compatible at the provider boundary, not coupled to the
current Muse family name: every referenced non-empty `meta/<model>` id is declared on
the synthesized Meta provider. Known Muse Spark 1.2 and 1.3 standard/Contributor ids
receive their published limits, multimodal inputs, high reasoning, and encrypted
reasoning inclusion from `META_MUSE_MODELS`; a future id such as `meta/muse-foo` is
declared with neutral metadata rather than guessed capabilities. This deliberately
moves validation of an unknown-but-well-formed Meta id from OpenCode's local preflight
to its first Meta request, in exchange for new Meta names working without an ECR
release [observed: `opencode.ts` `META_MUSE_MODELS` / `buildOpencodeConfig`].
## Claude Code CLI Containment
@@ -171,12 +174,22 @@ reads are denied by the unmatched-rule denial, and a withheld tool is denied by
([observed] `claude-code.ts:241-321`; behavior verified against `claude 2.1.212`).
Write/exec/network tools (`Bash`, `Edit`, `Write`, `NotebookEdit`, `WebFetch`,
`WebSearch`, `Task`, …) are always denied for every pass regardless of role, via the
`ALWAYS_DENIED_TOOLS` enumeration. A deny **enumeration** is used rather than an
allow-only model because permission rules cannot fail closed for tools outside the read
set and a `*` deny breaks tool calling outright — both verified against `claude 2.1.212`.
The residual (a future CLI version shipping a new read-capable tool this list does not
name) is bounded by pinning the CLI version, so **the list must be revisited on every
pinned-version bump** ([observed] `claude-code.ts:86-111`).
`ALWAYS_DENIED_TOOLS` enumeration. Permission rules alone cannot fail closed for tools
outside the read set, and a `*` deny breaks tool calling outright — both verified
against `claude 2.1.212`.
**`--tools` is the outer bound.** `--tools` is not a permission rule: it names the
built-in tools the CLI **loads**. ecr passes exactly the granted read tools
(`Read,Grep,Glob` or fewer), and `""` for the coordinator and the no-tools fallback.
A tool that is not loaded cannot be called, so a new tool in a future CLI version
stays out without anyone adding its name. The deny list was not enough on its own:
against `claude 2.1.280`, `dontAsk` rejected `Monitor` and `Workflow`, but
`EnterWorktree` created a git worktree, `RemoteTrigger` listed the logged-in
account's cloud routines, and `SendMessage` and `Skill` ran, because those tools ask
no permission. MCP tools (the research tools) and the CLI's `StructuredOutput` tool
(used by `--json-schema`) are outside the built-in set and stay loaded — also verified
against `2.1.280`. The scoped allow rules and `ALWAYS_DENIED_TOOLS` stay as inner
layers ([observed] `claude-code.ts` `buildClaudeArgs`, `ALWAYS_DENIED_TOOLS`).
**The env is an allowlist.** The child env is built from `CHILD_ENV_ALLOWLIST` (PATH,
HOME, locale, tmp, proxies, config dirs — what a CLI needs and nothing else), never a
@@ -184,8 +184,7 @@ hardcoded to a read-only tool set (`read`/`grep`/`glob`/`list`) with no schema k
write/bash [observed: `load.ts:126-155`, `load.ts:22-23`].
`resolveConfigDir` is the escape hatch (graft 1): precedence is explicit param → `ECR_CONFIG_DIR` env
→ the repo's default `.expo-code-review/`, and with neither override the result is byte-identical to
the default path [observed: `load.ts:29-41`]. `hasConfig` resolves the config dir the same way, so
→ the repo's default setup dir [observed: `load.ts`]. **The default setup dir has two spellings** (0.15.0): `.expo-agents/code-review/` is the home — one `.expo-agents/` directory holds every Expo agent tool's per-repo files (this tool's and `@expo/verify`'s) instead of one root dot-directory per tool — and the pre-0.15 `.expo-code-review/` stays fully supported. `configDirFor(base)` returns whichever exists, new name first, and the new name when neither does; it is used for the root and for every scope directory, `ecr init` writes into an existing legacy directory rather than doubling it, the config-file sweep (`isConfigDirPath`) and the reference index accept both, and the CI templates upload `.runs/reviews.jsonl` from either location. `.agents/` was rejected as the shared name because Codex, Cursor, Gemini CLI and Copilot already scan `.agents/skills/`. `hasConfig` resolves the config dir the same way, so
`doctor`'s "no config" diagnostic can never disagree with what the loader actually reads
[observed: `load.ts:55-60`]. The override designates an alternate ROOT config dir, so `config.jsonc`
and `routing.jsonc` always travel together: `loadRoutingManifest` reads through the same
@@ -56,7 +56,7 @@ The three workflows share a posture built entirely around one fact: a reviewer j
**Concurrency defaults by auth mode.** The commented `chunk.concurrency` default is 6 for an API key and 3 for a subscription (OAuth) credential, "one account handles many parallel streams poorly, and several PRs may review on the same credential at once" [observed: `templates/config.jsonc:28-30`].
**Muse Spark is opt-in without weakening the default secret surface.** The root config
documents `meta/muse-spark-1.2` plus a `META_API_KEY` auth entry, and each
documents `meta/muse-spark-1.3` plus a `META_API_KEY` auth entry, and each
review-running workflow shows the corresponding secret line commented out. It is not
forwarded by the default scaffold: adopters replace the Anthropic line and set
`ECR_EXPECTED_TOKEN_ENV=META_API_KEY`, so the model process receives only the
+10 -2
View File
@@ -245,6 +245,9 @@ test("buildClaudeArgs: read-only, trust-isolated, subscription argv; never --bar
);
expect(joined).not.toContain(" Grep ");
expect(joined).not.toContain(" Glob ");
// --tools loads only the granted read tools, so an ungranted built-in (a new one
// in a future CLI included) never reaches the model.
expect(args[args.indexOf("--tools") + 1]).toBe("Read,Grep,Glob");
// Write/exec/net tools are denied outright; out-of-tree reads are denied by
// dontAsk's unmatched-rule denial (NO explicit Read deny: `Read(~/**)` would
// deny the whole tree whenever the repo lives under the home directory).
@@ -582,6 +585,7 @@ test("buildClaudeArgs: cross-cutting/verifier get Read+Grep scoped, Glob denied
// withheld read tool must be denied BY NAME, not just left out of --allowedTools.
expect(joined).toContain("--allowedTools Read(//work/repo/**) Grep(//work/repo/**)");
expect(joined).not.toContain("Glob(/");
expect(args[args.indexOf("--tools") + 1]).toBe("Read,Grep");
expect(joined).toContain(
"--disallowedTools Glob Bash Edit Write NotebookEdit NotebookRead WebFetch WebSearch Task TodoWrite BashOutput KillShell ExitPlanMode",
);
@@ -599,9 +603,9 @@ test("buildClaudeArgs: reviewer tools honor the configured read set (list ignore
"--allowedTools Read(//work/repo/**) Grep(//work/repo/**) Glob(//work/repo/**)",
);
// `list` has no scoped Claude equivalent and is silently ignored (no crash, no rule).
expect(joined).not.toContain("List");
expect(args.some((arg) => arg.startsWith("List("))).toBe(false);
expect(joined).toContain(
"--disallowedTools Bash Edit Write NotebookEdit NotebookRead WebFetch WebSearch Task TodoWrite BashOutput KillShell ExitPlanMode",
"--disallowedTools Bash Edit Write NotebookEdit NotebookRead WebFetch WebSearch Task TodoWrite BashOutput KillShell ExitPlanMode ListAgents",
);
});
@@ -616,6 +620,8 @@ test("buildClaudeArgs: empty tools (coordinator/no-tools fallback) deny every re
// No --allowedTools at all, and Read/Grep/Glob explicitly denied so dontAsk's
// default-allow can't grant a read.
expect(joined).not.toContain("--allowedTools");
// An empty --tools value loads no built-in tool at all.
expect(args[args.indexOf("--tools") + 1]).toBe("");
expect(joined).toContain(
"--disallowedTools Read Grep Glob Bash Edit Write NotebookEdit NotebookRead WebFetch WebSearch Task TodoWrite BashOutput KillShell ExitPlanMode",
);
@@ -643,6 +649,8 @@ test("buildClaudeArgs: explicit research MCP replaces safe mode without loading
expect(joined).toContain("mcp__platform_docs__search_platform_docs");
expect(joined).toContain("mcp__platform_docs__fetch_platform_doc");
expect(joined).toContain("--mcp-config /private/mcp.json");
// MCP tools sit outside the built-in set, so --tools does not remove them.
expect(args[args.indexOf("--tools") + 1]).toBe("Read");
expect(joined).toContain("--setting-sources --disable-slash-commands");
expect(joined).toContain("--strict-mcp-config");
expect(joined).toContain("--no-session-persistence");
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
CONFIG_DIRNAME,
LEGACY_CONFIG_DIRNAME,
configDirFor,
isConfigDirPath,
resolveConfigDir,
} from "../config/load.js";
describe("setup dir: .expo-agents/code-review with the legacy .expo-code-review fallback", () => {
test("a fresh repo resolves to the new name", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ecr-dir-"));
expect(configDirFor(root)).toBe(path.join(root, CONFIG_DIRNAME));
});
test("a pre-0.15 repo keeps using .expo-code-review unchanged", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ecr-dir-"));
await mkdir(path.join(root, LEGACY_CONFIG_DIRNAME), { recursive: true });
expect(configDirFor(root)).toBe(path.join(root, LEGACY_CONFIG_DIRNAME));
expect(resolveConfigDir(root)).toBe(path.join(root, LEGACY_CONFIG_DIRNAME));
});
test("when both exist the new name wins", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ecr-dir-"));
await mkdir(path.join(root, LEGACY_CONFIG_DIRNAME), { recursive: true });
await mkdir(path.join(root, CONFIG_DIRNAME), { recursive: true });
await writeFile(path.join(root, CONFIG_DIRNAME, "config.jsonc"), "{}");
expect(configDirFor(root)).toBe(path.join(root, CONFIG_DIRNAME));
});
test("an explicit override still beats both", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ecr-dir-"));
await mkdir(path.join(root, LEGACY_CONFIG_DIRNAME), { recursive: true });
expect(resolveConfigDir(root, "custom")).toBe(path.join(root, "custom"));
});
test("isConfigDirPath recognizes either spelling, nowhere else", () => {
expect(isConfigDirPath("/r/.expo-code-review")).toBe(true);
expect(isConfigDirPath("/r/apps/api/.expo-agents/code-review")).toBe(true);
expect(isConfigDirPath("/r/.expo-agents")).toBe(false);
expect(isConfigDirPath("/r/code-review")).toBe(false);
});
});
+3 -1
View File
@@ -503,7 +503,9 @@ test("setup problems are labelled by setup-dir position when the config lives ou
setupDirs: [path.join(trustedBase, ".expo-code-review")],
});
expect(report.problems).toHaveLength(1);
expect(report.problems[0]!.file).toBe(path.join(".expo-code-review", "agents", "security.md"));
expect(report.problems[0]!.file).toBe(
path.join(".expo-agents", "code-review", "agents", "security.md"),
);
});
test("citedPathsTouchedBy reports cited paths a PR changed, dirs included", async () => {
+35 -4
View File
@@ -50,6 +50,36 @@ test("run with input: timeout kills the child and resolves timedOut (no throw)",
expect(Date.now() - started).toBeLessThan(10_000);
});
/**
* Wait until `pid` no longer names a LIVE process. `kill(pid, 0)` alone is racy
* here: it still succeeds while the killed grandchild is a zombie awaiting init's
* reap (the direct `sh` dies first, the orphaned `sleep` reparents, and reaping is
* asynchronous) — which is exactly what flaked on loaded CI runners. A zombie is
* dead for this test's purpose, so a `ps` state of `Z` (or no row) counts as gone.
* A process that genuinely survived the group kill (`sleep 30`) outlives the whole
* poll window and still fails the assertion.
*/
async function processGone(pid: number, timeoutMs = 5000): Promise<boolean> {
const started = Date.now();
for (;;) {
try {
process.kill(pid, 0);
} catch {
return true; // ESRCH: fully gone
}
const state = (
await run("ps", ["-o", "state=", "-p", String(pid)], { check: false })
).stdout.trim();
if (state === "" || state.startsWith("Z")) {
return true; // reaped between the two checks, or dead-but-unreaped
}
if (Date.now() - started >= timeoutMs) {
return false;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
test("run with input: timeout kills the whole process group, not just the direct child", async () => {
const dir = await mkdtemp(join(tmpdir(), "ecr-exec-test-"));
const pidFile = join(dir, "grandchild.pid");
@@ -61,13 +91,14 @@ test("run with input: timeout kills the whole process group, not just the direct
});
expect(result.timedOut).toBe(true);
const grandchildPid = Number((await readFile(pidFile, "utf8")).trim());
// If only the direct `sh` had been signaled, this detached grandchild `sleep`
// would still be alive; process.kill throws ESRCH once the whole group is gone.
expect(() => process.kill(grandchildPid, 0)).toThrow();
// If only the direct `sh` had been signaled, this grandchild `sleep 30` would
// still be alive well past the poll window; the group kill leaves it dead (or
// momentarily zombie) within milliseconds.
expect(await processGone(grandchildPid)).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
}, 10_000);
test("run with input: escalates to SIGKILL after the grace period when the child traps SIGTERM", async () => {
const started = Date.now();
+10 -2
View File
@@ -89,9 +89,17 @@ test("inlineStubBody: neutral — never claims resolved or dismissed", () => {
// ---- oneLineRationale ----
test("oneLineRationale: collapses whitespace, escapes <, truncates at a word boundary", () => {
test("oneLineRationale: strips HTML tags, collapses whitespace, escapes <, truncates at a word boundary", () => {
expect(oneLineRationale("a\n\nb c")).toBe("a b c");
expect(oneLineRationale("<details>x</details>")).toBe("&lt;details>x&lt;/details>");
expect(oneLineRationale("<details>x</details>")).toBe("x");
expect(oneLineRationale("High — reason.<br>**Impact:** Medium<BR/>end")).toBe(
"High — reason. **Impact:** Medium end",
);
expect(oneLineRationale("<details><summary>Why</summary>\n\nbody</details>")).toBe("Why body");
// Non-formatting angle brackets stay, escaped.
expect(oneLineRationale("returns `Promise<void>` when a < b")).toBe(
"returns `Promise&lt;void>` when a &lt; b",
);
const long = Array.from({ length: 60 }, (_, i) => `word${i}`).join(" ");
const short = oneLineRationale(long);
expect(short.length).toBeLessThanOrEqual(161);
+29 -20
View File
@@ -342,10 +342,10 @@ test("an upstream-alias auth entry synthesizes a provider block with exactly the
expect(opencode.provider?.openai).toBeUndefined();
});
test("Meta auth synthesizes the public Muse Responses provider without embedding its key", () => {
test("Meta auth synthesizes the public Muse 1.3 Responses provider without embedding its key", () => {
const config = configWith({
agents: [{ id: "correctness", model: "meta/muse-spark-1.2" }],
coordinatorModel: "meta/muse-spark-1.2",
agents: [{ id: "correctness", model: "meta/muse-spark-1.3" }],
coordinatorModel: "meta/muse-spark-1.3-contributor",
auth: [{ mode: "api-key", provider: "meta", tokenEnv: "META_API_KEY" }],
});
const opencode = buildOpencodeConfig(config) as {
@@ -354,7 +354,14 @@ test("Meta auth synthesizes the public Muse Responses provider without embedding
{
npm: string;
options: { baseURL: string; apiKey: string };
models: Record<string, { reasoning?: boolean; options?: Record<string, unknown> }>;
models: Record<
string,
{
reasoning?: boolean;
limit?: { context: number; output: number };
options?: Record<string, unknown>;
}
>;
}
>;
};
@@ -365,12 +372,17 @@ test("Meta auth synthesizes the public Muse Responses provider without embedding
baseURL: META_MODEL_API_BASE_URL,
apiKey: "{env:META_API_KEY}",
});
expect(Object.keys(meta!.models)).toEqual(["muse-spark-1.2"]);
expect(meta!.models["muse-spark-1.2"]?.reasoning).toBe(true);
expect(meta!.models["muse-spark-1.2"]?.options).toMatchObject({
expect(Object.keys(meta!.models)).toEqual(["muse-spark-1.3", "muse-spark-1.3-contributor"]);
expect(meta!.models["muse-spark-1.3"]?.reasoning).toBe(true);
expect(meta!.models["muse-spark-1.3"]?.limit).toEqual({
context: 1_048_576,
output: 1_048_576,
});
expect(meta!.models["muse-spark-1.3"]?.options).toMatchObject({
reasoningEffort: "high",
include: ["reasoning.encrypted_content"],
});
expect(meta!.models["muse-spark-1.3-contributor"]?.reasoning).toBe(true);
});
test("a Meta-named upstream alias does not redirect the upstream credential to Meta", () => {
@@ -400,25 +412,22 @@ test("a Meta-named upstream alias does not redirect the upstream credential to M
expect(Object.keys(alias!.models)).toEqual(["muse-spark-1.2"]);
});
test("Meta provider does not register an unknown Muse id, so preflight can reject it", () => {
test("Meta provider registers future model families neutrally and only for the meta prefix", () => {
const config = configWith({
agents: [{ id: "correctness", model: "meta/muse-spark-typo" }],
coordinatorModel: "meta/muse-spark-typo",
agents: [
{ id: "correctness", model: "meta/muse-foo" },
{ id: "security", model: "openai/muse-foo" },
],
coordinatorModel: "meta/a-future-family-2",
auth: [{ mode: "api-key", provider: "meta", tokenEnv: "META_API_KEY" }],
});
const opencode = buildOpencodeConfig(config) as {
provider?: Record<string, { models: Record<string, unknown> }>;
};
expect(opencode.provider?.meta?.models).toEqual({});
expect(
findUnknownModels(["meta/muse-spark-typo"], { meta: ["muse-spark-1.2"] }, ["meta"]),
).toEqual([
{
model: "meta/muse-spark-typo",
reason: "model",
suggestions: ["muse-spark-1.2"],
},
]);
expect(opencode.provider?.meta?.models).toEqual({
"muse-foo": {},
"a-future-family-2": {},
});
});
test("buildOpencodeConfig registers the no-tools stack verifier (empty tool list)", () => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import {
CONFIG_DIRNAME,
configDirFor,
hasScopeConfig,
loadAuthFromRoot,
loadReviewConfig,
@@ -495,7 +495,7 @@ function resolveStackConfirm(
* `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
*/
function workspaceRunsDir(cwd: string): string {
return path.join(cwd, CONFIG_DIRNAME, ".runs");
return path.join(configDirFor(cwd), ".runs");
}
/**
+3 -4
View File
@@ -4,6 +4,7 @@ import {
loadScopeConfig,
loadAuthFromRoot,
hasConfig,
CONFIG_DIRNAME,
resolveConfigDir,
tokenEnvMismatch,
} from "../config/load.js";
@@ -246,7 +247,7 @@ export async function doctorCommand(argv: string[] = []): Promise<void> {
}
if (!hasConfig(root)) {
line(false, `no ${".expo-code-review"}/config.jsonc (run \`ecr init\`)`);
line(false, `no ${CONFIG_DIRNAME}/config.jsonc (run \`ecr init\`)`);
} else {
try {
// Reuse the engine-detection peek above; re-load only if that failed so
@@ -479,9 +480,7 @@ async function listScopes(root: string): Promise<void> {
return;
}
if (!manifest) {
process.stdout.write(
`No ${".expo-code-review"}/routing.jsonc — run \`ecr init --monorepo\`.\n`,
);
process.stdout.write(`No ${CONFIG_DIRNAME}/routing.jsonc — run \`ecr init --monorepo\`.\n`);
process.exitCode = 0;
return;
}
+6 -6
View File
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CONFIG_DIRNAME } from "../config/load.js";
import { CONFIG_DIRNAME, configDirFor } from "../config/load.js";
import { ROUTING_FILENAME } from "../config/routing.js";
import { RoutingScopeSchema, type RoutingScope } from "../config/schema.js";
import { FORBIDDEN_TOKEN_ENVS } from "../core/auth.js";
@@ -13,7 +13,7 @@ import { errorMessage } from "../core/util.js";
const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
const USAGE = `ecr init — scaffold .expo-agents/code-review/ in the current repo (a legacy .expo-code-review/ is reused)
Usage:
ecr init [--no-workflow] [--force] Scaffold the root config (+ CI workflow)
@@ -22,8 +22,8 @@ Usage:
register it in the root routing.jsonc
Options:
--monorepo Also write .expo-code-review/routing.jsonc (routing manifest)
--scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
--monorepo Also write routing.jsonc in the setup dir (routing manifest)
--scope <dir> Scaffold <dir>/.expo-agents/code-review/ (no auth) + add a scope entry
--no-workflow Skip writing the CI workflows (review, command, and dismiss
under .github/workflows/)
--token-env <name[,name…]>
@@ -83,7 +83,7 @@ async function scaffold(argv: string[]): Promise<void> {
}
const root = (await repoRoot()) ?? process.cwd();
const configDir = path.join(root, CONFIG_DIRNAME);
const configDir = configDirFor(root); // an existing legacy .expo-code-review/ is kept, never doubled
// A non-default --token-env only takes effect through the review workflows,
// but existing workflow files are skipped (not rewritten) without --force —
@@ -286,7 +286,7 @@ async function scaffoldScope(argv: string[], scopeDirRaw: string): Promise<void>
);
}
const configDir = path.join(root, scopeDir, CONFIG_DIRNAME);
const configDir = configDirFor(path.join(root, scopeDir));
await mkdir(configDir, { recursive: true });
const created: string[] = [];
+9 -7
View File
@@ -2,7 +2,13 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
import {
CONFIG_DIRNAME,
configDirFor,
isConfigDirPath,
stripJsonComments,
stripTrailingCommas,
} from "../config/load.js";
import { ROUTING_FILENAME } from "../config/routing.js";
import { repoRoot } from "../core/exec.js";
import { errorMessage } from "../core/util.js";
@@ -76,11 +82,7 @@ async function discoverConfigFiles(root: string): Promise<string[]> {
continue;
}
await walk(path.join(dir, entry.name));
} else if (
entry.isFile() &&
path.basename(dir) === CONFIG_DIRNAME &&
CONFIG_FILENAMES.has(entry.name)
) {
} else if (entry.isFile() && isConfigDirPath(dir) && CONFIG_FILENAMES.has(entry.name)) {
found.push(path.join(dir, entry.name));
}
}
@@ -145,7 +147,7 @@ export async function verifyConfig(
options: { expected?: string } = {},
): Promise<VerifyResult> {
const findings: VerifyFinding[] = [];
const rootConfigDir = path.join(root, CONFIG_DIRNAME);
const rootConfigDir = configDirFor(root);
const rel = (file: string): string => path.relative(root, file) || path.basename(file);
const files = await discoverConfigFiles(root);
+39 -9
View File
@@ -17,7 +17,25 @@ import type {
} from "./schema.js";
import { toolMap } from "../core/tools.js";
export const CONFIG_DIRNAME = ".expo-code-review";
/**
* The setup directory. `.expo-agents/code-review/` is the home from 0.15.0 —
* one `.expo-agents/` directory holds every Expo agent tool's per-repo files
* (code-review, verify) instead of one dot directory per tool at the root.
* The pre-0.15 `.expo-code-review/` keeps working unchanged: every lookup
* takes whichever of the two exists (new first), and scaffolding writes into
* an existing legacy directory rather than starting a second one.
*/
export const CONFIG_DIRNAME = path.join(".expo-agents", "code-review");
export const LEGACY_CONFIG_DIRNAME = ".expo-code-review";
export const CONFIG_DIRNAMES: readonly string[] = [CONFIG_DIRNAME, LEGACY_CONFIG_DIRNAME];
/** Does this directory path name a setup dir (either spelling)? */
export function isConfigDirPath(dir: string): boolean {
const normalized = path.normalize(dir);
return CONFIG_DIRNAMES.some(
(name) => normalized === name || normalized.endsWith(path.sep + name),
);
}
/** Stack config for a scope load (where `stack` is schema-rejected and absent). */
const STACK_CONFIG_DEFAULTS: LoadedConfig["stack"] = {
@@ -56,22 +74,34 @@ const RESEARCH_CONFIG_DEFAULTS: LoadedConfig["research"] = {
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
export function configDirFor(repoRoot: string): string {
return path.join(repoRoot, CONFIG_DIRNAME);
/**
* The setup dir under `base`: `.expo-agents/code-review/` when it exists, else
* the legacy `.expo-code-review/` when THAT exists, else the new name (for
* messages and for scaffolding a fresh repo). Works for the repo root and for
* scope directories alike.
*/
export function configDirFor(base: string): string {
for (const name of CONFIG_DIRNAMES) {
const candidate = path.join(base, name);
if (existsSync(candidate)) {
return candidate;
}
}
return path.join(base, CONFIG_DIRNAME);
}
/**
* Resolve the config directory: an explicit override (absolute or repo-relative)
* → the ECR_CONFIG_DIR env var → the repo's default `.expo-code-review/`. This is
* the escape hatch (graft 1); with neither override present the result is exactly
* `configDirFor(repoRoot)`, so the default behavior is byte-identical.
* → the ECR_CONFIG_DIR env var → the repo's default setup dir (new name, then
* legacy). This is the escape hatch (graft 1); with neither override present the
* result is exactly `configDirFor(repoRoot)`.
*/
export function resolveConfigDir(repoRoot: string, override?: string): string {
const chosen = override ?? process.env.ECR_CONFIG_DIR;
if (chosen) {
return path.isAbsolute(chosen) ? chosen : path.join(repoRoot, chosen);
}
return path.join(repoRoot, CONFIG_DIRNAME);
return configDirFor(repoRoot);
}
export interface LoadConfigOptions {
@@ -320,7 +350,7 @@ export function hasScopeConfig(root: string, scope: RoutingScope): boolean {
if (scope.config === ".") {
return true;
}
const dir = path.join(root, scope.config, CONFIG_DIRNAME);
const dir = configDirFor(path.join(root, scope.config));
return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
}
@@ -354,7 +384,7 @@ export async function loadScopeConfig(
`scope "${scope.name}": config "${scope.config}" resolves outside the repo checkout`,
);
}
const dir = path.join(root, scope.config, CONFIG_DIRNAME);
const dir = configDirFor(path.join(root, scope.config));
const { config } = await loadConfigDir(dir, ScopeReviewConfigSchema);
base = config;
// Non-default scopes never carry their own marker (the scope schema rejects
+21 -8
View File
@@ -92,17 +92,16 @@ const READ_TOOL_MAP: Record<string, "Read" | "Grep" | "Glob"> = {
glob: "Glob",
};
const ALL_READ_TOOLS = ["Read", "Grep", "Glob"] as const;
// @ref LLP 0003#claude-code-cli-containment [implements] — deny enumeration (not allow-only) because an empty/absent --allowedTools list default-ALLOWS reads; verified against claude 2.1.212, revisit on every CLI version bump
// @ref LLP 0003#claude-code-cli-containment [implements] — inner deny layer behind --tools, because an empty/absent --allowedTools list default-ALLOWS reads; verified against claude 2.1.212, tool list re-checked against 2.1.280 (ListAgents added)
/**
* Tools never available to a review pass, whatever the role. A DENY enumeration is
* the only workable containment: permission rules cannot fail closed here — reads
* Tools never available to a review pass, whatever the role. The primary bound is
* `--tools` (see buildClaudeArgs), which loads only the granted read tools; this
* list is the inner layer behind it. Permission rules alone cannot fail closed — reads
* inside the workspace are default-ALLOWED even when an allow list is present but
* unmatched, and a `*` deny breaks tool calling outright (both verified against
* claude 2.1.212). The residual risk — a FUTURE CLI version shipping a new
* read-capable tool this list doesn't name — is bounded by pinning the CLI version
* (the scaffolded workflow installs an exact @anthropic-ai/claude-code version;
* bump it deliberately and revisit this list). Unknown names are ignored by the
* CLI, so denying tools that don't exist in a given version is harmless.
* claude 2.1.212). A FUTURE CLI version shipping a new tool this list doesn't name
* is no longer the gap it was: `--tools` does not load it. Unknown names are ignored
* by the CLI, so denying tools that don't exist in a given version is harmless.
*/
const ALWAYS_DENIED_TOOLS = [
"Bash",
@@ -117,6 +116,7 @@ const ALWAYS_DENIED_TOOLS = [
"BashOutput",
"KillShell",
"ExitPlanMode",
"ListAgents",
];
/**
@@ -273,6 +273,16 @@ export function claudeModelMatches(requested: string, actualKey: string): boolea
* tree itself (paths resolve to absolute), and `Read(~/**)` denies the whole tree
* whenever the repo lives under the home directory — the common case.
*
* `--tools` is the outer bound: it names the built-in tools the CLI LOADS, so
* everything else (including tools a future CLI version adds) never reaches the
* model. It is an availability allowlist, not a permission rule, so it fails closed
* where `--allowedTools` does not. Verified against claude 2.1.280: `dontAsk` does
* NOT stop tools that ask no permission — EnterWorktree created a git worktree,
* RemoteTrigger listed the account's cloud routines, SendMessage and Skill ran — and
* `--tools` removes them all. MCP tools (the research tools) and the CLI's own
* StructuredOutput tool (`--json-schema`) are not in the built-in set and stay
* loaded. The scoped allow rules and ALWAYS_DENIED_TOOLS stay as the inner layers.
*
* This is NOT, by itself, a boundary against a symlink committed inside the PR-head
* tree (e.g. `docs/notes.md -> ~/.claude/.credentials.json`): the permission rule
* matches the literal path ARGUMENT, which is in-tree, but Read/Grep then follow the
@@ -324,6 +334,9 @@ export function buildClaudeArgs(opts: {
"--append-system-prompt",
opts.system,
...(opts.jsonSchema ? ["--json-schema", JSON.stringify(opts.jsonSchema)] : []),
// @ref LLP 0003#claude-code-cli-containment [implements] — load only the granted read tools; "" loads none
"--tools",
enabled.join(","),
...(allowedTools.length > 0 ? ["--allowedTools", ...allowedTools] : []),
"--disallowedTools",
...deniedReadTools,
+18 -4
View File
@@ -4,7 +4,13 @@
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
import {
CONFIG_DIRNAME,
LEGACY_CONFIG_DIRNAME,
configDirFor,
stripJsonComments,
stripTrailingCommas,
} from "../config/load.js";
import { ROUTING_FILENAME } from "../config/routing.js";
import { git, pathInside } from "./exec.js";
import { matchesIgnore } from "./noise.js";
@@ -518,10 +524,18 @@ export async function discoverSetupDirs(root: string): Promise<string[]> {
continue;
}
const child = path.join(dir, entry.name);
if (entry.name === CONFIG_DIRNAME) {
if (entry.name === LEGACY_CONFIG_DIRNAME) {
found.push(child);
continue; // a setup dir never nests another
}
if (entry.name === ".expo-agents") {
// The shared Expo agent-tools dir: only its code-review/ child is ours.
const setup = path.join(dir, CONFIG_DIRNAME);
if ((await pathKind(setup)) === "dir") {
found.push(setup);
}
continue;
}
// Other dot dirs are skipped wholesale: `.claude/worktrees/` holds checkouts of
// this same repo, whose setup dirs would otherwise be swept as if they were scopes.
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
@@ -617,7 +631,7 @@ async function checkRoutingManifest(
}
const configDir = typeof entry.config === "string" ? entry.config : null;
if (configDir) {
const setupDir = path.resolve(index.root, configDir, CONFIG_DIRNAME);
const setupDir = configDirFor(path.resolve(index.root, configDir));
if (!pathInside(setupDir, index.root)) {
problems.push({
file: relative,
@@ -630,7 +644,7 @@ async function checkRoutingManifest(
file: relative,
line: 1,
kind: "structural",
problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ directory`,
problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ (or ${LEGACY_CONFIG_DIRNAME}/) directory`,
});
}
}
+46 -8
View File
@@ -20,10 +20,10 @@ import type { ResearchMcpRuntime } from "./research.js";
export const CLAUDE_CODE_ENGINE = "claude-code" as const;
/** Fixed so repo config cannot redirect the Meta credential. */
// @ref LLP 0003#muse-spark-via-meta-model-api [implements] — fixed endpoint and model allowlist
// @ref LLP 0003#muse-spark-via-meta-model-api [implements] — fixed endpoint, provider routing, and known-model metadata
export const META_MODEL_API_BASE_URL = "https://api.meta.ai/v1";
/** Supported public Muse models. Unknown ids remain visible to preflight. */
/** Known public Muse models. Other explicit `meta/...` ids use neutral metadata. */
export const META_MUSE_MODELS: Record<string, Record<string, unknown>> = {
"muse-spark-1.2": {
name: "Muse Spark 1.2",
@@ -55,6 +55,36 @@ export const META_MUSE_MODELS: Record<string, Record<string, unknown>> = {
include: ["reasoning.encrypted_content"],
},
},
"muse-spark-1.3": {
name: "Muse Spark 1.3",
reasoning: true,
limit: { context: 1_048_576, output: 1_048_576 },
modalities: {
input: ["text", "image", "pdf", "video"],
output: ["text"],
},
options: {
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
"muse-spark-1.3-contributor": {
name: "Muse Spark 1.3 Contributor",
reasoning: true,
limit: { context: 1_048_576, output: 1_048_576 },
modalities: {
input: ["text", "image", "pdf", "video"],
output: ["text"],
},
options: {
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
};
/**
@@ -265,7 +295,19 @@ export function buildOpencodeConfig(
entry.tokenEnv &&
!entry.upstream
) {
// Muse needs the Responses adapter; openai-compatible uses Chat Completions.
// Meta's Model API uses the Responses adapter; openai-compatible uses Chat
// Completions. Register every explicitly referenced `meta/…` model so a new
// Meta family/name does not require an ECR release merely to select the right
// provider. Known Muse ids get their published capabilities; unknown ids stay
// neutral and are validated by Meta on their first request.
const models: Record<string, unknown> = {};
for (const id of referencedModels) {
const slash = id.indexOf("/");
if (slash > 0 && id.slice(0, slash) === "meta" && id.length > slash + 1) {
const model = id.slice(slash + 1);
models[model] = META_MUSE_MODELS[model] ?? {};
}
}
provider.meta = {
npm: "@ai-sdk/openai",
name: "Meta Model API",
@@ -273,11 +315,7 @@ export function buildOpencodeConfig(
baseURL: META_MODEL_API_BASE_URL,
apiKey: `{env:${entry.tokenEnv}}`,
},
models: Object.fromEntries(
Object.entries(META_MUSE_MODELS).filter(([model]) =>
referencedModels.includes(`meta/${model}`),
),
),
models,
};
continue;
}
+19 -6
View File
@@ -385,14 +385,27 @@ function sourceLabel(value: string): string {
const SHORT_RATIONALE_CHARS = 160;
/**
* A rationale reduced to one plain line for the short (inlined) form: whitespace
* collapsed, every `<` escaped so a truncated HTML block (`<details>`) can never
* leave an unclosed tag in the comment, cut at a word boundary with an ellipsis.
* This excerpt is the audit trail that survives the PR author collapsing or
* resolving the inline thread, so it must always be non-empty when the rationale is.
* HTML formatting tags a model writes into a rationale (`<br>`, `<details>`, ...).
* A fixed list, not "anything in angle brackets", so a type like `Promise<void>`
* in the prose survives.
*/
const FORMATTING_TAG_RE =
/<\/?(?:a|b|blockquote|br|code|del|details|div|em|h[1-6]|hr|i|kbd|li|ol|p|pre|s|span|strong|sub|summary|sup|table|tbody|td|th|thead|tr|u|ul)\b[^<>]*>/gi;
/**
* A rationale reduced to one plain line for the short (inlined) form: HTML
* formatting tags removed (an escaped `<br>` would show as literal text),
* whitespace collapsed, every remaining `<` escaped so a truncated HTML block can
* never leave an unclosed tag in the comment, cut at a word boundary with an
* ellipsis. This excerpt is the audit trail that survives the PR author collapsing
* or resolving the inline thread, so it must always be non-empty when the rationale is.
*/
export function oneLineRationale(text: string): string {
const flat = text.replace(/</g, "&lt;").replace(/\s+/g, " ").trim();
const flat = text
.replace(FORMATTING_TAG_RE, " ")
.replace(/</g, "&lt;")
.replace(/\s+/g, " ")
.trim();
if (flat.length <= SHORT_RATIONALE_CHARS) {
return flat;
}
+1 -1
View File
@@ -7,7 +7,7 @@ alwaysRun: true
# default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
# subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
# @ref LLP 0009#config-and-prompt-templates [implements]
model: anthropic/claude-opus-5
model: anthropic/claude-opus-5-5
---
# Security & secrets
+4 -2
View File
@@ -84,7 +84,7 @@ jobs:
# The anthropic/… models run through the Claude Code CLI (claude-code
# engine). Pinned exactly — see workflow.yml (expo-code-review.yml).
- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/[email protected]12
run: npm install -g @anthropic-ai/[email protected]80
# SECURITY: the base-ref checkout includes every .expo-code-review/ config,
# whose auth.tokenEnv names the forwarded model credential. `ecr verify-config`
@@ -128,6 +128,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: review-run-log-pr${{ github.event.issue.number }}
path: .expo-code-review/.runs/reviews.jsonl
path: |
.expo-agents/code-review/.runs/reviews.jsonl
.expo-code-review/.runs/reviews.jsonl
if-no-files-found: ignore
retention-days: 14
+4 -2
View File
@@ -115,7 +115,7 @@ jobs:
# engine). Pinned exactly — see workflow.yml (expo-code-review.yml).
- name: Install Claude Code CLI
if: steps.cmd.outputs.run == 'true'
run: npm install -g @anthropic-ai/[email protected]12
run: npm install -g @anthropic-ai/[email protected]80
# SECURITY: the base-ref checkout above includes every .expo-code-review/
# config.jsonc + routing.jsonc, whose auth.tokenEnv names the env var the CLI
@@ -183,6 +183,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: review-run-log-pr${{ github.event.issue.number }}
path: .expo-code-review/.runs/reviews.jsonl
path: |
.expo-agents/code-review/.runs/reviews.jsonl
.expo-code-review/.runs/reviews.jsonl
if-no-files-found: ignore
retention-days: 14
+3 -3
View File
@@ -5,13 +5,13 @@
// below for the credential. Override per-agent via frontmatter in the agent's
// markdown, or at runtime with the REVIEWER_MODEL env var
// (e.g. REVIEWER_MODEL=anthropic/claude-haiku-4-5).
"model": "anthropic/claude-sonnet-5",
"model": "anthropic/claude-opus-5-5",
// Agents: every markdown file in agents/ is one reviewer (id = filename).
// Add or remove files to change the roster — no list needed here.
// shared.md (prepended to every agent + coordinator) and coordinator.md are
// reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
// e.g. `---\nmodel: anthropic/claude-opus-5\n---`.
// e.g. `---\nmodel: anthropic/claude-opus-5-5\n---`.
// @ref LLP 0009#config-and-prompt-templates [implements] — suggestions off by default, not a schema limit
"policy": {
@@ -87,7 +87,7 @@
// "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" }
//
// Muse Spark via the public Meta Model API Responses endpoint:
// set models to `meta/muse-spark-1.2` and configure:
// set models to `meta/muse-spark-1.3` and configure:
// "auth": { "providers": {
// "meta": { "mode": "api-key", "tokenEnv": "META_API_KEY" }
// } }
+1 -1
View File
@@ -5,7 +5,7 @@
# than the small serial-tail latency it adds (no repo tools, one bounded pass).
# Override with a cheaper model if you'd rather trade decision quality for latency.
# @ref LLP 0009#config-and-prompt-templates [implements]
model: anthropic/claude-opus-5
model: anthropic/claude-opus-5-5
---
# Coordinator — consolidation & decision
+1 -1
View File
@@ -4,7 +4,7 @@
{
// Default model for every agent in this scope. Override per-agent via frontmatter,
// or at runtime with REVIEWER_MODEL.
"model": "anthropic/claude-sonnet-5",
"model": "anthropic/claude-opus-5-5",
// Agents: every markdown file in agents/ beside this file is one reviewer for this
// scope (id = filename). shared.md + coordinator.md are this scope's prompts.
+4 -2
View File
@@ -72,7 +72,7 @@ jobs:
# would silently drift on every CI run; bump it deliberately. Remove this
# step if you switch every model to an OpenCode-served provider.
- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/[email protected]12
run: npm install -g @anthropic-ai/[email protected]80
# SECURITY: the TRUSTED BASE checkout above includes every
# .expo-code-review/config.jsonc + routing.jsonc, whose auth.tokenEnv names the
@@ -133,6 +133,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: review-run-log-pr${{ github.event.pull_request.number }}
path: .expo-code-review/.runs/reviews.jsonl
path: |
.expo-agents/code-review/.runs/reviews.jsonl
.expo-code-review/.runs/reviews.jsonl
if-no-files-found: ignore
retention-days: 14